1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum ReplacePolicy {
28 #[default]
30 Trash,
31 Permanent,
33}
34
35#[derive(Debug, Clone, Copy)]
37pub(crate) struct Preserve {
38 pub times: bool,
39 pub xattrs: bool,
40}
41
42pub(crate) struct Transfer<'a> {
45 token: &'a CancellationToken,
46 bytes: &'a AtomicU64,
47 preserve: Preserve,
48 errors: Vec<OperationError>,
49 omitted_errors: usize,
50 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 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 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 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 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 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 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 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 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 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
380pub(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 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#[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 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 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}