Skip to main content

laminar_core/
durable_fs.rs

1//! Crash-durable same-directory file publication.
2//!
3//! Unix durability requires syncing directory metadata after publication.
4//! Windows directory handles do not provide the same portable contract, so
5//! publication uses `MoveFileExW(MOVEFILE_WRITE_THROUGH)` instead. Unsupported
6//! platforms fail closed rather than claiming durable output after a no-op.
7
8use std::io;
9use std::path::Path;
10
11/// Creates every missing directory component with a crash-durable parent
12/// publication before returning.
13///
14/// Existing directories are re-synchronized on Unix. Windows treats them as a
15/// pre-established namespace because it has no documented directory flush
16/// primitive. A concurrent creator is accepted only after the winner is
17/// verified to be a real directory.
18///
19/// # Errors
20///
21/// Returns an I/O error if a component is not a directory, the path escapes
22/// through `..`, or the platform cannot durably publish a missing component.
23pub fn ensure_durable_directory(path: &Path) -> io::Result<()> {
24    if path.as_os_str().is_empty() {
25        return Err(io::Error::new(
26            io::ErrorKind::InvalidInput,
27            "durable directory path is empty",
28        ));
29    }
30    let path = if path.is_absolute() {
31        path.to_path_buf()
32    } else {
33        std::env::current_dir()?.join(path)
34    };
35
36    match std::fs::symlink_metadata(&path) {
37        Ok(metadata) if metadata.file_type().is_dir() => {
38            #[cfg(unix)]
39            return establish_existing_directory(&path);
40            #[cfg(not(unix))]
41            {
42                establish_existing_directory(&path);
43                return Ok(());
44            }
45        }
46        Ok(_) => {
47            return Err(io::Error::new(
48                io::ErrorKind::AlreadyExists,
49                format!("{} exists and is not a directory", path.display()),
50            ));
51        }
52        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
53        Err(error) => return Err(error),
54    }
55
56    let mut ancestor = path.as_path();
57    loop {
58        ancestor = ancestor.parent().ok_or_else(|| {
59            io::Error::new(
60                io::ErrorKind::NotFound,
61                "durable directory has no existing ancestor",
62            )
63        })?;
64        match std::fs::symlink_metadata(ancestor) {
65            Ok(metadata) if metadata.file_type().is_dir() => break,
66            Ok(_) => {
67                return Err(io::Error::new(
68                    io::ErrorKind::AlreadyExists,
69                    format!("{} exists and is not a directory", ancestor.display()),
70                ));
71            }
72            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
73            Err(error) => return Err(error),
74        }
75    }
76
77    let relative = path.strip_prefix(ancestor).map_err(|_| {
78        io::Error::new(
79            io::ErrorKind::InvalidInput,
80            "durable directory is outside its existing ancestor",
81        )
82    })?;
83    let mut current = ancestor.to_path_buf();
84    for component in relative.components() {
85        let std::path::Component::Normal(component) = component else {
86            return Err(io::Error::new(
87                io::ErrorKind::InvalidInput,
88                "durable directory contains a non-normal path component",
89            ));
90        };
91        let destination = current.join(component);
92        publish_directory_component(&current, &destination)?;
93        current = destination;
94    }
95    Ok(())
96}
97
98#[cfg(unix)]
99fn establish_existing_directory(path: &Path) -> io::Result<()> {
100    sync_directory(path)?;
101    if let Some(parent) = path.parent() {
102        sync_directory(parent)?;
103    }
104    Ok(())
105}
106
107#[cfg(not(unix))]
108fn establish_existing_directory(_path: &Path) {}
109
110/// Whether publication may replace an existing destination.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum DurableRenameMode {
113    /// Refuse to replace an existing destination.
114    NoReplace,
115    /// Atomically replace an existing destination when present.
116    Replace,
117}
118
119/// Publishes a synced temporary file under a same-directory destination and
120/// does not return until the rename metadata is durably flushed.
121///
122/// # Errors
123///
124/// Returns an I/O error when paths are not in the same directory, publication
125/// or durability fails, the destination exists in [`DurableRenameMode::NoReplace`]
126/// mode, or the target platform has no proven implementation.
127pub fn durable_rename(
128    source: &Path,
129    destination: &Path,
130    mode: DurableRenameMode,
131) -> io::Result<()> {
132    let source_parent = source.parent().unwrap_or_else(|| Path::new("."));
133    let destination_parent = destination.parent().unwrap_or_else(|| Path::new("."));
134    if source_parent != destination_parent {
135        return Err(io::Error::new(
136            io::ErrorKind::InvalidInput,
137            "durable rename requires source and destination in the same directory",
138        ));
139    }
140
141    durable_rename_platform(source, destination, destination_parent, mode)
142}
143
144#[cfg(unix)]
145fn publish_directory_component(parent: &Path, destination: &Path) -> io::Result<()> {
146    match std::fs::create_dir(destination) {
147        Ok(()) => {}
148        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
149            let metadata = std::fs::symlink_metadata(destination)?;
150            if !metadata.file_type().is_dir() {
151                return Err(error);
152            }
153        }
154        Err(error) => return Err(error),
155    }
156    sync_directory(destination)?;
157    sync_directory(parent)
158}
159
160#[cfg(windows)]
161fn publish_directory_component(parent: &Path, destination: &Path) -> io::Result<()> {
162    let temporary = loop {
163        let candidate = parent.join(format!(
164            ".laminardb-directory#{}",
165            uuid::Uuid::new_v4().as_u128()
166        ));
167        match std::fs::create_dir(&candidate) {
168            Ok(()) => break candidate,
169            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
170            Err(error) => return Err(error),
171        }
172    };
173    let cleanup = TemporaryDirectory(temporary.clone());
174    match durable_rename(&temporary, destination, DurableRenameMode::NoReplace) {
175        Ok(()) => {}
176        Err(error) => match std::fs::symlink_metadata(destination) {
177            Ok(metadata) if metadata.file_type().is_dir() => {}
178            _ => {
179                return Err(error);
180            }
181        },
182    }
183    drop(cleanup);
184    Ok(())
185}
186
187#[cfg(windows)]
188struct TemporaryDirectory(std::path::PathBuf);
189
190#[cfg(windows)]
191impl Drop for TemporaryDirectory {
192    fn drop(&mut self) {
193        let _ = std::fs::remove_dir(&self.0);
194    }
195}
196
197#[cfg(not(any(unix, windows)))]
198fn publish_directory_component(_parent: &Path, _destination: &Path) -> io::Result<()> {
199    Err(io::Error::new(
200        io::ErrorKind::Unsupported,
201        "no proven crash-durable directory publication primitive for this platform",
202    ))
203}
204
205#[cfg(unix)]
206fn durable_rename_platform(
207    source: &Path,
208    destination: &Path,
209    parent: &Path,
210    mode: DurableRenameMode,
211) -> io::Result<()> {
212    match mode {
213        DurableRenameMode::Replace => {
214            std::fs::rename(source, destination)?;
215            sync_directory(parent)
216        }
217        DurableRenameMode::NoReplace => {
218            // `hard_link` atomically creates the destination or fails with
219            // AlreadyExists. Sync the new name before removing the temporary
220            // name, then sync that removal. A crash at either boundary leaves
221            // at least one complete name for recovery.
222            std::fs::hard_link(source, destination)?;
223            sync_directory(parent)?;
224            std::fs::remove_file(source)?;
225            sync_directory(parent)
226        }
227    }
228}
229
230#[cfg(unix)]
231pub(crate) fn sync_directory(parent: &Path) -> io::Result<()> {
232    std::fs::File::open(parent)?.sync_all()
233}
234
235#[cfg(windows)]
236fn durable_rename_platform(
237    source: &Path,
238    destination: &Path,
239    _parent: &Path,
240    mode: DurableRenameMode,
241) -> io::Result<()> {
242    use std::os::windows::ffi::OsStrExt;
243    use windows_sys::Win32::Storage::FileSystem::{
244        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
245    };
246
247    let source_wide: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
248    let destination_wide: Vec<u16> = destination
249        .as_os_str()
250        .encode_wide()
251        .chain(Some(0))
252        .collect();
253    let mut flags = MOVEFILE_WRITE_THROUGH;
254    if mode == DurableRenameMode::Replace {
255        flags |= MOVEFILE_REPLACE_EXISTING;
256    }
257
258    // SAFETY: both buffers are NUL-terminated and remain alive for the call.
259    let moved = unsafe { MoveFileExW(source_wide.as_ptr(), destination_wide.as_ptr(), flags) };
260    if moved == 0 {
261        Err(io::Error::last_os_error())
262    } else {
263        Ok(())
264    }
265}
266
267#[cfg(not(any(unix, windows)))]
268fn durable_rename_platform(
269    _source: &Path,
270    _destination: &Path,
271    _parent: &Path,
272    _mode: DurableRenameMode,
273) -> io::Result<()> {
274    Err(io::Error::new(
275        io::ErrorKind::Unsupported,
276        "no proven crash-durable rename primitive for this platform",
277    ))
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn no_replace_publishes_and_refuses_existing_destination() {
286        let directory = tempfile::tempdir().unwrap();
287        let source = directory.path().join("source.tmp");
288        let destination = directory.path().join("final");
289        std::fs::write(&source, b"first").unwrap();
290        durable_rename(&source, &destination, DurableRenameMode::NoReplace).unwrap();
291        assert_eq!(std::fs::read(&destination).unwrap(), b"first");
292        assert!(!source.exists());
293
294        std::fs::write(&source, b"second").unwrap();
295        assert!(durable_rename(&source, &destination, DurableRenameMode::NoReplace).is_err());
296        assert_eq!(std::fs::read(&destination).unwrap(), b"first");
297        assert!(source.exists());
298    }
299
300    #[test]
301    fn replace_publishes_new_bytes() {
302        let directory = tempfile::tempdir().unwrap();
303        let source = directory.path().join("source.tmp");
304        let destination = directory.path().join("final");
305        std::fs::write(&destination, b"old").unwrap();
306        std::fs::write(&source, b"new").unwrap();
307        durable_rename(&source, &destination, DurableRenameMode::Replace).unwrap();
308        assert_eq!(std::fs::read(&destination).unwrap(), b"new");
309        assert!(!source.exists());
310    }
311
312    #[test]
313    fn creates_nested_durable_directories_and_is_idempotent() {
314        let directory = tempfile::tempdir().unwrap();
315        let nested = directory.path().join("one").join("two").join("three");
316        ensure_durable_directory(&nested).unwrap();
317        assert!(nested.is_dir());
318        ensure_durable_directory(&nested).unwrap();
319    }
320
321    #[test]
322    fn rejects_a_file_in_the_directory_path() {
323        let directory = tempfile::tempdir().unwrap();
324        let file = directory.path().join("file");
325        std::fs::write(&file, b"not a directory").unwrap();
326        let error = ensure_durable_directory(&file.join("child")).unwrap_err();
327        assert!(
328            matches!(
329                error.kind(),
330                io::ErrorKind::AlreadyExists | io::ErrorKind::NotADirectory
331            ),
332            "unexpected error for a file in the directory path: {error}"
333        );
334    }
335
336    #[cfg(windows)]
337    #[test]
338    fn directory_publication_accepts_a_competing_real_directory() {
339        let directory = tempfile::tempdir().unwrap();
340        let destination = directory.path().join("winner");
341        std::fs::create_dir(&destination).unwrap();
342
343        publish_directory_component(directory.path(), &destination).unwrap();
344
345        assert!(destination.is_dir());
346        assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
347    }
348}