Skip to main content

gravityfile_ops/
fs.rs

1//! Filesystem primitives shared by every operation.
2//!
3//! Everything here is synchronous and must run on a blocking thread.
4
5use std::ffi::OsString;
6use std::fs;
7use std::io;
8use std::path::{Component, Path, PathBuf};
9
10/// Message used when cooperative cancellation aborts blocking work.
11pub(crate) const CANCELLED: &str = "operation cancelled";
12
13pub(crate) fn cancelled() -> io::Error {
14    io::Error::new(io::ErrorKind::Interrupted, CANCELLED)
15}
16
17/// Why a no-replace rename did not succeed.
18///
19/// Callers must distinguish these: treating "destination exists" as a generic
20/// failure and falling back to a copy would silently destroy the existing item.
21#[derive(Debug, thiserror::Error)]
22pub(crate) enum RenameError {
23    #[error("destination already exists")]
24    DestinationExists,
25    #[error("source and destination are on different filesystems")]
26    CrossDevice,
27    #[error("{0}")]
28    Io(io::Error),
29}
30
31impl RenameError {
32    fn classify(error: io::Error) -> Self {
33        if is_cross_device(&error) {
34            Self::CrossDevice
35        } else if error.kind() == io::ErrorKind::AlreadyExists {
36            Self::DestinationExists
37        } else {
38            Self::Io(error)
39        }
40    }
41}
42
43impl From<RenameError> for io::Error {
44    fn from(error: RenameError) -> Self {
45        match error {
46            RenameError::DestinationExists => {
47                io::Error::new(io::ErrorKind::AlreadyExists, "destination already exists")
48            }
49            RenameError::CrossDevice => io::Error::new(
50                io::ErrorKind::CrossesDevices,
51                "source and destination are on different filesystems",
52            ),
53            RenameError::Io(error) => error,
54        }
55    }
56}
57
58#[cfg(unix)]
59fn is_cross_device(error: &io::Error) -> bool {
60    error.raw_os_error() == Some(libc::EXDEV)
61}
62
63#[cfg(windows)]
64fn is_cross_device(error: &io::Error) -> bool {
65    const ERROR_NOT_SAME_DEVICE: i32 = 17;
66    error.raw_os_error() == Some(ERROR_NOT_SAME_DEVICE)
67}
68
69#[cfg(not(any(unix, windows)))]
70fn is_cross_device(_error: &io::Error) -> bool {
71    false
72}
73
74/// Rename `source` to `destination`, never replacing an existing destination.
75///
76/// Uses `renameat2(RENAME_NOREPLACE)` on Linux, `renamex_np(RENAME_EXCL)` on
77/// Apple platforms and `MoveFileExW` without `REPLACE_EXISTING` on Windows.
78/// Filesystems that reject the flag (NFS, SMB, exFAT, many FUSE mounts) fall
79/// back to primitives that are still exclusive: `link` + `unlink` for files and
80/// symlinks, `mkdir` + `rename` over the freshly created empty directory for
81/// directories.
82pub(crate) fn rename_noreplace(source: &Path, destination: &Path) -> Result<(), RenameError> {
83    match platform::rename_noreplace(source, destination) {
84        Some(result) => result.map_err(RenameError::classify),
85        None => rename_noreplace_fallback(source, destination),
86    }
87}
88
89fn rename_noreplace_fallback(source: &Path, destination: &Path) -> Result<(), RenameError> {
90    let metadata = fs::symlink_metadata(source).map_err(RenameError::Io)?;
91    if metadata.is_dir() {
92        // `mkdir` is exclusive; POSIX `rename` atomically replaces the empty
93        // directory it created, so no foreign content can be clobbered.
94        fs::create_dir(destination).map_err(RenameError::classify)?;
95        if let Err(error) = fs::rename(source, destination) {
96            let _ = fs::remove_dir(destination);
97            return Err(RenameError::classify(error));
98        }
99        return Ok(());
100    }
101    match fs::hard_link(source, destination) {
102        Ok(()) => fs::remove_file(source).map_err(|error| {
103            // Both names now refer to the data; report the stray source.
104            RenameError::Io(io::Error::new(
105                error.kind(),
106                format!("renamed by linking, but the original name remains: {error}"),
107            ))
108        }),
109        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
110            Err(RenameError::DestinationExists)
111        }
112        Err(error) if is_cross_device(&error) => Err(RenameError::CrossDevice),
113        // Filesystems without hard links (FAT, exFAT): a check-then-rename
114        // is the best the platform offers.
115        Err(_) => {
116            if fs::symlink_metadata(destination).is_ok() {
117                return Err(RenameError::DestinationExists);
118            }
119            fs::rename(source, destination).map_err(RenameError::classify)
120        }
121    }
122}
123
124#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
125mod platform {
126    use std::ffi::CString;
127    use std::io;
128    use std::os::unix::ffi::OsStrExt;
129    use std::path::Path;
130
131    /// `None` when the filesystem does not support an atomic no-replace rename.
132    pub(super) fn rename_noreplace(source: &Path, destination: &Path) -> Option<io::Result<()>> {
133        let (Ok(source), Ok(destination)) = (
134            CString::new(source.as_os_str().as_bytes()),
135            CString::new(destination.as_os_str().as_bytes()),
136        ) else {
137            return Some(Err(io::Error::new(
138                io::ErrorKind::InvalidInput,
139                "path contains a NUL byte",
140            )));
141        };
142        // SAFETY: both C strings are NUL-terminated and outlive the call.
143        let result = unsafe {
144            // The system call itself: musl, which the static Linux build
145            // uses, has no renameat2 wrapper. A kernel without the call
146            // reports ENOSYS, which falls back below.
147            #[cfg(any(target_os = "linux", target_os = "android"))]
148            {
149                libc::syscall(
150                    libc::SYS_renameat2,
151                    libc::AT_FDCWD,
152                    source.as_ptr(),
153                    libc::AT_FDCWD,
154                    destination.as_ptr(),
155                    libc::RENAME_NOREPLACE,
156                ) as libc::c_int
157            }
158            #[cfg(target_vendor = "apple")]
159            {
160                libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL)
161            }
162        };
163        if result == 0 {
164            return Some(Ok(()));
165        }
166        let error = io::Error::last_os_error();
167        match error.raw_os_error() {
168            Some(code)
169                if code == libc::ENOSYS
170                    || code == libc::EINVAL
171                    || code == libc::ENOTSUP
172                    || code == libc::EOPNOTSUPP =>
173            {
174                None
175            }
176            _ => Some(Err(error)),
177        }
178    }
179}
180
181#[cfg(windows)]
182mod platform {
183    use std::io;
184    use std::os::windows::ffi::OsStrExt;
185    use std::path::Path;
186
187    /// `path` as a NUL-terminated wide string. Like `std::fs`, a long absolute
188    /// path takes the verbatim `\\?\` form so it can exceed `MAX_PATH`.
189    pub(super) fn wide(path: &Path) -> Vec<u16> {
190        use std::ffi::OsString;
191        use std::path::{Component, Prefix};
192        // The limit is 260 including the NUL; directories leave room for an
193        // 8.3 name, hence std's 248.
194        const LONG: usize = 248;
195        let plain = || path.as_os_str().encode_wide().chain(Some(0)).collect();
196        if path.as_os_str().len() < LONG || !path.is_absolute() {
197            return plain();
198        }
199        // Verbatim paths are not normalised by Windows: build one from the
200        // components, with backslashes, and leave `..` to the plain form.
201        let mut verbatim = OsString::new();
202        for component in path.components() {
203            match component {
204                Component::Prefix(prefix) => match prefix.kind() {
205                    Prefix::Disk(drive) => {
206                        verbatim.push(format!(r"\\?\{}:", char::from(drive)));
207                    }
208                    Prefix::UNC(server, share) => {
209                        verbatim.push(r"\\?\UNC\");
210                        verbatim.push(server);
211                        verbatim.push(r"\");
212                        verbatim.push(share);
213                    }
214                    Prefix::Verbatim(_)
215                    | Prefix::VerbatimDisk(_)
216                    | Prefix::VerbatimUNC(..)
217                    | Prefix::DeviceNS(_) => return plain(),
218                },
219                Component::RootDir | Component::CurDir => {}
220                Component::Normal(name) => {
221                    verbatim.push(r"\");
222                    verbatim.push(name);
223                }
224                Component::ParentDir => return plain(),
225            }
226        }
227        verbatim.encode_wide().chain(Some(0)).collect()
228    }
229
230    pub(super) fn rename_noreplace(source: &Path, destination: &Path) -> Option<io::Result<()>> {
231        let (source, destination) = (wide(source), wide(destination));
232        // SAFETY: NUL-terminated UTF-16 buffers that outlive the call. Zero
233        // flags exclude REPLACE_EXISTING and COPY_ALLOWED.
234        let ok = unsafe {
235            windows_sys::Win32::Storage::FileSystem::MoveFileExW(
236                source.as_ptr(),
237                destination.as_ptr(),
238                0,
239            )
240        };
241        Some(if ok == 0 {
242            Err(io::Error::last_os_error())
243        } else {
244            Ok(())
245        })
246    }
247}
248
249#[cfg(not(any(
250    target_os = "linux",
251    target_os = "android",
252    target_vendor = "apple",
253    windows
254)))]
255mod platform {
256    pub(super) fn rename_noreplace(
257        _source: &std::path::Path,
258        _destination: &std::path::Path,
259    ) -> Option<std::io::Result<()>> {
260        None
261    }
262}
263
264/// Remove a file, symlink or directory tree without following symlinks.
265pub(crate) fn remove_path(path: &Path) -> io::Result<()> {
266    match fs::symlink_metadata(path) {
267        Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path),
268        Ok(_) => remove_link_or_file(path),
269        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
270        Err(error) => Err(error),
271    }
272}
273
274fn remove_link_or_file(path: &Path) -> io::Result<()> {
275    #[cfg(windows)]
276    {
277        use std::os::windows::fs::FileTypeExt;
278        if fs::symlink_metadata(path)?.file_type().is_symlink_dir() {
279            return fs::remove_dir(path);
280        }
281    }
282    fs::remove_file(path)
283}
284
285/// Recreate the symlink `source` (pointing at `target`) at `destination`.
286pub(crate) fn symlink(target: &Path, source: &Path, destination: &Path) -> io::Result<()> {
287    #[cfg(unix)]
288    {
289        let _ = source;
290        std::os::unix::fs::symlink(target, destination)
291    }
292    #[cfg(windows)]
293    {
294        if fs::metadata(source).is_ok_and(|metadata| metadata.is_dir()) {
295            std::os::windows::fs::symlink_dir(target, destination)
296        } else {
297            std::os::windows::fs::symlink_file(target, destination)
298        }
299    }
300}
301
302/// Reserve an owner-only directory beside `sibling`, on the same filesystem,
303/// so its contents can later be published with a single rename.
304pub(crate) fn staging_dir(sibling: &Path, prefix: &str) -> io::Result<tempfile::TempDir> {
305    let parent = parent_or_current(sibling);
306    let mut builder = tempfile::Builder::new();
307    builder.prefix(prefix);
308    #[cfg(unix)]
309    {
310        use std::os::unix::fs::PermissionsExt;
311        builder.permissions(fs::Permissions::from_mode(0o700));
312    }
313    builder.tempdir_in(parent)
314}
315
316/// Prefix of every staging directory this crate creates.
317pub const STAGING_PREFIX: &str = ".gravityfile-";
318
319pub(crate) fn parent_or_current(path: &Path) -> &Path {
320    path.parent()
321        .filter(|parent| !parent.as_os_str().is_empty())
322        .unwrap_or(Path::new("."))
323}
324
325/// Canonicalize the longest existing prefix of `path` and append the rest
326/// lexically, so not-yet-created destinations can be compared with sources.
327pub(crate) fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
328    if let Ok(path) = dunce::canonicalize(path) {
329        return Some(path);
330    }
331    let absolute = if path.is_absolute() {
332        path.to_path_buf()
333    } else {
334        std::env::current_dir().ok()?.join(path)
335    };
336    let mut existing = absolute.as_path();
337    let mut suffix: Vec<OsString> = Vec::new();
338    while let Some(name) = existing.file_name() {
339        suffix.push(name.to_owned());
340        existing = existing.parent()?;
341        if let Ok(mut canonical) = dunce::canonicalize(existing) {
342            canonical.extend(suffix.iter().rev());
343            return Some(canonical);
344        }
345    }
346    None
347}
348
349/// Whether `destination` equals or lies beneath the directory `source`,
350/// resolving symlinked ancestors on both sides.
351pub(crate) fn is_within_directory(source: &Path, destination: &Path) -> bool {
352    if !fs::symlink_metadata(source).is_ok_and(|metadata| metadata.is_dir()) {
353        return false;
354    }
355    match (
356        canonicalize_lenient(source),
357        canonicalize_lenient(destination),
358    ) {
359        (Some(source), Some(destination)) => destination.starts_with(source),
360        _ => false,
361    }
362}
363
364/// Whether two paths name the same filesystem object. A final symlink is
365/// compared as itself, not as what it points to.
366pub(crate) fn same_object(a: &Path, b: &Path) -> bool {
367    match (file_id(a), file_id(b)) {
368        (Ok(a), Ok(b)) => a == b,
369        _ => false,
370    }
371}
372
373/// Device and inode.
374#[cfg(unix)]
375fn file_id(path: &Path) -> io::Result<(u64, u64)> {
376    use std::os::unix::fs::MetadataExt;
377    let metadata = fs::symlink_metadata(path)?;
378    Ok((metadata.dev(), metadata.ino()))
379}
380
381/// Volume serial number and file index, Windows' device and inode. Comparing
382/// sizes and times instead would take two files written in the same clock
383/// tick for one.
384#[cfg(windows)]
385fn file_id(path: &Path) -> io::Result<(u64, u64)> {
386    use std::os::windows::fs::OpenOptionsExt;
387    use std::os::windows::io::AsRawHandle;
388    use windows_sys::Win32::Storage::FileSystem::{
389        BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
390        GetFileInformationByHandle,
391    };
392    // No access rights are needed to read the identity; backup semantics
393    // allow opening directories, and the reparse flag keeps symlinks unfollowed.
394    let file = fs::OpenOptions::new()
395        .access_mode(0)
396        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
397        .open(path)?;
398    // SAFETY: an all-zero BY_HANDLE_FILE_INFORMATION is a valid value.
399    let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
400    // SAFETY: the handle stays open for the call and `info` is a valid
401    // out-pointer.
402    if unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut info) } == 0 {
403        return Err(io::Error::last_os_error());
404    }
405    let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow);
406    Ok((u64::from(info.dwVolumeSerialNumber), index))
407}
408
409#[cfg(not(any(unix, windows)))]
410fn file_id(_path: &Path) -> io::Result<(u64, u64)> {
411    Err(io::Error::new(
412        io::ErrorKind::Unsupported,
413        "file identity is not available on this platform",
414    ))
415}
416
417/// True when a regular file still matches an earlier observation.
418pub(crate) fn unchanged_file(before: &fs::Metadata, after: &fs::Metadata) -> bool {
419    if !after.is_file()
420        || before.len() != after.len()
421        || before.modified().ok() != after.modified().ok()
422    {
423        return false;
424    }
425    #[cfg(unix)]
426    {
427        use std::os::unix::fs::MetadataExt;
428        if before.ino() != after.ino()
429            || before.dev() != after.dev()
430            || before.ctime() != after.ctime()
431            || before.ctime_nsec() != after.ctime_nsec()
432        {
433            return false;
434        }
435    }
436    true
437}
438
439/// Resolve `path` component by component, following symlinks the way the
440/// kernel would, and report whether the result stays inside `root`.
441///
442/// Unlike a lexical normalization this handles chained links such as
443/// `a -> ..` followed by `b -> a/../..`. Missing trailing components are
444/// resolved lexically. `root` must be canonical.
445pub(crate) fn resolves_within(root: &Path, path: &Path) -> io::Result<bool> {
446    const MAX_LINKS: usize = 40;
447    let mut resolved = PathBuf::new();
448    let mut pending: Vec<OsString> = Vec::new();
449    push_components(&mut pending, path, &mut resolved);
450    let mut links = 0;
451    while let Some(component) = pending.pop() {
452        if component == ".." {
453            resolved.pop();
454            continue;
455        }
456        let candidate = resolved.join(&component);
457        match fs::symlink_metadata(&candidate) {
458            Ok(metadata) if metadata.file_type().is_symlink() => {
459                links += 1;
460                if links > MAX_LINKS {
461                    return Err(io::Error::other("too many levels of symbolic links"));
462                }
463                let target = fs::read_link(&candidate)?;
464                push_components(&mut pending, &target, &mut resolved);
465            }
466            Ok(_) => resolved = candidate,
467            Err(error) if error.kind() == io::ErrorKind::NotFound => resolved = candidate,
468            Err(error) => return Err(error),
469        }
470    }
471    Ok(resolved.starts_with(root))
472}
473
474/// Queue `path`'s components (reversed, for popping). Absolute paths reset
475/// the resolution to their root.
476fn push_components(pending: &mut Vec<OsString>, path: &Path, resolved: &mut PathBuf) {
477    let mut parts = Vec::new();
478    for component in path.components() {
479        match component {
480            Component::Prefix(_) | Component::RootDir => {
481                *resolved = PathBuf::from(component.as_os_str());
482                parts.clear();
483            }
484            Component::CurDir => {}
485            Component::ParentDir => parts.push(OsString::from("..")),
486            Component::Normal(name) => parts.push(name.to_owned()),
487        }
488    }
489    pending.extend(parts.into_iter().rev());
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn rename_noreplace_refuses_existing_destination() {
498        let dir = tempfile::tempdir().unwrap();
499        let (a, b) = (dir.path().join("a"), dir.path().join("b"));
500        fs::write(&a, "a").unwrap();
501        fs::write(&b, "b").unwrap();
502        assert!(matches!(
503            rename_noreplace(&a, &b),
504            Err(RenameError::DestinationExists)
505        ));
506        assert_eq!(fs::read_to_string(&b).unwrap(), "b");
507        fs::remove_file(&b).unwrap();
508        rename_noreplace(&a, &b).unwrap();
509        assert_eq!(fs::read_to_string(&b).unwrap(), "a");
510    }
511
512    #[test]
513    fn fallback_is_exclusive_for_files_and_directories() {
514        let dir = tempfile::tempdir().unwrap();
515        let file = dir.path().join("file");
516        let taken = dir.path().join("taken");
517        fs::write(&file, "data").unwrap();
518        fs::write(&taken, "keep").unwrap();
519        assert!(matches!(
520            rename_noreplace_fallback(&file, &taken),
521            Err(RenameError::DestinationExists)
522        ));
523        rename_noreplace_fallback(&file, &dir.path().join("moved")).unwrap();
524        assert!(!file.exists());
525
526        let tree = dir.path().join("tree");
527        fs::create_dir_all(tree.join("inner")).unwrap();
528        let occupied = dir.path().join("occupied");
529        fs::create_dir(&occupied).unwrap();
530        fs::write(occupied.join("keep"), "x").unwrap();
531        assert!(rename_noreplace_fallback(&tree, &occupied).is_err());
532        assert!(occupied.join("keep").exists());
533        rename_noreplace_fallback(&tree, &dir.path().join("tree2")).unwrap();
534        assert!(dir.path().join("tree2/inner").is_dir());
535    }
536
537    #[test]
538    fn lenient_canonicalization_appends_missing_suffix() {
539        let dir = tempfile::tempdir().unwrap();
540        let root = dunce::canonicalize(dir.path()).unwrap();
541        assert_eq!(
542            canonicalize_lenient(&dir.path().join("missing/child")).unwrap(),
543            root.join("missing/child")
544        );
545        assert!(is_within_directory(dir.path(), &dir.path().join("x/y")));
546        assert!(!is_within_directory(&dir.path().join("x"), dir.path()));
547    }
548
549    #[cfg(unix)]
550    #[test]
551    fn chained_symlinks_are_resolved_like_the_kernel() {
552        let dir = tempfile::tempdir().unwrap();
553        fs::create_dir_all(dir.path().join("root/d")).unwrap();
554        let root = dunce::canonicalize(dir.path().join("root")).unwrap();
555        std::os::unix::fs::symlink("..", root.join("d/l1")).unwrap();
556        // Lexically `d/l1/../..` is `root`, but `l1` resolves to `root`, so
557        // the kernel lands on root's parent.
558        std::os::unix::fs::symlink("l1/../..", root.join("d/esc")).unwrap();
559        assert!(resolves_within(&root, &root.join("d/l1")).unwrap());
560        assert!(!resolves_within(&root, &root.join("d/esc")).unwrap());
561        assert!(resolves_within(&root, &root.join("d/missing/file")).unwrap());
562    }
563}