1use std::fs;
23use std::io;
24use std::os::unix::fs::PermissionsExt;
25use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::time::{Duration, Instant, SystemTime};
28
29#[derive(Debug, thiserror::Error)]
30pub enum SnapshotError {
31 #[error("io error at {path}: {source}")]
32 Io {
33 path: String,
34 #[source]
35 source: io::Error,
36 },
37 #[error("store object {hash} is corrupt (content does not match its hash); restore refused")]
38 CorruptObject { hash: String },
39 #[error("store object {hash} is missing; restore refused")]
40 MissingObject { hash: String },
41 #[error(
42 "manifest entry path {0:?} is not a safe relative path (escapes the root); restore refused"
43 )]
44 UnsafeManifestPath(PathBuf),
45}
46
47fn rel_is_safe(rel: &Path) -> bool {
54 use std::path::Component;
55 rel.components()
56 .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
57}
58
59fn normalize_abs(path: &Path) -> PathBuf {
64 use std::path::Component;
65 let abs = if path.is_absolute() {
66 path.to_path_buf()
67 } else {
68 std::env::current_dir()
69 .unwrap_or_else(|_| PathBuf::from("/"))
70 .join(path)
71 };
72 let mut out = PathBuf::new();
73 for c in abs.components() {
74 match c {
75 Component::ParentDir => {
76 out.pop();
77 }
78 Component::CurDir => {}
79 other => out.push(other.as_os_str()),
80 }
81 }
82 out
83}
84
85pub struct SkipPolicy {
104 names: Vec<String>,
105 gitignore: Option<ignore::gitignore::Gitignore>,
106}
107
108impl SkipPolicy {
109 pub fn new(names: Vec<String>, repo_root: Option<&Path>) -> Self {
111 let gitignore = repo_root.and_then(|root| {
112 let mut b = ignore::gitignore::GitignoreBuilder::new(root);
113 b.add(root.join(".gitignore"));
116 b.add(root.join(".git/info/exclude"));
117 b.build().ok()
118 });
119 Self { names, gitignore }
120 }
121
122 pub fn none() -> Self {
124 Self {
125 names: Vec::new(),
126 gitignore: None,
127 }
128 }
129
130 fn skips(&self, dir: &Path, name: &str) -> bool {
131 if !self.names.iter().any(|n| n == name) {
132 return false;
133 }
134 match &self.gitignore {
135 Some(gi) => gi.matched_path_or_any_parents(dir, true).is_ignore(),
137 None => true,
139 }
140 }
141}
142
143fn io_err(path: &Path, source: io::Error) -> SnapshotError {
144 SnapshotError::Io {
145 path: path.display().to_string(),
146 source,
147 }
148}
149
150#[derive(Debug, Clone, Copy, Default)]
151pub struct StoreOptions {
152 pub force_copy: bool,
155}
156
157#[derive(Debug, Clone, Copy)]
158pub struct Limits {
159 pub max_files: u64,
160 pub max_bytes: u64,
161 pub max_duration: Option<Duration>,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
169pub enum Root {
170 Present,
172 Absent,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
177pub enum EntryKind {
178 File {
179 hash: String,
180 len: u64,
181 mode: u32,
182 mtime: SystemTime,
183 xattrs: Vec<(String, Vec<u8>)>,
184 },
185 Dir {
186 mode: u32,
187 },
188 Symlink {
189 target: PathBuf,
190 },
191 Fifo {
195 mode: u32,
196 },
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
200pub struct Entry {
201 pub rel: PathBuf,
203 pub kind: EntryKind,
204}
205
206pub const MANIFEST_SCHEMA: u32 = 1;
210
211#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
212pub struct Manifest {
213 #[serde(default)]
215 pub schema: u32,
216 pub path: PathBuf,
218 pub root: Root,
219 pub entries: Vec<Entry>,
221 pub truncated: bool,
223 pub skipped: u64,
225 pub warnings: Vec<String>,
229 #[serde(default)]
234 pub skipped_dirs: Vec<PathBuf>,
235}
236
237#[derive(Debug, Default)]
238pub struct RestoreReport {
239 pub files_restored: u64,
240 pub warnings: Vec<String>,
241}
242
243pub struct Store {
244 objects: PathBuf,
245 tmp: PathBuf,
246 opts: StoreOptions,
247}
248
249fn next_unique() -> u64 {
252 static SEQ: AtomicU64 = AtomicU64::new(0);
253 SEQ.fetch_add(1, Ordering::Relaxed)
254}
255
256impl Store {
257 pub fn open(root: impl Into<PathBuf>) -> Result<Self, SnapshotError> {
258 let force_copy = std::env::var_os("DOOVER_FORCE_COPY").is_some();
259 Self::open_with(root, StoreOptions { force_copy })
260 }
261
262 pub fn open_with(root: impl Into<PathBuf>, opts: StoreOptions) -> Result<Self, SnapshotError> {
263 let root = root.into();
264 let objects = root.join("objects");
265 let tmp = root.join("tmp");
266 fs::create_dir_all(&objects).map_err(|e| io_err(&objects, e))?;
267 fs::create_dir_all(&tmp).map_err(|e| io_err(&tmp, e))?;
268 Ok(Self { objects, tmp, opts })
269 }
270
271 pub fn snapshot(
274 &self,
275 path: &Path,
276 limits: Option<&Limits>,
277 ) -> Result<Manifest, SnapshotError> {
278 self.snapshot_scoped(path, limits, &[], &SkipPolicy::none())
279 }
280
281 pub fn snapshot_excluding(
282 &self,
283 path: &Path,
284 limits: Option<&Limits>,
285 exclude: &[PathBuf],
286 ) -> Result<Manifest, SnapshotError> {
287 self.snapshot_scoped(path, limits, exclude, &SkipPolicy::none())
288 }
289
290 pub fn snapshot_scoped(
305 &self,
306 path: &Path,
307 limits: Option<&Limits>,
308 exclude: &[PathBuf],
309 skip: &SkipPolicy,
310 ) -> Result<Manifest, SnapshotError> {
311 let mut manifest = Manifest {
312 schema: MANIFEST_SCHEMA,
313 path: path.to_path_buf(),
314 root: Root::Present,
315 entries: Vec::new(),
316 truncated: false,
317 skipped: 0,
318 warnings: Vec::new(),
319 skipped_dirs: Vec::new(),
320 };
321 let meta = match fs::symlink_metadata(path) {
322 Ok(m) => m,
323 Err(e) if e.kind() == io::ErrorKind::NotFound => {
324 manifest.root = Root::Absent;
325 return Ok(manifest);
326 }
327 Err(e) => return Err(io_err(path, e)),
328 };
329
330 if !meta.is_dir() || meta.file_type().is_symlink() {
332 match self.classify(path, PathBuf::new(), &meta)? {
333 Captured::Entry(entry) => manifest.entries.push(entry),
334 Captured::Skipped(reason) => {
335 manifest
341 .warnings
342 .push(format!("{}: {reason}", path.display()));
343 }
344 }
345 return Ok(manifest);
346 }
347
348 let mut files: u64 = 0;
351 let mut bytes: u64 = 0;
352 let deadline = limits
356 .and_then(|l| l.max_duration)
357 .map(|d| Instant::now() + d);
358 let excludes: Vec<PathBuf> = exclude.iter().map(|p| normalize_abs(p)).collect();
361 let mut walker = walkdir::WalkDir::new(path).sort_by_file_name().into_iter();
362 while let Some(item) = walker.next() {
363 if let Some(dl) = deadline {
364 if Instant::now() >= dl {
365 manifest.truncated = true;
366 manifest.warnings.push(format!(
367 "snapshot time budget exceeded; PARTIAL coverage — stopped after \
368 {} entries (undo of this action can only restore what was captured)",
369 manifest.entries.len()
370 ));
371 break;
372 }
373 }
374 if let Ok(entry) = &item {
375 let abs = normalize_abs(entry.path());
376 if excludes.iter().any(|ex| abs == *ex || abs.starts_with(ex)) {
377 if entry.file_type().is_dir() {
378 walker.skip_current_dir();
379 }
380 continue;
381 }
382 if entry.depth() > 0 && entry.file_type().is_dir() {
385 let name = entry.file_name().to_string_lossy();
386 if skip.skips(entry.path(), &name) {
387 manifest.skipped_dirs.push(entry.path().to_path_buf());
388 walker.skip_current_dir();
389 continue;
390 }
391 }
392 }
393 let item = match item {
394 Ok(i) => i,
395 Err(e) => {
396 let at = e
397 .path()
398 .map(|p| p.display().to_string())
399 .unwrap_or_else(|| path.display().to_string());
400 manifest.warnings.push(format!("{at}: unreadable ({e})"));
401 continue;
402 }
403 };
404 let rel = item
405 .path()
406 .strip_prefix(path)
407 .unwrap_or(item.path())
408 .to_path_buf();
409 let meta = match item.metadata() {
410 Ok(m) => m,
411 Err(e) => {
412 manifest
413 .warnings
414 .push(format!("{}: unreadable ({e})", item.path().display()));
415 continue;
416 }
417 };
418 if meta.is_dir() && !meta.file_type().is_symlink() {
419 manifest.entries.push(Entry {
420 rel,
421 kind: EntryKind::Dir {
422 mode: meta.permissions().mode() & 0o7777,
423 },
424 });
425 continue;
426 }
427 if meta.is_file() && !meta.file_type().is_symlink() {
428 if let Some(l) = limits {
429 if files + 1 > l.max_files || bytes + meta.len() > l.max_bytes {
430 manifest.truncated = true;
431 manifest.skipped += 1;
432 continue;
433 }
434 }
435 files += 1;
436 bytes += meta.len();
437 }
438 match self.classify(item.path(), rel, &meta)? {
439 Captured::Entry(entry) => manifest.entries.push(entry),
440 Captured::Skipped(reason) => manifest
441 .warnings
442 .push(format!("{}: {reason}", item.path().display())),
443 }
444 }
445 Ok(manifest)
446 }
447
448 fn classify(
451 &self,
452 abs: &Path,
453 rel: PathBuf,
454 meta: &fs::Metadata,
455 ) -> Result<Captured, SnapshotError> {
456 use std::os::unix::fs::FileTypeExt;
457 let ft = meta.file_type();
458 let mode = meta.permissions().mode() & 0o7777;
459 if ft.is_symlink() {
460 return Ok(Captured::Entry(Entry {
461 rel,
462 kind: EntryKind::Symlink {
463 target: fs::read_link(abs).map_err(|e| io_err(abs, e))?,
464 },
465 }));
466 }
467 if ft.is_dir() {
468 return Ok(Captured::Entry(Entry {
469 rel,
470 kind: EntryKind::Dir { mode },
471 }));
472 }
473 if ft.is_file() {
474 return Ok(Captured::Entry(self.file_entry(abs, rel, meta)?));
475 }
476 if ft.is_fifo() {
477 return Ok(Captured::Entry(Entry {
479 rel,
480 kind: EntryKind::Fifo { mode },
481 }));
482 }
483 let what = if ft.is_socket() {
485 "socket"
486 } else if ft.is_block_device() {
487 "block device"
488 } else if ft.is_char_device() {
489 "char device"
490 } else {
491 "special file"
492 };
493 Ok(Captured::Skipped(format!(
494 "skipped {what} (cannot snapshot)"
495 )))
496 }
497
498 pub fn restore(&self, manifest: &Manifest) -> Result<RestoreReport, SnapshotError> {
503 let mut report = RestoreReport::default();
504 let target_root = &manifest.path;
505
506 for entry in &manifest.entries {
511 if !rel_is_safe(&entry.rel) {
512 return Err(SnapshotError::UnsafeManifestPath(entry.rel.clone()));
513 }
514 }
515
516 if manifest.root == Root::Absent {
517 remove_any(target_root)?;
518 return Ok(report);
519 }
520
521 let mut verified = std::collections::BTreeSet::new();
523 for entry in &manifest.entries {
524 if let EntryKind::File { hash, .. } = &entry.kind {
525 if verified.contains(hash) {
526 continue;
527 }
528 let object = self.object_path(hash);
529 if !object.exists() {
530 return Err(SnapshotError::MissingObject { hash: hash.clone() });
531 }
532 if hash_file(&object)? != *hash {
533 return Err(SnapshotError::CorruptObject { hash: hash.clone() });
534 }
535 verified.insert(hash.clone());
536 }
537 }
538
539 if manifest.entries.is_empty() {
544 report.warnings.push(format!(
545 "{}: nothing was capturable at snapshot time; restore is a no-op",
546 target_root.display()
547 ));
548 return Ok(report);
549 }
550
551 if let [entry] = manifest.entries.as_slice() {
559 if entry.rel.as_os_str().is_empty() {
560 if let EntryKind::File {
561 hash, mode, mtime, ..
562 } = &entry.kind
563 {
564 if fs::symlink_metadata(target_root)
565 .ok()
566 .filter(|m| m.file_type().is_file())
567 .map(|m| std::os::unix::fs::MetadataExt::nlink(&m))
568 .is_some_and(|n| n > 1)
569 {
570 let object = self.object_path(hash);
571 self.write_in_place(target_root, &object, *mode, *mtime)?;
572 report.files_restored += 1;
573 return Ok(report);
574 }
575 }
576 }
577 }
578
579 let parent = target_root
580 .parent()
581 .filter(|p| !p.as_os_str().is_empty())
582 .ok_or_else(|| SnapshotError::Io {
583 path: target_root.display().to_string(),
584 source: io::Error::new(
585 io::ErrorKind::InvalidInput,
586 "cannot restore a filesystem root",
587 ),
588 })?;
589 fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?;
590
591 let staging = self.staging_path(parent);
598 let build = self.build_into(&staging, manifest, &mut report);
599 if let Err(e) = build {
600 let _ = remove_any(&staging);
601 return Err(e);
602 }
603
604 for skipped in &manifest.skipped_dirs {
609 let Ok(rel) = skipped.strip_prefix(target_root) else {
610 continue;
611 };
612 if !rel_is_safe(rel) || !skipped.exists() {
613 continue;
614 }
615 let dest = staging.join(rel);
616 if let Some(parent) = dest.parent() {
617 if let Err(e) = fs::create_dir_all(parent) {
618 let _ = remove_any(&staging);
619 return Err(io_err(parent, e));
620 }
621 }
622 if let Err(e) = fs::rename(skipped, &dest) {
623 let _ = remove_any(&staging);
624 return Err(io_err(skipped, e));
625 }
626 report.warnings.push(format!(
627 "{}: not captured (regenerable); left as it is now",
628 rel.display()
629 ));
630 }
631
632 if let Err(e) = remove_any(target_root) {
633 let _ = remove_any(&staging);
634 return Err(e);
635 }
636 if let Err(e) = fs::rename(&staging, target_root) {
637 let _ = remove_any(&staging);
638 return Err(io_err(target_root, e));
639 }
640 Ok(report)
641 }
642
643 fn build_into(
647 &self,
648 base: &Path,
649 manifest: &Manifest,
650 report: &mut RestoreReport,
651 ) -> Result<(), SnapshotError> {
652 let mut dir_modes: Vec<(PathBuf, u32)> = Vec::new();
653 for entry in &manifest.entries {
654 let dest = if entry.rel.as_os_str().is_empty() {
655 base.to_path_buf()
656 } else {
657 base.join(&entry.rel)
658 };
659 match &entry.kind {
660 EntryKind::Dir { mode } => {
661 fs::create_dir_all(&dest).map_err(|e| io_err(&dest, e))?;
662 dir_modes.push((dest, *mode));
663 }
664 EntryKind::Symlink { target } => {
665 if let Some(p) = dest.parent() {
666 fs::create_dir_all(p).map_err(|e| io_err(p, e))?;
667 }
668 std::os::unix::fs::symlink(target, &dest).map_err(|e| io_err(&dest, e))?;
669 }
670 EntryKind::Fifo { mode } => {
671 if let Some(p) = dest.parent() {
672 fs::create_dir_all(p).map_err(|e| io_err(p, e))?;
673 }
674 make_fifo(&dest, *mode)?;
675 fs::set_permissions(&dest, fs::Permissions::from_mode(*mode))
678 .map_err(|e| io_err(&dest, e))?;
679 }
680 EntryKind::File {
681 hash,
682 mode,
683 mtime,
684 xattrs,
685 ..
686 } => {
687 if let Some(p) = dest.parent() {
688 fs::create_dir_all(p).map_err(|e| io_err(p, e))?;
689 }
690 let object = self.object_path(hash);
691 self.copy_out(&object, &dest)?;
692 fs::set_permissions(&dest, fs::Permissions::from_mode(0o600))
696 .map_err(|e| io_err(&dest, e))?;
697 let fh = fs::OpenOptions::new()
698 .write(true)
699 .open(&dest)
700 .map_err(|e| io_err(&dest, e))?;
701 fh.set_times(fs::FileTimes::new().set_modified(*mtime))
702 .map_err(|e| io_err(&dest, e))?;
703 drop(fh);
704 for (name, value) in xattrs {
705 if let Err(e) = xattr::set(&dest, name, value) {
706 report.warnings.push(format!(
707 "xattr {name} not restored on {}: {e}",
708 dest.display()
709 ));
710 }
711 }
712 fs::set_permissions(&dest, fs::Permissions::from_mode(*mode))
713 .map_err(|e| io_err(&dest, e))?;
714 report.files_restored += 1;
715 }
716 }
717 }
718 for (dir, mode) in dir_modes.into_iter().rev() {
719 fs::set_permissions(&dir, fs::Permissions::from_mode(mode))
720 .map_err(|e| io_err(&dir, e))?;
721 }
722 Ok(())
723 }
724
725 fn write_in_place(
729 &self,
730 dest: &Path,
731 object: &Path,
732 mode: u32,
733 mtime: SystemTime,
734 ) -> Result<(), SnapshotError> {
735 fs::set_permissions(dest, fs::Permissions::from_mode(0o600))
737 .map_err(|e| io_err(dest, e))?;
738 let bytes = fs::read(object).map_err(|e| io_err(object, e))?;
739 let fh = fs::OpenOptions::new()
740 .write(true)
741 .truncate(true)
742 .open(dest)
743 .map_err(|e| io_err(dest, e))?;
744 {
745 use std::io::Write;
746 let mut w = &fh;
747 w.write_all(&bytes).map_err(|e| io_err(dest, e))?;
748 }
749 fh.set_times(fs::FileTimes::new().set_modified(mtime))
750 .map_err(|e| io_err(dest, e))?;
751 drop(fh);
752 fs::set_permissions(dest, fs::Permissions::from_mode(mode)).map_err(|e| io_err(dest, e))?;
753 Ok(())
754 }
755
756 fn staging_path(&self, parent: &Path) -> PathBuf {
757 parent.join(format!(
758 ".doover-restore-{}-{}",
759 std::process::id(),
760 next_unique()
761 ))
762 }
763
764 pub fn state_matches(&self, manifest: &Manifest) -> Result<bool, SnapshotError> {
772 use std::collections::BTreeMap;
773 let root = &manifest.path;
774 if manifest.root == Root::Absent {
775 return Ok(fs::symlink_metadata(root).is_err());
776 }
777 if fs::symlink_metadata(root).is_err() {
778 return Ok(false);
779 }
780 let expected: BTreeMap<&Path, &EntryKind> = manifest
781 .entries
782 .iter()
783 .map(|e| (e.rel.as_path(), &e.kind))
784 .collect();
785 let skipped: std::collections::BTreeSet<&Path> =
790 manifest.skipped_dirs.iter().map(|p| p.as_path()).collect();
791 let mut seen = 0usize;
792 let mut walker = walkdir::WalkDir::new(root).sort_by_file_name().into_iter();
793 while let Some(item) = walker.next() {
794 let Ok(item) = item else { return Ok(false) };
795 if skipped.contains(item.path()) {
796 if item.file_type().is_dir() {
797 walker.skip_current_dir();
798 }
799 continue;
800 }
801 let rel = item.path().strip_prefix(root).unwrap_or(item.path());
802 let Ok(meta) = fs::symlink_metadata(item.path()) else {
803 return Ok(false);
804 };
805 let Some(kind) = expected.get(rel) else {
806 if manifest.truncated {
807 continue; }
809 return Ok(false); };
811 seen += 1;
812 let ft = meta.file_type();
813 let matches = match kind {
814 EntryKind::Dir { .. } => ft.is_dir() && !ft.is_symlink(),
815 EntryKind::Symlink { target } => {
816 ft.is_symlink() && fs::read_link(item.path()).is_ok_and(|t| &t == target)
817 }
818 EntryKind::Fifo { .. } => std::os::unix::fs::FileTypeExt::is_fifo(&ft),
819 EntryKind::File { hash, len, .. } => {
820 ft.is_file()
821 && !ft.is_symlink()
822 && meta.len() == *len
823 && hash_file(item.path())? == *hash
824 }
825 };
826 if !matches {
827 return Ok(false);
828 }
829 }
830 Ok(seen == expected.len())
831 }
832
833 pub fn supports_reflink(&self) -> bool {
835 let a = self.tmp_path();
836 let b = self.tmp_path();
837 let ok = fs::write(&a, b"probe").is_ok() && reflink_copy::reflink(&a, &b).is_ok();
838 let _ = fs::remove_file(&a);
839 let _ = fs::remove_file(&b);
840 ok
841 }
842
843 pub fn prune(
856 &self,
857 live: &std::collections::BTreeSet<String>,
858 grace_ms: u64,
859 dry_run: bool,
860 ) -> Result<(u64, u64), SnapshotError> {
861 let now = SystemTime::now();
862 let mut removed = 0u64;
863 let mut bytes = 0u64;
864 for path in self.object_paths()? {
865 let Some(hash) = path.file_name().map(|f| f.to_string_lossy().into_owned()) else {
866 continue;
867 };
868 if live.contains(&hash) {
869 continue;
870 }
871 let meta = fs::metadata(&path);
872 let young = meta
875 .as_ref()
876 .ok()
877 .and_then(|m| m.modified().ok())
878 .and_then(|mtime| now.duration_since(mtime).ok())
879 .is_none_or(|age| (age.as_millis() as u64) < grace_ms);
880 if young {
881 continue;
882 }
883 let len = meta.map(|m| m.len()).unwrap_or(0);
884 if !dry_run {
885 fs::remove_file(&path).map_err(|e| io_err(&path, e))?;
886 if let Some(parent) = path.parent() {
888 let _ = fs::remove_dir(parent);
889 }
890 }
891 removed += 1;
892 bytes += len;
893 }
894 Ok((removed, bytes))
895 }
896
897 pub fn clean_tmp(&self, max_age_ms: u64, dry_run: bool) -> Result<u64, SnapshotError> {
901 let now = SystemTime::now();
902 let mut removed = 0u64;
903 for entry in fs::read_dir(&self.tmp).map_err(|e| io_err(&self.tmp, e))? {
904 let entry = entry.map_err(|e| io_err(&self.tmp, e))?;
905 let path = entry.path();
906 let old_enough = fs::metadata(&path)
907 .and_then(|m| m.modified())
908 .ok()
909 .and_then(|mtime| now.duration_since(mtime).ok())
910 .is_some_and(|age| age.as_millis() as u64 >= max_age_ms);
911 if old_enough {
912 if !dry_run {
913 let _ = fs::remove_file(&path);
914 }
915 removed += 1;
916 }
917 }
918 Ok(removed)
919 }
920
921 pub fn object_count(&self) -> Result<u64, SnapshotError> {
922 Ok(self.object_paths()?.len() as u64)
923 }
924
925 pub fn total_bytes(&self) -> Result<u64, SnapshotError> {
930 let mut total = 0u64;
931 for p in self.object_paths()? {
932 total = total.saturating_add(fs::metadata(&p).map(|m| m.len()).unwrap_or(0));
933 }
934 Ok(total)
935 }
936
937 pub fn object_paths(&self) -> Result<Vec<PathBuf>, SnapshotError> {
938 let mut out = Vec::new();
939 for item in walkdir::WalkDir::new(&self.objects) {
940 let item = item.map_err(|e| SnapshotError::Io {
941 path: self.objects.display().to_string(),
942 source: e.into(),
943 })?;
944 if item.file_type().is_file() {
945 out.push(item.path().to_path_buf());
946 }
947 }
948 Ok(out)
949 }
950
951 fn file_entry(
952 &self,
953 src: &Path,
954 rel: PathBuf,
955 meta: &fs::Metadata,
956 ) -> Result<Entry, SnapshotError> {
957 let (hash, len) = self.ingest(src)?;
958 Ok(Entry {
959 rel,
960 kind: EntryKind::File {
961 hash,
962 len,
963 mode: meta.permissions().mode() & 0o7777,
964 mtime: meta.modified().map_err(|e| io_err(src, e))?,
965 xattrs: read_xattrs(src),
966 },
967 })
968 }
969
970 fn ingest(&self, src: &Path) -> Result<(String, u64), SnapshotError> {
973 let tmp = self.tmp_path();
974 let result = self.ingest_via(src, &tmp);
979 if result.is_err() {
980 let _ = fs::remove_file(&tmp);
981 }
982 result
983 }
984
985 fn ingest_via(&self, src: &Path, tmp: &Path) -> Result<(String, u64), SnapshotError> {
986 if self.opts.force_copy {
987 fs::copy(src, tmp).map_err(|e| io_err(src, e))?;
988 } else {
989 reflink_copy::reflink_or_copy(src, tmp).map_err(|e| io_err(src, e))?;
990 }
991 let hash = hash_file(tmp)?;
992 let len = fs::metadata(tmp).map_err(|e| io_err(tmp, e))?.len();
993 let object = self.object_path(&hash);
994 if object.exists() {
995 let _ = fs::remove_file(tmp);
996 } else {
997 let parent = object.parent().expect("object path has parent");
998 fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?;
999 fs::rename(tmp, &object).map_err(|e| io_err(&object, e))?;
1000 let _ = fs::set_permissions(&object, fs::Permissions::from_mode(0o400));
1003 }
1004 Ok((hash, len))
1005 }
1006
1007 fn copy_out(&self, object: &Path, dest: &Path) -> Result<(), SnapshotError> {
1008 if self.opts.force_copy {
1009 fs::copy(object, dest).map_err(|e| io_err(dest, e))?;
1010 } else {
1011 reflink_copy::reflink_or_copy(object, dest).map_err(|e| io_err(dest, e))?;
1012 }
1013 Ok(())
1014 }
1015
1016 fn object_path(&self, hash: &str) -> PathBuf {
1017 let prefix = hash.get(0..2).unwrap_or("xx");
1018 self.objects.join(prefix).join(hash)
1019 }
1020
1021 fn tmp_path(&self) -> PathBuf {
1022 self.tmp
1023 .join(format!("{}-{}", std::process::id(), next_unique()))
1024 }
1025}
1026
1027enum Captured {
1028 Entry(Entry),
1029 Skipped(String),
1030}
1031
1032pub fn orphaned_staging(dir: &Path) -> Result<Vec<PathBuf>, SnapshotError> {
1035 let mut out = Vec::new();
1036 for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? {
1037 let entry = entry.map_err(|e| io_err(dir, e))?;
1038 if entry
1039 .file_name()
1040 .to_string_lossy()
1041 .starts_with(".doover-restore-")
1042 {
1043 out.push(entry.path());
1044 }
1045 }
1046 out.sort();
1047 Ok(out)
1048}
1049
1050fn make_fifo(path: &Path, mode: u32) -> Result<(), SnapshotError> {
1051 use std::os::unix::ffi::OsStrExt;
1052 let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
1053 io_err(
1054 path,
1055 io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"),
1056 )
1057 })?;
1058 let rc = unsafe { libc::mkfifo(cpath.as_ptr(), mode as libc::mode_t) };
1060 if rc != 0 {
1061 return Err(io_err(path, io::Error::last_os_error()));
1062 }
1063 Ok(())
1064}
1065
1066fn read_xattrs(path: &Path) -> Vec<(String, Vec<u8>)> {
1067 let Ok(names) = xattr::list(path) else {
1068 return Vec::new();
1069 };
1070 let mut out = Vec::new();
1071 for name in names {
1072 if let Ok(Some(value)) = xattr::get(path, &name) {
1073 out.push((name.to_string_lossy().into_owned(), value));
1074 }
1075 }
1076 out
1077}
1078
1079fn remove_any(path: &Path) -> Result<(), SnapshotError> {
1080 match fs::symlink_metadata(path) {
1081 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1082 Err(e) => Err(io_err(path, e)),
1083 Ok(meta) => {
1084 let result = if meta.is_dir() && !meta.file_type().is_symlink() {
1085 fs::remove_dir_all(path)
1086 } else {
1087 fs::remove_file(path)
1088 };
1089 result.map_err(|e| io_err(path, e))
1090 }
1091 }
1092}
1093
1094#[cfg(unix)]
1099pub fn free_bytes(path: &Path) -> Option<u64> {
1100 use std::os::unix::ffi::OsStrExt;
1101 let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1102 let mut s: libc::statvfs = unsafe { std::mem::zeroed() };
1103 if unsafe { libc::statvfs(c.as_ptr(), &mut s) } != 0 {
1104 return None;
1105 }
1106 interpret_statvfs(s.f_bavail as u64, s.f_frsize as u64, s.f_blocks as u64)
1107}
1108
1109fn interpret_statvfs(bavail: u64, frsize: u64, blocks: u64) -> Option<u64> {
1114 if frsize == 0 || blocks == 0 {
1115 return None;
1116 }
1117 Some(bavail.saturating_mul(frsize))
1118}
1119
1120#[cfg(not(unix))]
1121pub fn free_bytes(_path: &Path) -> Option<u64> {
1122 None
1123}
1124
1125const MMAP_THRESHOLD: u64 = 1024 * 1024;
1126
1127pub(crate) fn hash_file(path: &Path) -> Result<String, SnapshotError> {
1128 let len = fs::metadata(path).map_err(|e| io_err(path, e))?.len();
1129 let mut hasher = blake3::Hasher::new();
1130 if len >= MMAP_THRESHOLD {
1131 hasher
1132 .update_mmap_rayon(path)
1133 .map_err(|e| io_err(path, e))?;
1134 } else {
1135 hasher.update(&fs::read(path).map_err(|e| io_err(path, e))?);
1136 }
1137 Ok(hasher.finalize().to_hex().to_string())
1138}
1139
1140#[cfg(test)]
1141mod statvfs_tests {
1142 use super::interpret_statvfs;
1143
1144 #[test]
1145 fn degenerate_statvfs_geometry_is_indeterminate_not_zero() {
1146 assert_eq!(interpret_statvfs(0, 0, 0), None, "all-zero FUSE stub");
1147 assert_eq!(interpret_statvfs(100, 0, 100), None, "frsize 0");
1148 assert_eq!(interpret_statvfs(100, 4096, 0), None, "blocks 0");
1149 assert_eq!(interpret_statvfs(0, 4096, 1_000_000), Some(0));
1151 assert_eq!(interpret_statvfs(10, 4096, 1_000_000), Some(40_960));
1152 }
1153}