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            #[cfg(any(target_os = "linux", target_os = "android"))]
145            {
146                libc::renameat2(
147                    libc::AT_FDCWD,
148                    source.as_ptr(),
149                    libc::AT_FDCWD,
150                    destination.as_ptr(),
151                    libc::RENAME_NOREPLACE,
152                )
153            }
154            #[cfg(target_vendor = "apple")]
155            {
156                libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL)
157            }
158        };
159        if result == 0 {
160            return Some(Ok(()));
161        }
162        let error = io::Error::last_os_error();
163        match error.raw_os_error() {
164            Some(code)
165                if code == libc::ENOSYS
166                    || code == libc::EINVAL
167                    || code == libc::ENOTSUP
168                    || code == libc::EOPNOTSUPP =>
169            {
170                None
171            }
172            _ => Some(Err(error)),
173        }
174    }
175}
176
177#[cfg(windows)]
178mod platform {
179    use std::io;
180    use std::os::windows::ffi::OsStrExt;
181    use std::path::Path;
182
183    /// `path` as a NUL-terminated wide string. Like `std::fs`, a long absolute
184    /// path takes the verbatim `\\?\` form so it can exceed `MAX_PATH`.
185    pub(super) fn wide(path: &Path) -> Vec<u16> {
186        use std::ffi::OsString;
187        use std::path::{Component, Prefix};
188        // The limit is 260 including the NUL; directories leave room for an
189        // 8.3 name, hence std's 248.
190        const LONG: usize = 248;
191        let plain = || path.as_os_str().encode_wide().chain(Some(0)).collect();
192        if path.as_os_str().len() < LONG || !path.is_absolute() {
193            return plain();
194        }
195        // Verbatim paths are not normalised by Windows: build one from the
196        // components, with backslashes, and leave `..` to the plain form.
197        let mut verbatim = OsString::new();
198        for component in path.components() {
199            match component {
200                Component::Prefix(prefix) => match prefix.kind() {
201                    Prefix::Disk(drive) => {
202                        verbatim.push(format!(r"\\?\{}:", char::from(drive)));
203                    }
204                    Prefix::UNC(server, share) => {
205                        verbatim.push(r"\\?\UNC\");
206                        verbatim.push(server);
207                        verbatim.push(r"\");
208                        verbatim.push(share);
209                    }
210                    Prefix::Verbatim(_)
211                    | Prefix::VerbatimDisk(_)
212                    | Prefix::VerbatimUNC(..)
213                    | Prefix::DeviceNS(_) => return plain(),
214                },
215                Component::RootDir | Component::CurDir => {}
216                Component::Normal(name) => {
217                    verbatim.push(r"\");
218                    verbatim.push(name);
219                }
220                Component::ParentDir => return plain(),
221            }
222        }
223        verbatim.encode_wide().chain(Some(0)).collect()
224    }
225
226    pub(super) fn rename_noreplace(source: &Path, destination: &Path) -> Option<io::Result<()>> {
227        let (source, destination) = (wide(source), wide(destination));
228        // SAFETY: NUL-terminated UTF-16 buffers that outlive the call. Zero
229        // flags exclude REPLACE_EXISTING and COPY_ALLOWED.
230        let ok = unsafe {
231            windows_sys::Win32::Storage::FileSystem::MoveFileExW(
232                source.as_ptr(),
233                destination.as_ptr(),
234                0,
235            )
236        };
237        Some(if ok == 0 {
238            Err(io::Error::last_os_error())
239        } else {
240            Ok(())
241        })
242    }
243}
244
245#[cfg(not(any(
246    target_os = "linux",
247    target_os = "android",
248    target_vendor = "apple",
249    windows
250)))]
251mod platform {
252    pub(super) fn rename_noreplace(
253        _source: &std::path::Path,
254        _destination: &std::path::Path,
255    ) -> Option<std::io::Result<()>> {
256        None
257    }
258}
259
260/// Remove a file, symlink or directory tree without following symlinks.
261pub(crate) fn remove_path(path: &Path) -> io::Result<()> {
262    match fs::symlink_metadata(path) {
263        Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path),
264        Ok(_) => remove_link_or_file(path),
265        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
266        Err(error) => Err(error),
267    }
268}
269
270fn remove_link_or_file(path: &Path) -> io::Result<()> {
271    #[cfg(windows)]
272    {
273        use std::os::windows::fs::FileTypeExt;
274        if fs::symlink_metadata(path)?.file_type().is_symlink_dir() {
275            return fs::remove_dir(path);
276        }
277    }
278    fs::remove_file(path)
279}
280
281/// Recreate the symlink `source` (pointing at `target`) at `destination`.
282pub(crate) fn symlink(target: &Path, source: &Path, destination: &Path) -> io::Result<()> {
283    #[cfg(unix)]
284    {
285        let _ = source;
286        std::os::unix::fs::symlink(target, destination)
287    }
288    #[cfg(windows)]
289    {
290        if fs::metadata(source).is_ok_and(|metadata| metadata.is_dir()) {
291            std::os::windows::fs::symlink_dir(target, destination)
292        } else {
293            std::os::windows::fs::symlink_file(target, destination)
294        }
295    }
296}
297
298/// Reserve an owner-only directory beside `sibling`, on the same filesystem,
299/// so its contents can later be published with a single rename.
300pub(crate) fn staging_dir(sibling: &Path, prefix: &str) -> io::Result<tempfile::TempDir> {
301    let parent = parent_or_current(sibling);
302    let mut builder = tempfile::Builder::new();
303    builder.prefix(prefix);
304    #[cfg(unix)]
305    {
306        use std::os::unix::fs::PermissionsExt;
307        builder.permissions(fs::Permissions::from_mode(0o700));
308    }
309    builder.tempdir_in(parent)
310}
311
312/// Prefix of every staging directory this crate creates.
313pub const STAGING_PREFIX: &str = ".gravityfile-";
314
315pub(crate) fn parent_or_current(path: &Path) -> &Path {
316    path.parent()
317        .filter(|parent| !parent.as_os_str().is_empty())
318        .unwrap_or(Path::new("."))
319}
320
321/// Canonicalize the longest existing prefix of `path` and append the rest
322/// lexically, so not-yet-created destinations can be compared with sources.
323pub(crate) fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
324    if let Ok(path) = dunce::canonicalize(path) {
325        return Some(path);
326    }
327    let absolute = if path.is_absolute() {
328        path.to_path_buf()
329    } else {
330        std::env::current_dir().ok()?.join(path)
331    };
332    let mut existing = absolute.as_path();
333    let mut suffix: Vec<OsString> = Vec::new();
334    while let Some(name) = existing.file_name() {
335        suffix.push(name.to_owned());
336        existing = existing.parent()?;
337        if let Ok(mut canonical) = dunce::canonicalize(existing) {
338            canonical.extend(suffix.iter().rev());
339            return Some(canonical);
340        }
341    }
342    None
343}
344
345/// Whether `destination` equals or lies beneath the directory `source`,
346/// resolving symlinked ancestors on both sides.
347pub(crate) fn is_within_directory(source: &Path, destination: &Path) -> bool {
348    if !fs::symlink_metadata(source).is_ok_and(|metadata| metadata.is_dir()) {
349        return false;
350    }
351    match (
352        canonicalize_lenient(source),
353        canonicalize_lenient(destination),
354    ) {
355        (Some(source), Some(destination)) => destination.starts_with(source),
356        _ => false,
357    }
358}
359
360/// Whether two paths name the same filesystem object. A final symlink is
361/// compared as itself, not as what it points to.
362pub(crate) fn same_object(a: &Path, b: &Path) -> bool {
363    match (file_id(a), file_id(b)) {
364        (Ok(a), Ok(b)) => a == b,
365        _ => false,
366    }
367}
368
369/// Device and inode.
370#[cfg(unix)]
371fn file_id(path: &Path) -> io::Result<(u64, u64)> {
372    use std::os::unix::fs::MetadataExt;
373    let metadata = fs::symlink_metadata(path)?;
374    Ok((metadata.dev(), metadata.ino()))
375}
376
377/// Volume serial number and file index, Windows' device and inode. Comparing
378/// sizes and times instead would take two files written in the same clock
379/// tick for one.
380#[cfg(windows)]
381fn file_id(path: &Path) -> io::Result<(u64, u64)> {
382    use std::os::windows::fs::OpenOptionsExt;
383    use std::os::windows::io::AsRawHandle;
384    use windows_sys::Win32::Storage::FileSystem::{
385        BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
386        GetFileInformationByHandle,
387    };
388    // No access rights are needed to read the identity; backup semantics
389    // allow opening directories, and the reparse flag keeps symlinks unfollowed.
390    let file = fs::OpenOptions::new()
391        .access_mode(0)
392        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
393        .open(path)?;
394    // SAFETY: an all-zero BY_HANDLE_FILE_INFORMATION is a valid value.
395    let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
396    // SAFETY: the handle stays open for the call and `info` is a valid
397    // out-pointer.
398    if unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut info) } == 0 {
399        return Err(io::Error::last_os_error());
400    }
401    let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow);
402    Ok((u64::from(info.dwVolumeSerialNumber), index))
403}
404
405#[cfg(not(any(unix, windows)))]
406fn file_id(_path: &Path) -> io::Result<(u64, u64)> {
407    Err(io::Error::new(
408        io::ErrorKind::Unsupported,
409        "file identity is not available on this platform",
410    ))
411}
412
413/// True when a regular file still matches an earlier observation.
414pub(crate) fn unchanged_file(before: &fs::Metadata, after: &fs::Metadata) -> bool {
415    if !after.is_file()
416        || before.len() != after.len()
417        || before.modified().ok() != after.modified().ok()
418    {
419        return false;
420    }
421    #[cfg(unix)]
422    {
423        use std::os::unix::fs::MetadataExt;
424        if before.ino() != after.ino()
425            || before.dev() != after.dev()
426            || before.ctime() != after.ctime()
427            || before.ctime_nsec() != after.ctime_nsec()
428        {
429            return false;
430        }
431    }
432    true
433}
434
435/// Resolve `path` component by component, following symlinks the way the
436/// kernel would, and report whether the result stays inside `root`.
437///
438/// Unlike a lexical normalization this handles chained links such as
439/// `a -> ..` followed by `b -> a/../..`. Missing trailing components are
440/// resolved lexically. `root` must be canonical.
441pub(crate) fn resolves_within(root: &Path, path: &Path) -> io::Result<bool> {
442    const MAX_LINKS: usize = 40;
443    let mut resolved = PathBuf::new();
444    let mut pending: Vec<OsString> = Vec::new();
445    push_components(&mut pending, path, &mut resolved);
446    let mut links = 0;
447    while let Some(component) = pending.pop() {
448        if component == ".." {
449            resolved.pop();
450            continue;
451        }
452        let candidate = resolved.join(&component);
453        match fs::symlink_metadata(&candidate) {
454            Ok(metadata) if metadata.file_type().is_symlink() => {
455                links += 1;
456                if links > MAX_LINKS {
457                    return Err(io::Error::other("too many levels of symbolic links"));
458                }
459                let target = fs::read_link(&candidate)?;
460                push_components(&mut pending, &target, &mut resolved);
461            }
462            Ok(_) => resolved = candidate,
463            Err(error) if error.kind() == io::ErrorKind::NotFound => resolved = candidate,
464            Err(error) => return Err(error),
465        }
466    }
467    Ok(resolved.starts_with(root))
468}
469
470/// Queue `path`'s components (reversed, for popping). Absolute paths reset
471/// the resolution to their root.
472fn push_components(pending: &mut Vec<OsString>, path: &Path, resolved: &mut PathBuf) {
473    let mut parts = Vec::new();
474    for component in path.components() {
475        match component {
476            Component::Prefix(_) | Component::RootDir => {
477                *resolved = PathBuf::from(component.as_os_str());
478                parts.clear();
479            }
480            Component::CurDir => {}
481            Component::ParentDir => parts.push(OsString::from("..")),
482            Component::Normal(name) => parts.push(name.to_owned()),
483        }
484    }
485    pending.extend(parts.into_iter().rev());
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn rename_noreplace_refuses_existing_destination() {
494        let dir = tempfile::tempdir().unwrap();
495        let (a, b) = (dir.path().join("a"), dir.path().join("b"));
496        fs::write(&a, "a").unwrap();
497        fs::write(&b, "b").unwrap();
498        assert!(matches!(
499            rename_noreplace(&a, &b),
500            Err(RenameError::DestinationExists)
501        ));
502        assert_eq!(fs::read_to_string(&b).unwrap(), "b");
503        fs::remove_file(&b).unwrap();
504        rename_noreplace(&a, &b).unwrap();
505        assert_eq!(fs::read_to_string(&b).unwrap(), "a");
506    }
507
508    #[test]
509    fn fallback_is_exclusive_for_files_and_directories() {
510        let dir = tempfile::tempdir().unwrap();
511        let file = dir.path().join("file");
512        let taken = dir.path().join("taken");
513        fs::write(&file, "data").unwrap();
514        fs::write(&taken, "keep").unwrap();
515        assert!(matches!(
516            rename_noreplace_fallback(&file, &taken),
517            Err(RenameError::DestinationExists)
518        ));
519        rename_noreplace_fallback(&file, &dir.path().join("moved")).unwrap();
520        assert!(!file.exists());
521
522        let tree = dir.path().join("tree");
523        fs::create_dir_all(tree.join("inner")).unwrap();
524        let occupied = dir.path().join("occupied");
525        fs::create_dir(&occupied).unwrap();
526        fs::write(occupied.join("keep"), "x").unwrap();
527        assert!(rename_noreplace_fallback(&tree, &occupied).is_err());
528        assert!(occupied.join("keep").exists());
529        rename_noreplace_fallback(&tree, &dir.path().join("tree2")).unwrap();
530        assert!(dir.path().join("tree2/inner").is_dir());
531    }
532
533    #[test]
534    fn lenient_canonicalization_appends_missing_suffix() {
535        let dir = tempfile::tempdir().unwrap();
536        let root = dunce::canonicalize(dir.path()).unwrap();
537        assert_eq!(
538            canonicalize_lenient(&dir.path().join("missing/child")).unwrap(),
539            root.join("missing/child")
540        );
541        assert!(is_within_directory(dir.path(), &dir.path().join("x/y")));
542        assert!(!is_within_directory(&dir.path().join("x"), dir.path()));
543    }
544
545    #[cfg(unix)]
546    #[test]
547    fn chained_symlinks_are_resolved_like_the_kernel() {
548        let dir = tempfile::tempdir().unwrap();
549        fs::create_dir_all(dir.path().join("root/d")).unwrap();
550        let root = dunce::canonicalize(dir.path().join("root")).unwrap();
551        std::os::unix::fs::symlink("..", root.join("d/l1")).unwrap();
552        // Lexically `d/l1/../..` is `root`, but `l1` resolves to `root`, so
553        // the kernel lands on root's parent.
554        std::os::unix::fs::symlink("l1/../..", root.join("d/esc")).unwrap();
555        assert!(resolves_within(&root, &root.join("d/l1")).unwrap());
556        assert!(!resolves_within(&root, &root.join("d/esc")).unwrap());
557        assert!(resolves_within(&root, &root.join("d/missing/file")).unwrap());
558    }
559}