Skip to main content

doover_core/
snapshot.rs

1//! Content-addressed snapshot store.
2//!
3//! Pre-action state is captured into `<store>/objects/<hh>/<blake3>` using
4//! copy-on-write clones where the filesystem supports them (`clonefile` on
5//! APFS, `FICLONE` on Btrfs/XFS via the `reflink-copy` crate) and plain copies
6//! everywhere else. Ingestion clones *first* and hashes the frozen clone, so a
7//! concurrently mutating source can never poison the store with a wrong hash.
8//!
9//! Restore is fail-closed: every referenced object is re-hashed and verified
10//! **before** the first byte of the destination is touched; a corrupt store
11//! refuses loudly instead of "restoring" garbage.
12//!
13//! File metadata (mode, mtime, xattrs) lives in the [`Manifest`], not in the
14//! object store — objects are pure content, which is what makes deduplication
15//! sound. Hardlink identity WITHIN a snapshotted directory tree is not
16//! preserved (linked files restore as independent copies with identical
17//! content — harmless, since both names are captured). But a single-file
18//! restore whose current target still has nlink>1 rewrites content in place to
19//! preserve the shared inode, so a truncating write through one name
20//! (`> alias`) recovers every hardlinked sibling (audit round 8).
21
22use 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
47/// A manifest `rel` must stay inside the restore root: relative, with only
48/// normal or `.` components — no `..`, no absolute/root prefix. `rel` is read
49/// from a stored manifest (journal JSON), so a corrupted or tampered one could
50/// otherwise steer `base.join(rel)` outside the staging tree and make `undo`
51/// write to an arbitrary path. Our own snapshot walk never produces such a
52/// path; this is fail-closed defense-in-depth, matching the hash-verify pass.
53fn 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
59/// Lexical absolute normalization (no filesystem access): make relative paths
60/// absolute against cwd, then fold `.`/`..`. Used only for prefix comparison
61/// in the exclusion check — symlink resolution is intentionally avoided so a
62/// symlinked project can't dodge the DOOVER_HOME exclusion by identity games.
63fn 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
85/// Which directories a snapshot walks past.
86///
87/// A NAME alone is only a guess: plenty of projects keep real, irreplaceable
88/// source in a folder called `build/`, and Go projects commit `vendor/`.
89/// Skipping those by name would mean undo could never bring them back — a
90/// data-loss bug in a tool that exists to prevent data loss.
91///
92/// So a directory is skipped only when BOTH hold:
93///   1. its name is on the list (it is the KIND of thing that gets rebuilt), and
94///   2. git already ignores it (the project itself declares it disposable).
95///
96/// Gitignored-but-not-a-build-dir (`.env`, a local database) is captured —
97/// that is precisely what git does NOT protect and what doover is for.
98///
99/// Outside a git repo there is no disposability signal, so the name list is all
100/// there is; the fallback is name-only. Every other uncertainty (no repo root,
101/// unreadable .gitignore, a nested ignore we did not load) resolves toward
102/// CAPTURING: being slower is recoverable, being lossy is not.
103pub struct SkipPolicy {
104    names: Vec<String>,
105    gitignore: Option<ignore::gitignore::Gitignore>,
106}
107
108impl SkipPolicy {
109    /// `repo_root` = the enclosing git repository, if any.
110    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            // root .gitignore plus the repo's private excludes; a pattern we
114            // fail to load simply means we capture that directory
115            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    /// Skip nothing (used by plain `snapshot()` and by tests).
123    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            // inside a repo: git must agree the directory is disposable
136            Some(gi) => gi.matched_path_or_any_parents(dir, true).is_ignore(),
137            // no repo: the name list is the only signal we have
138            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    /// Skip reflink attempts and always plain-copy (also settable via the
153    /// DOOVER_FORCE_COPY env var through [`Store::open`]).
154    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    /// Wall-clock ceiling for a single snapshot. `None` = no time limit.
162    /// When exceeded the walk stops and the manifest is marked `truncated` — a
163    /// loud, journaled coverage gap — instead of letting the hook run past the
164    /// harness timeout and be SIGKILLed with nothing recorded (bench D1).
165    pub max_duration: Option<Duration>,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
169pub enum Root {
170    /// The path existed when snapshotted.
171    Present,
172    /// The path did not exist: restoring deletes whatever is there now.
173    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    /// Named pipe. Recreated empty on restore (its transient contents are not
192    /// data we can or should capture); never opened during snapshot, which is
193    /// what used to hang the engine.
194    Fifo {
195        mode: u32,
196    },
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
200pub struct Entry {
201    /// Path relative to the snapshot root; empty for the root itself.
202    pub rel: PathBuf,
203    pub kind: EntryKind,
204}
205
206/// Version stamped into every newly-written [`Manifest`]. Bump when the
207/// serialized shape changes incompatibly; readers refuse anything newer than
208/// they understand. Legacy JSON without the field deserializes as 0.
209pub const MANIFEST_SCHEMA: u32 = 1;
210
211#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
212pub struct Manifest {
213    /// Serialization schema version (see [`MANIFEST_SCHEMA`]).
214    #[serde(default)]
215    pub schema: u32,
216    /// Absolute path this snapshot captured.
217    pub path: PathBuf,
218    pub root: Root,
219    /// Walk order: parents before children.
220    pub entries: Vec<Entry>,
221    /// True when limits stopped the capture before completion.
222    pub truncated: bool,
223    /// Files seen but not captured because of limits.
224    pub skipped: u64,
225    /// Loud record of anything the snapshot could not fully capture:
226    /// unreadable subtrees, skipped special files (sockets/devices), fifos.
227    /// A non-empty list means coverage has gaps the caller must surface.
228    pub warnings: Vec<String>,
229    /// Regenerable build/dependency directories deliberately not captured
230    /// (`target/`, `node_modules/`, …). This is POLICY, not a coverage gap:
231    /// it keeps the time budget on the user's source instead of artifacts a
232    /// build command recreates. Recorded so `show` can be honest about it.
233    #[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
249/// Process-global uniquifier for tmp/staging names: two `Store` handles on the
250/// same root (concurrent hook invocations in one process) must never collide.
251fn 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    /// Capture the pre-action state of `path` (file, directory tree, symlink,
272    /// or an absent-marker when the path does not exist).
273    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    /// The full snapshot entry point.
291    ///
292    /// `exclude` prunes subtrees under an absolute path — the hook passes
293    /// DOOVER_HOME so a defensive cwd snapshot of a project that CONTAINS
294    /// doover's own store never ingests doover's internals (round 21).
295    ///
296    /// `skip` prunes regenerable build/dependency directories found INSIDE the
297    /// tree (`target/`, `node_modules/`, …) — see [`SkipPolicy`] for why a name
298    /// alone is not enough. Dogfooding showed a Rust repo is 99.5% `target/`:
299    /// without this the time budget is spent on build artifacts and the user's
300    /// actual source never gets captured. The snapshot ROOT is never skipped,
301    /// so `rm -rf target` still snapshots target/ in full — skipping only
302    /// applies to dirs we find while walking a tree the user did not point at
303    /// directly.
304    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        // single non-directory root
331        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                    // the root itself is uncapturable (e.g. a device node):
336                    // record an absent-style marker is wrong, so warn and leave
337                    // entries empty — restore of an empty Present manifest is a
338                    // no-op, and the loud warning routes the caller to the
339                    // unknown policy
340                    manifest
341                        .warnings
342                        .push(format!("{}: {reason}", path.display()));
343                }
344            }
345            return Ok(manifest);
346        }
347
348        // directory tree — tolerate per-entry errors (unreadable subdirs) by
349        // recording a warning and continuing, never aborting the whole snapshot
350        let mut files: u64 = 0;
351        let mut bytes: u64 = 0;
352        // wall-clock budget: stop the walk cleanly before the hook runs past the
353        // harness timeout and is SIGKILLed with nothing journaled (bench D1).
354        // Checked between entries, so overshoot is bounded by one entry's cost.
355        let deadline = limits
356            .and_then(|l| l.max_duration)
357            .map(|d| Instant::now() + d);
358        // normalized exclusion roots (DOOVER_HOME): any entry at or under one
359        // is pruned so doover never snapshots its own state
360        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                // regenerable build dirs: skip the subtree, but NEVER the root
383                // (an explicitly targeted `target/` must still be captured)
384                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    /// Turn one filesystem object into a manifest entry, or decide it can't be
449    /// captured. Never opens fifos/sockets/devices for I/O.
450    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            // recreated empty; never opened (opening a fifo for read blocks)
478            return Ok(Captured::Entry(Entry {
479                rel,
480                kind: EntryKind::Fifo { mode },
481            }));
482        }
483        // sockets, block/char devices: not restorable data
484        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    /// Restore the exact captured state, replacing whatever is at the path
499    /// now. Fails before touching anything if the store is corrupt. Callers
500    /// wanting conflict detection diff current state against the manifest
501    /// first (undo engine, step 6).
502    pub fn restore(&self, manifest: &Manifest) -> Result<RestoreReport, SnapshotError> {
503        let mut report = RestoreReport::default();
504        let target_root = &manifest.path;
505
506        // fail-closed BEFORE any mutation: refuse a manifest whose entry paths
507        // would escape the restore root (`..`/absolute). undo is a write
508        // primitive fed from on-disk manifests; a corrupt one must never write
509        // outside the target tree.
510        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        // fail-closed verification pass, deduped by hash — before any mutation
522        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        // A Present manifest with zero entries means the root existed but was
540        // uncapturable (socket/device — see the snapshot warnings). There is
541        // nothing to rebuild, and destroying the live object would turn a
542        // coverage gap into data loss: warn and leave the target alone.
543        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        // Hardlink preservation: if the manifest is a single regular file and
552        // the current target is still a regular file with more than one link,
553        // stage-then-swap would rebuild a FRESH inode and orphan the sibling
554        // names (which a truncating write like `> alias` clobbered through the
555        // shared inode). Rewrite the content IN PLACE so every hardlinked name
556        // recovers. Restore of a recovery is acceptable to do non-atomically —
557        // the pre-restore state is already the clobbered content.
558        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        // Build the whole restored tree into a sibling staging path first, so
592        // every fallible operation (copy, chmod, set_times, xattr, mkfifo)
593        // happens while the current target is still fully intact. Only once
594        // staging succeeds do we swap — remove target, rename staging in —
595        // which is metadata-only and on the same filesystem. A crash mid-build
596        // leaves the target untouched; a failure is cleaned up.
597        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        // Carry the skipped (never-captured) dirs across the swap. We did not
605        // snapshot target/ or node_modules/, so we have nothing to restore for
606        // them — but swapping in a tree that omits them would DELETE them.
607        // Move them into staging first: undo leaves them exactly as they are.
608        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    /// Materialize `manifest` at `base` (a fresh staging path). Directory modes
644    /// are applied deepest-first so a restrictive mode can't block writing its
645    /// own children.
646    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                    // mkfifo's mode argument is masked by the umask; apply the
676                    // recorded mode explicitly
677                    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                    // clones inherit the object's read-only mode: make the
693                    // destination writable before touching times/xattrs, then
694                    // apply the recorded mode last
695                    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    /// Truncate-and-rewrite an existing file's content without replacing its
726    /// inode, so hardlinked siblings recover too. Only used on the nlink>1
727    /// path; the object bytes are read from the (already hash-verified) store.
728    fn write_in_place(
729        &self,
730        dest: &Path,
731        object: &Path,
732        mode: u32,
733        mtime: SystemTime,
734    ) -> Result<(), SnapshotError> {
735        // ensure we can open for writing regardless of the clobbered mode
736        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    /// Does the CURRENT filesystem state at `manifest.path` match the
765    /// manifest? Content-level comparison: file hashes (with a length
766    /// fast-path), directory presence, symlink targets, fifo presence. Mode
767    /// and mtime are deliberately ignored — metadata-only drift is not a
768    /// conflict. Extra entries under the root or missing entries are
769    /// mismatches, except under a truncated manifest, which compares only
770    /// what it captured (weaker evidence — callers should warn).
771    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        // The snapshot deliberately did not capture these (build dirs). The
786        // live tree still has them, so a naive walk would see "extra entries"
787        // and report a conflict on EVERY project with a target/ or
788        // node_modules/. Prune them here too: compare like with like.
789        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; // uncaptured region: can't judge
808                }
809                return Ok(false); // extra entry appeared
810            };
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    /// Probe whether the store's filesystem supports copy-on-write clones.
834    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    /// Remove every object whose hash is NOT in `live`. Returns
844    /// (objects_removed, bytes_freed); `dry_run` only counts. Objects are
845    /// immutable and content-addressed, so removal is always safe for
846    /// anything outside the live set — the live set is the whole contract.
847    /// Remove content objects not in `live`. `grace_ms` protects objects
848    /// younger than the window: a hook promotes an object into `objects/` and
849    /// only THEN journals the manifest that references it, so a gc racing that
850    /// window would see a just-promoted object no journal row vouches for yet.
851    /// Deleting it strands the about-to-be-written manifest — silent undo
852    /// breakage. Young unreferenced objects are therefore treated as
853    /// possibly-in-flight and kept (same guard `clean_tmp` gives tmp files);
854    /// only aged orphans (crash leftovers) are reaped.
855    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            // fail safe: if we cannot read the age, assume it might be
873            // in-flight and keep it (a later pass reaps it once readable)
874            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                // best-effort: drop the prefix dir if now empty
887                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    /// Remove tmp entries older than `max_age_ms` — crash leftovers from
898    /// interrupted ingestions. Age-gated so a concurrently-running hook's
899    /// in-flight tmp file is never yanked out from under it.
900    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    /// Apparent store size: the sum of object lengths. On CoW filesystems the
926    /// blocks may be shared with the originals (actual usage lower), which is
927    /// exactly why this is the PREDICTABLE bound the size cap enforces — the
928    /// free-space floor covers the physical-disk side.
929    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    /// Clone-then-hash: the clone freezes the content, so the recorded hash is
971    /// correct even if the source mutates mid-snapshot.
972    fn ingest(&self, src: &Path) -> Result<(String, u64), SnapshotError> {
973        let tmp = self.tmp_path();
974        // any failure after the tmp file exists must remove it NOW — leaking
975        // partials until clean_tmp's hour-long age gate would stack copies on
976        // a disk that is likely already strained (ENOSPC is a failure mode
977        // this path must expect)
978        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            // objects are immutable content AND copies of user files:
1001            // owner-read only (D4), never world-readable
1002            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
1032/// Crash leftovers from an interrupted restore swap. `doover doctor` surfaces
1033/// these; their contents are a fully-built restore image and may aid recovery.
1034pub 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    // SAFETY: cpath is a valid NUL-terminated C string for the duration of the call.
1059    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/// Free space available to unprivileged writes on the filesystem holding
1095/// `path` (statvfs f_bavail × f_frsize). `None` when it cannot be determined —
1096/// callers must treat that as "no pressure signal", never as zero: evicting
1097/// undo history on a misread would destroy data to fix a phantom problem.
1098#[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
1109/// Degenerate geometry (FUSE stubs and some network mounts report all-zero
1110/// statvfs on SUCCESS) must read as "indeterminate", never as "0 bytes free" —
1111/// Some(0) would masquerade as a maximal disk-pressure emergency. A zero
1112/// f_bavail with sane geometry remains a legitimate full-disk signal.
1113fn 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        // a genuinely full disk with sane geometry IS a real zero
1150        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}