Skip to main content

doover_core/
inspect.rs

1//! Read-only inspection: compare a recorded manifest against the live
2//! filesystem, per entry (step 8, `doover diff`).
3//!
4//! This is strictly informational — it never mutates anything and shares its
5//! notion of "changed" with the undo conflict oracle (content hash for files,
6//! target for symlinks, kind for everything), so what `diff` reports as
7//! modified is exactly what `undo` would flag as a conflict.
8//!
9//! Robustness (audit round 13): an informational command must degrade, never
10//! abort. One unreadable file marks that line `Unreadable` and the walk
11//! continues; a root whose identity changed (a dir now a symlink) is reported
12//! and the walk stops, so child paths are never stat'd THROUGH an impostor
13//! (which would both mislead and hash an unbounded, unrelated tree); a
14//! truncated pre-manifest flags the whole report `partial`.
15
16use crate::snapshot::{EntryKind, Manifest, Root, SnapshotError, hash_file};
17use std::io::ErrorKind;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PathStatus {
22    /// Matches the recorded state.
23    Unchanged,
24    /// Same kind, different content (file hash or symlink target).
25    Modified,
26    /// Recorded but absent now.
27    Missing,
28    /// Present but a different kind of filesystem object.
29    TypeChanged,
30    /// Recorded as absent, exists now.
31    Created,
32    /// Present and same kind, but we could not read it to compare (e.g.
33    /// permission denied). Reported, not fatal.
34    Unreadable,
35}
36
37impl PathStatus {
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Self::Unchanged => "unchanged",
41            Self::Modified => "modified",
42            Self::Missing => "missing",
43            Self::TypeChanged => "type-changed",
44            Self::Created => "created",
45            Self::Unreadable => "unreadable",
46        }
47    }
48}
49
50#[derive(Debug)]
51pub struct DiffLine {
52    pub path: PathBuf,
53    pub status: PathStatus,
54}
55
56#[derive(Debug)]
57pub struct DiffReport {
58    pub lines: Vec<DiffLine>,
59    /// The recorded manifest was truncated by limits, so this comparison
60    /// covers only part of the captured tree — never imply total coverage.
61    pub partial: bool,
62}
63
64/// Per-entry status of `m` against the world as it is right now.
65pub fn diff_manifest(m: &Manifest) -> Result<DiffReport, SnapshotError> {
66    let partial = m.truncated;
67    if m.root == Root::Absent {
68        // the path did not exist when captured; one line says it all
69        let status = if m.path.symlink_metadata().is_ok() {
70            PathStatus::Created
71        } else {
72            PathStatus::Unchanged
73        };
74        return Ok(DiffReport {
75            lines: vec![DiffLine {
76                path: m.path.clone(),
77                status,
78            }],
79            partial,
80        });
81    }
82
83    // Evaluate the recorded root first. If its very identity changed —
84    // type-changed (a dir replaced by a symlink/file) or gone — then every
85    // child entry is relative to a root that no longer means what it meant.
86    // Stat'ing `root/child` would resolve THROUGH the impostor (a symlink to
87    // an unrelated, possibly huge tree): misleading statuses and unbounded
88    // hashing of data the action never touched. Report the root and stop.
89    let root_kind = m
90        .entries
91        .iter()
92        .find(|e| e.rel.as_os_str().is_empty())
93        .map(|e| &e.kind);
94    if let Some(kind) = root_kind {
95        let root_status = status_of_entry(&m.path, kind)?;
96        if matches!(root_status, PathStatus::TypeChanged | PathStatus::Missing) {
97            return Ok(DiffReport {
98                lines: vec![DiffLine {
99                    path: m.path.clone(),
100                    status: root_status,
101                }],
102                partial,
103            });
104        }
105    }
106
107    let mut lines = Vec::with_capacity(m.entries.len());
108    for entry in &m.entries {
109        // rel is empty for the root itself; join("") would grow a trailing
110        // slash and stat("…/file/") is ENOTDIR — the intact root would read
111        // as Missing
112        let abs = if entry.rel.as_os_str().is_empty() {
113            m.path.clone()
114        } else {
115            m.path.join(&entry.rel)
116        };
117        let status = status_of_entry(&abs, &entry.kind)?;
118        lines.push(DiffLine { path: abs, status });
119    }
120    Ok(DiffReport { lines, partial })
121}
122
123/// Compare one live path against a recorded entry kind. Never follows links
124/// (a file replaced by a symlink is a type change, matching the restore path)
125/// and never aborts: an unreadable object is `Unreadable`, not an error.
126fn status_of_entry(abs: &Path, kind: &EntryKind) -> Result<PathStatus, SnapshotError> {
127    let meta = match abs.symlink_metadata() {
128        Ok(meta) => meta,
129        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(PathStatus::Missing),
130        // lstat itself failed (e.g. a parent dir lost search permission): we
131        // cannot judge, so say so rather than pretend it is missing
132        Err(_) => return Ok(PathStatus::Unreadable),
133    };
134    let ft = meta.file_type();
135    let status = match kind {
136        EntryKind::File { hash, .. } => {
137            if !ft.is_file() {
138                PathStatus::TypeChanged
139            } else {
140                match hash_file(abs) {
141                    Ok(h) if &h == hash => PathStatus::Unchanged,
142                    Ok(_) => PathStatus::Modified,
143                    // present, still a file, but unreadable — do not abort the
144                    // whole report over one locked file
145                    Err(_) => PathStatus::Unreadable,
146                }
147            }
148        }
149        EntryKind::Dir { .. } => {
150            if ft.is_dir() {
151                PathStatus::Unchanged
152            } else {
153                PathStatus::TypeChanged
154            }
155        }
156        EntryKind::Symlink { target } => {
157            if !ft.is_symlink() {
158                PathStatus::TypeChanged
159            } else {
160                match std::fs::read_link(abs) {
161                    Ok(t) if &t == target => PathStatus::Unchanged,
162                    Ok(_) => PathStatus::Modified,
163                    Err(_) => PathStatus::Unreadable,
164                }
165            }
166        }
167        EntryKind::Fifo { .. } => {
168            #[cfg(unix)]
169            {
170                use std::os::unix::fs::FileTypeExt;
171                if ft.is_fifo() {
172                    PathStatus::Unchanged
173                } else {
174                    PathStatus::TypeChanged
175                }
176            }
177            #[cfg(not(unix))]
178            {
179                PathStatus::TypeChanged
180            }
181        }
182    };
183    Ok(status)
184}