flint_lean/scan.rs
1//! The barrier's scan-diff (plan ยง2.1 step 2).
2//!
3//! A full walk against the PERSISTED baseline โ never a re-seeded
4//! bucket manifest. Deletion basis: a path is delete-eligible only if
5//! it is absent in THIS scan AND was absent in the PREVIOUS scan AND is
6//! present in our own baseline (two-consecutive-scans: the
7//! rename-vs-walk race guard โ a directory renamed mid-walk can appear
8//! in neither pass of one readdir, and that must never read as mass
9//! deletion).
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14use super::{state::Baseline, LeanResult, CONTROL_DIR, STATE_DIR};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ScanEntry {
18 pub size: u64,
19 pub mtime_unix: i64,
20 pub mode: u32,
21}
22
23/// Walk the workspace. Skips the state dir, symlinks (v1 non-goal, as
24/// the tier manifest's), and anything unreadable (reported upstream by
25/// the barrier as a warning, not a wedge).
26pub fn scan(root: &Path) -> LeanResult<BTreeMap<String, ScanEntry>> {
27 let mut out = BTreeMap::new();
28 walk(root, root, &mut out)?;
29 Ok(out)
30}
31
32fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, ScanEntry>) -> LeanResult<()> {
33 for entry in std::fs::read_dir(dir)? {
34 let entry = entry?;
35 let path = entry.path();
36 let name = entry.file_name();
37 if dir == root {
38 let n = name.to_string_lossy();
39 // The state dir and the control namespace (boundary-verbs
40 // plan D0). Without the second exclusion `.flint/publish`
41 // is live ammunition: an ordinary regular file, scanned and
42 // PUBLISHED to `<prefix>/files/.flint/publish`.
43 if n == STATE_DIR || n == CONTROL_DIR {
44 continue;
45 }
46 }
47 let meta = std::fs::symlink_metadata(&path)?;
48 if meta.file_type().is_symlink() {
49 continue;
50 }
51 if meta.is_dir() {
52 walk(root, &path, out)?;
53 } else if meta.is_file() {
54 let rel = path
55 .strip_prefix(root)
56 .expect("walk stays under root")
57 .to_string_lossy()
58 .replace('\\', "/");
59 #[cfg(unix)]
60 let mode = {
61 use std::os::unix::fs::MetadataExt;
62 meta.mode()
63 };
64 #[cfg(not(unix))]
65 let mode = 0o644;
66 let mtime_unix = meta
67 .modified()
68 .ok()
69 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
70 .map(|d| d.as_secs() as i64)
71 .unwrap_or(0);
72 out.insert(rel, ScanEntry { size: meta.len(), mtime_unix, mode });
73 }
74 }
75 Ok(())
76}
77
78#[derive(Debug, Default)]
79pub struct Classified {
80 /// Changed or new vs the baseline (by size/mtime; mtime-granularity
81 /// evasion is a stated v1 residual).
82 pub uploads: BTreeSet<String>,
83 /// Absent this scan AND the previous scan, present in the baseline.
84 pub deletes: BTreeSet<String>,
85 /// Absent this scan but present in the previous one: NOT yet
86 /// delete-eligible (first absence).
87 pub first_absence: BTreeSet<String>,
88}
89
90/// Legacy citations under the reserved control namespace (boundary-verbs
91/// plan D0.2). A workspace that legally published `files/.flint/...`
92/// under a pre-D0 syncer has those paths in its baseline; the new scan
93/// skips them, so two consecutive scans would classify them absent and
94/// publish their DELETION. An upgrade must never delete data: they are
95/// carried forward frozen โ never re-uploaded, never deleted by us.
96pub fn is_control_path(path: &str) -> bool {
97 path == CONTROL_DIR || path.strip_prefix(CONTROL_DIR).map(|r| r.starts_with('/')).unwrap_or(false)
98}
99
100pub fn classify(scan: &BTreeMap<String, ScanEntry>, baseline: &Baseline) -> Classified {
101 let mut c = Classified::default();
102 for (path, s) in scan {
103 match baseline.entries.get(path) {
104 None => {
105 c.uploads.insert(path.clone());
106 }
107 Some(b) => {
108 if b.size != s.size || b.mtime_unix != s.mtime_unix {
109 c.uploads.insert(path.clone());
110 }
111 }
112 }
113 }
114 for path in baseline.entries.keys() {
115 if scan.contains_key(path) {
116 continue;
117 }
118 if is_control_path(path) {
119 // Frozen legacy citation (D0.2): absent from every scan by
120 // construction now, and NEVER delete-eligible.
121 continue;
122 }
123 if baseline.prev_scan.contains(path) {
124 // Present a scan ago: first observed absence.
125 c.first_absence.insert(path.clone());
126 } else {
127 c.deletes.insert(path.clone());
128 }
129 }
130 c
131}