Skip to main content

gravityfile_ops/
transfer.rs

1//! Blocking copy engine shared by copy and cross-filesystem move.
2//!
3//! A transfer copies one top-level item into a private staging directory on
4//! the destination filesystem and publishes it with a single no-clobber
5//! rename, so a cancelled or failed transfer never leaves a partial item at
6//! the user-visible path. Problems with individual entries inside a directory
7//! (unreadable files, sockets, FIFOs) are recorded and skipped rather than
8//! discarding everything that did copy.
9
10use std::collections::{HashMap, HashSet};
11use std::fs::{self, File};
12use std::io::{self, Read};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15
16use tokio_util::sync::CancellationToken;
17
18use crate::OperationError;
19use crate::fs::{RenameError, cancelled, rename_noreplace, staging_dir, unchanged_file};
20
21const CHUNK: usize = 1024 * 1024;
22const MAX_RETAINED_ERRORS: usize = 1000;
23
24/// How an existing destination is disposed of when the user chooses to
25/// overwrite it.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ReplacePolicy {
28    /// Move the existing item to the system Trash first (recoverable).
29    #[default]
30    Trash,
31    /// Delete the existing item permanently.
32    Permanent,
33}
34
35/// Metadata preservation settings for a transfer.
36#[derive(Debug, Clone, Copy)]
37pub(crate) struct Preserve {
38    pub times: bool,
39    pub xattrs: bool,
40}
41
42/// One transfer's state: cancellation, live byte counter, and the non-fatal
43/// per-entry errors collected along the way.
44pub(crate) struct Transfer<'a> {
45    token: &'a CancellationToken,
46    bytes: &'a AtomicU64,
47    preserve: Preserve,
48    errors: Vec<OperationError>,
49    omitted_errors: usize,
50    /// First destination path for each multiply-linked source inode.
51    links: HashMap<(u64, u64), PathBuf>,
52}
53
54impl<'a> Transfer<'a> {
55    pub fn new(token: &'a CancellationToken, bytes: &'a AtomicU64, preserve: Preserve) -> Self {
56        Self {
57            token,
58            bytes,
59            preserve,
60            errors: Vec::new(),
61            omitted_errors: 0,
62            links: HashMap::new(),
63        }
64    }
65
66    /// Non-fatal errors, with an omission record when the list was capped.
67    pub fn into_errors(self) -> Vec<OperationError> {
68        let mut errors = self.errors;
69        if self.omitted_errors > 0 {
70            errors.push(OperationError::omitted(self.omitted_errors));
71        }
72        errors
73    }
74
75    fn record(&mut self, path: &Path, message: impl Into<String>) {
76        if self.errors.len() < MAX_RETAINED_ERRORS {
77            self.errors
78                .push(OperationError::new(path.to_path_buf(), message));
79        } else {
80            self.omitted_errors += 1;
81        }
82    }
83
84    fn check(&self) -> io::Result<()> {
85        if self.token.is_cancelled() {
86            Err(cancelled())
87        } else {
88            Ok(())
89        }
90    }
91
92    /// Copy `source` to the not-yet-existing `destination` through staging.
93    ///
94    /// With `replace`, an existing destination is disposed of (per the
95    /// policy) only after the new copy is complete.
96    pub fn copy_staged(
97        &mut self,
98        source: &Path,
99        destination: &Path,
100        replace: Option<ReplacePolicy>,
101    ) -> io::Result<()> {
102        let staging = staging_dir(destination, ".gravityfile-copy-")?;
103        let payload = staging.path().join("payload");
104        self.copy_entry(source, &payload)?;
105        self.check()?;
106        if let Some(policy) = replace
107            && fs::symlink_metadata(destination).is_ok()
108        {
109            dispose(destination, policy)?;
110        }
111        rename_noreplace(&payload, destination).map_err(|error| match error {
112            RenameError::DestinationExists => io::Error::new(
113                io::ErrorKind::AlreadyExists,
114                format!("'{}' appeared while copying", destination.display()),
115            ),
116            other => other.into(),
117        })?;
118        if let Err(error) = staging.close() {
119            tracing::warn!(%error, "copy published; staging cleanup incomplete");
120        }
121        Ok(())
122    }
123
124    /// Copy one entry of any type. Errors returned here are fatal for the
125    /// whole top-level item; problems with nested entries are recorded.
126    fn copy_entry(&mut self, source: &Path, destination: &Path) -> io::Result<()> {
127        self.check()?;
128        let metadata = fs::symlink_metadata(source)?;
129        let file_type = metadata.file_type();
130        if file_type.is_symlink() {
131            self.copy_symlink(source, destination)
132        } else if file_type.is_dir() {
133            self.copy_tree(source, destination)
134        } else if file_type.is_file() {
135            self.copy_file(source, destination, &metadata)
136        } else {
137            Err(io::Error::new(
138                io::ErrorKind::Unsupported,
139                "sockets, FIFOs and device files cannot be copied",
140            ))
141        }
142    }
143
144    fn copy_symlink(&mut self, source: &Path, destination: &Path) -> io::Result<()> {
145        let target = fs::read_link(source)?;
146        crate::fs::symlink(&target, source, destination)?;
147        if self.preserve.times {
148            let metadata = fs::symlink_metadata(source)?;
149            filetime::set_symlink_file_times(
150                destination,
151                filetime::FileTime::from_last_access_time(&metadata),
152                filetime::FileTime::from_last_modification_time(&metadata),
153            )?;
154        }
155        Ok(())
156    }
157
158    fn copy_tree(&mut self, source: &Path, destination: &Path) -> io::Result<()> {
159        let mut pending = vec![(source.to_path_buf(), destination.to_path_buf())];
160        let mut finished: Vec<(PathBuf, PathBuf, fs::Metadata)> = Vec::new();
161        let mut visited: HashSet<(u64, u64)> = HashSet::new();
162        let mut is_root = true;
163
164        while let Some((source_dir, dest_dir)) = pending.pop() {
165            self.check()?;
166            let metadata = match fs::symlink_metadata(&source_dir) {
167                Ok(metadata) if metadata.is_dir() => metadata,
168                Ok(_) if !is_root => {
169                    self.record(&source_dir, "changed type while copying; skipped");
170                    continue;
171                }
172                Ok(_) => return Err(io::Error::other("source directory changed type")),
173                Err(error) if !is_root => {
174                    self.record(&source_dir, error.to_string());
175                    continue;
176                }
177                Err(error) => return Err(error),
178            };
179            if let Some(identity) = identity(&metadata)
180                && !visited.insert(identity)
181            {
182                self.record(&source_dir, "directory reached twice (mount loop); skipped");
183                continue;
184            }
185            // The root must be created; nested directories that fail are
186            // recorded and skipped.
187            match fs::create_dir(&dest_dir) {
188                Ok(()) => {}
189                Err(error) if is_root => return Err(error),
190                Err(error) => {
191                    self.record(&dest_dir, format!("cannot create directory: {error}"));
192                    continue;
193                }
194            }
195            is_root = false;
196            finished.push((source_dir.clone(), dest_dir.clone(), metadata));
197
198            let entries = match fs::read_dir(&source_dir) {
199                Ok(entries) => entries,
200                Err(error) => {
201                    self.record(&source_dir, format!("cannot read directory: {error}"));
202                    continue;
203                }
204            };
205            for entry in entries {
206                self.check()?;
207                let entry = match entry {
208                    Ok(entry) => entry,
209                    Err(error) => {
210                        self.record(&source_dir, format!("cannot read entry: {error}"));
211                        continue;
212                    }
213                };
214                let path = entry.path();
215                let target = dest_dir.join(entry.file_name());
216                let file_type = match entry.file_type() {
217                    Ok(file_type) => file_type,
218                    Err(error) => {
219                        self.record(&path, error.to_string());
220                        continue;
221                    }
222                };
223                if file_type.is_dir() {
224                    pending.push((path, target));
225                    continue;
226                }
227                let result = if file_type.is_symlink() {
228                    self.copy_symlink(&path, &target)
229                } else if file_type.is_file() {
230                    fs::symlink_metadata(&path)
231                        .and_then(|metadata| self.copy_file(&path, &target, &metadata))
232                } else {
233                    self.record(&path, "special file (socket, FIFO or device) skipped");
234                    continue;
235                };
236                match result {
237                    Ok(()) => {}
238                    Err(error) if error.kind() == io::ErrorKind::Interrupted => return Err(error),
239                    Err(error) => {
240                        let _ = fs::remove_file(&target);
241                        self.record(&path, error.to_string());
242                    }
243                }
244            }
245        }
246
247        // Apply modes and times bottom-up, after contents exist: a read-only
248        // directory must not prevent its own population.
249        for (source_dir, directory, metadata) in finished.into_iter().rev() {
250            self.check()?;
251            if self.preserve.xattrs {
252                copy_xattrs(&source_dir, &directory);
253            }
254            if self.preserve.times {
255                let _ = filetime::set_file_times(
256                    &directory,
257                    filetime::FileTime::from_last_access_time(&metadata),
258                    filetime::FileTime::from_last_modification_time(&metadata),
259                );
260            }
261            if let Err(error) = fs::set_permissions(&directory, metadata.permissions()) {
262                self.record(&directory, format!("cannot restore permissions: {error}"));
263            }
264        }
265        Ok(())
266    }
267
268    fn copy_file(
269        &mut self,
270        source: &Path,
271        destination: &Path,
272        metadata: &fs::Metadata,
273    ) -> io::Result<()> {
274        if let Some(key) = identity(metadata).filter(|_| link_count(metadata) > 1) {
275            if let Some(first) = self.links.get(&key) {
276                if fs::hard_link(first, destination).is_ok() {
277                    return Ok(());
278                }
279            } else {
280                self.links.insert(key, destination.to_path_buf());
281            }
282        }
283
284        // Copy-on-write clone: instant on APFS, Btrfs, XFS, ReFS. The
285        // destination lives in private staging, so cleanup after a failed
286        // attempt cannot touch anything else.
287        if metadata.len() > 0 && reflink_copy::reflink(source, destination).is_ok() {
288            self.bytes.fetch_add(metadata.len(), Ordering::Relaxed);
289            self.finish_file(source, destination, metadata, cfg!(target_vendor = "apple"))?;
290            return Ok(());
291        }
292        let _ = fs::remove_file(destination);
293
294        let mut input = open_regular(source)?;
295        let opened = input.metadata()?;
296        if !unchanged_file(metadata, &opened) {
297            return Err(io::Error::other("file changed while opening"));
298        }
299        let mut output = fs::OpenOptions::new()
300            .write(true)
301            .create_new(true)
302            .open(destination)?;
303        self.copy_contents(&mut input, &mut output, opened.len())?;
304        if !unchanged_file(&opened, &input.metadata()?) {
305            return Err(io::Error::other("file changed while copying"));
306        }
307        output.set_permissions(opened.permissions())?;
308        drop(output);
309        self.finish_file(source, destination, &opened, false)
310    }
311
312    fn finish_file(
313        &self,
314        source: &Path,
315        destination: &Path,
316        metadata: &fs::Metadata,
317        metadata_cloned: bool,
318    ) -> io::Result<()> {
319        if self.preserve.xattrs && !metadata_cloned {
320            copy_xattrs(source, destination);
321        }
322        if self.preserve.times {
323            filetime::set_file_times(
324                destination,
325                filetime::FileTime::from_last_access_time(metadata),
326                filetime::FileTime::from_last_modification_time(metadata),
327            )?;
328        }
329        Ok(())
330    }
331
332    /// Copy exactly `length` bytes in cancellable chunks.
333    fn copy_contents(&self, input: &mut File, output: &mut File, length: u64) -> io::Result<()> {
334        let mut remaining = length;
335        #[cfg(any(target_os = "linux", target_os = "android"))]
336        {
337            // `io::copy` between files uses copy_file_range/sendfile.
338            while remaining > 0 {
339                self.check()?;
340                let chunk = remaining.min(CHUNK as u64);
341                let copied = io::copy(&mut Read::by_ref(input).take(chunk), output)?;
342                if copied == 0 {
343                    return Err(io::Error::other("file shrank while copying"));
344                }
345                remaining -= copied;
346                self.bytes.fetch_add(copied, Ordering::Relaxed);
347            }
348        }
349        #[cfg(not(any(target_os = "linux", target_os = "android")))]
350        {
351            use std::io::{Seek, Write};
352            let mut buffer = vec![0u8; (length.clamp(1, CHUNK as u64)) as usize];
353            let mut sparse = cfg!(unix);
354            while remaining > 0 {
355                self.check()?;
356                let amount = remaining.min(buffer.len() as u64) as usize;
357                input.read_exact(&mut buffer[..amount])?;
358                if sparse && buffer[..amount].iter().all(|byte| *byte == 0) {
359                    if output.seek(io::SeekFrom::Current(amount as i64)).is_err() {
360                        sparse = false;
361                        output.write_all(&buffer[..amount])?;
362                    }
363                } else {
364                    output.write_all(&buffer[..amount])?;
365                }
366                remaining -= amount as u64;
367                self.bytes.fetch_add(amount as u64, Ordering::Relaxed);
368            }
369            // A trailing hole is only materialized by extending the length.
370            output.set_len(length)?;
371        }
372        let mut probe = [0u8; 1];
373        if input.read(&mut probe)? != 0 {
374            return Err(io::Error::other("file grew while copying"));
375        }
376        Ok(())
377    }
378}
379
380/// Dispose of an existing destination before replacing it.
381pub(crate) fn dispose(path: &Path, policy: ReplacePolicy) -> io::Result<()> {
382    match policy {
383        ReplacePolicy::Trash => crate::delete::trash_context()
384            .delete(path)
385            .map_err(|error| {
386                io::Error::other(format!(
387                    "could not move the existing item to the Trash ({error}); it was left in place"
388                ))
389            }),
390        ReplacePolicy::Permanent => crate::fs::remove_path(path),
391    }
392}
393
394fn open_regular(path: &Path) -> io::Result<File> {
395    let mut options = fs::OpenOptions::new();
396    options.read(true);
397    #[cfg(unix)]
398    {
399        use std::os::unix::fs::OpenOptionsExt;
400        // Never follow a symlink swapped in after inspection, and never block
401        // on a FIFO swapped in for a regular file.
402        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
403    }
404    let file = options.open(path)?;
405    if !file.metadata()?.is_file() {
406        return Err(io::Error::other("not a regular file"));
407    }
408    Ok(file)
409}
410
411#[cfg(unix)]
412fn identity(metadata: &fs::Metadata) -> Option<(u64, u64)> {
413    use std::os::unix::fs::MetadataExt;
414    Some((metadata.dev(), metadata.ino()))
415}
416
417#[cfg(not(unix))]
418fn identity(_metadata: &fs::Metadata) -> Option<(u64, u64)> {
419    None
420}
421
422#[cfg(unix)]
423fn link_count(metadata: &fs::Metadata) -> u64 {
424    use std::os::unix::fs::MetadataExt;
425    metadata.nlink()
426}
427
428#[cfg(not(unix))]
429fn link_count(_metadata: &fs::Metadata) -> u64 {
430    1
431}
432
433/// Best-effort extended attribute copy (Finder tags, quarantine, SELinux
434/// labels...). Filesystems without xattr support are silently skipped.
435#[cfg(unix)]
436fn copy_xattrs(source: &Path, destination: &Path) {
437    let Ok(names) = xattr::list(source) else {
438        return;
439    };
440    for name in names {
441        if let Ok(Some(value)) = xattr::get(source, &name)
442            && let Err(error) = xattr::set(destination, &name, &value)
443        {
444            tracing::debug!(path = %destination.display(), ?name, %error, "xattr not copied");
445        }
446    }
447}
448
449#[cfg(not(unix))]
450fn copy_xattrs(_source: &Path, _destination: &Path) {}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn run(source: &Path, destination: &Path) -> (io::Result<()>, Vec<OperationError>, u64) {
457        let token = CancellationToken::new();
458        let bytes = AtomicU64::new(0);
459        let mut transfer = Transfer::new(
460            &token,
461            &bytes,
462            Preserve {
463                times: true,
464                xattrs: true,
465            },
466        );
467        let result = transfer.copy_staged(source, destination, None);
468        (result, transfer.into_errors(), bytes.into_inner())
469    }
470
471    #[test]
472    fn copies_tree_with_contents_and_counts_bytes() {
473        let dir = tempfile::tempdir().unwrap();
474        let source = dir.path().join("src");
475        fs::create_dir_all(source.join("a/b")).unwrap();
476        fs::write(source.join("a/b/file"), vec![7u8; 3 * CHUNK + 5]).unwrap();
477        fs::write(source.join("top"), "x").unwrap();
478        let destination = dir.path().join("dst");
479        let (result, errors, bytes) = run(&source, &destination);
480        result.unwrap();
481        assert!(errors.is_empty(), "{errors:?}");
482        assert_eq!(bytes, 3 * CHUNK as u64 + 6);
483        assert_eq!(
484            fs::read(destination.join("a/b/file")).unwrap().len(),
485            3 * CHUNK + 5
486        );
487        assert_eq!(fs::read_to_string(destination.join("top")).unwrap(), "x");
488        assert!(!dir.path().read_dir().unwrap().any(|entry| {
489            entry
490                .unwrap()
491                .file_name()
492                .to_string_lossy()
493                .starts_with(".gravityfile-")
494        }));
495    }
496
497    #[cfg(unix)]
498    #[test]
499    fn unreadable_and_special_entries_are_skipped_not_fatal() {
500        use std::os::unix::fs::PermissionsExt;
501        let dir = tempfile::tempdir().unwrap();
502        let source = dir.path().join("src");
503        fs::create_dir(&source).unwrap();
504        fs::write(source.join("ok"), "fine").unwrap();
505        let secret = source.join("secret");
506        fs::write(&secret, "no").unwrap();
507        fs::set_permissions(&secret, fs::Permissions::from_mode(0o000)).unwrap();
508        let fifo =
509            std::ffi::CString::new(source.join("fifo").into_os_string().into_encoded_bytes())
510                .unwrap();
511        // SAFETY: valid NUL-terminated path.
512        assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
513        let destination = dir.path().join("dst");
514        let (result, errors, _) = run(&source, &destination);
515        fs::set_permissions(&secret, fs::Permissions::from_mode(0o600)).unwrap();
516        result.unwrap();
517        assert_eq!(fs::read_to_string(destination.join("ok")).unwrap(), "fine");
518        assert!(fs::symlink_metadata(destination.join("fifo")).is_err());
519        assert!(errors.iter().any(|error| error.path.ends_with("fifo")));
520        // Root may read anything; only assert the skip when it actually failed.
521        if !destination.join("secret").exists() {
522            assert!(errors.iter().any(|error| error.path.ends_with("secret")));
523        }
524    }
525
526    #[cfg(unix)]
527    #[test]
528    fn hard_links_and_read_only_directories_are_preserved() {
529        use std::os::unix::fs::{MetadataExt, PermissionsExt};
530        let dir = tempfile::tempdir().unwrap();
531        let source = dir.path().join("src");
532        let locked = source.join("locked");
533        fs::create_dir_all(&locked).unwrap();
534        fs::write(locked.join("a"), "shared").unwrap();
535        fs::hard_link(locked.join("a"), locked.join("b")).unwrap();
536        fs::set_permissions(&locked, fs::Permissions::from_mode(0o555)).unwrap();
537        let destination = dir.path().join("dst");
538        let (result, errors, _) = run(&source, &destination);
539        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
540        result.unwrap();
541        assert!(errors.is_empty(), "{errors:?}");
542        let copied = destination.join("locked");
543        assert_eq!(
544            fs::metadata(&copied).unwrap().permissions().mode() & 0o777,
545            0o555
546        );
547        assert_eq!(
548            fs::metadata(copied.join("a")).unwrap().ino(),
549            fs::metadata(copied.join("b")).unwrap().ino()
550        );
551        fs::set_permissions(&copied, fs::Permissions::from_mode(0o755)).unwrap();
552    }
553
554    #[test]
555    fn cancellation_leaves_nothing_at_the_destination() {
556        let dir = tempfile::tempdir().unwrap();
557        let source = dir.path().join("src");
558        fs::create_dir(&source).unwrap();
559        fs::write(source.join("file"), "x").unwrap();
560        let token = CancellationToken::new();
561        token.cancel();
562        let bytes = AtomicU64::new(0);
563        let mut transfer = Transfer::new(
564            &token,
565            &bytes,
566            Preserve {
567                times: false,
568                xattrs: false,
569            },
570        );
571        let destination = dir.path().join("dst");
572        let error = transfer
573            .copy_staged(&source, &destination, None)
574            .unwrap_err();
575        assert_eq!(error.kind(), io::ErrorKind::Interrupted);
576        assert!(!destination.exists());
577        assert_eq!(dir.path().read_dir().unwrap().count(), 1);
578    }
579
580    #[test]
581    fn replace_disposes_existing_item_only_after_copy_completes() {
582        let dir = tempfile::tempdir().unwrap();
583        let source = dir.path().join("new");
584        let destination = dir.path().join("old");
585        fs::write(&source, "new").unwrap();
586        fs::write(&destination, "old").unwrap();
587        let token = CancellationToken::new();
588        let bytes = AtomicU64::new(0);
589        let mut transfer = Transfer::new(
590            &token,
591            &bytes,
592            Preserve {
593                times: true,
594                xattrs: false,
595            },
596        );
597        transfer
598            .copy_staged(&source, &destination, Some(ReplacePolicy::Permanent))
599            .unwrap();
600        assert_eq!(fs::read_to_string(&destination).unwrap(), "new");
601    }
602}