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}