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 /// The sub-second part of mtime. Compared only against a baseline
21 /// that recorded one (review 2026-09-12, atomicity-4: a same-size
22 /// rewrite inside the recorded second was invisible forever).
23 pub mtime_nanos: u32,
24 pub mode: u32,
25}
26
27/// The consume's temp sibling (`safefs`): a crash between its create and
28/// its rename leaves it as a regular file in the tree, and the scan used
29/// to publish it as data (review 2026-09-12, atomicity-7).
30pub const TMP_SUFFIX: &str = ".flint-sync-tmp";
31
32/// Whether a file's stat differs from its baseline stamp. Size and
33/// seconds always; nanoseconds only when the baseline recorded them — a
34/// baseline written before nanoseconds were stamped has `None`, and
35/// comparing against a missing value would re-upload every file once.
36pub fn stat_changed(b_size: u64, b_mtime: i64, b_nanos: Option<u32>, size: u64, mtime: i64, nanos: u32) -> bool {
37 b_size != size || b_mtime != mtime || b_nanos.map(|n| n != nanos).unwrap_or(false)
38}
39
40/// Walk the workspace. Skips the state dir, symlinks (v1 non-goal, as
41/// the tier manifest's), and anything unreadable (reported upstream by
42/// the barrier as a warning, not a wedge).
43pub fn scan(root: &Path) -> LeanResult<BTreeMap<String, ScanEntry>> {
44 let mut out = BTreeMap::new();
45 walk(root, root, &mut out)?;
46 Ok(out)
47}
48
49fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, ScanEntry>) -> LeanResult<()> {
50 for entry in std::fs::read_dir(dir)? {
51 let entry = entry?;
52 let path = entry.path();
53 let name = entry.file_name();
54 if dir == root {
55 let n = name.to_string_lossy();
56 // The state dir and the control namespace (boundary-verbs
57 // plan D0). Without the second exclusion `.flint/publish`
58 // is live ammunition: an ordinary regular file, scanned and
59 // PUBLISHED to `<prefix>/files/.flint/publish`.
60 if n == STATE_DIR || n == CONTROL_DIR {
61 continue;
62 }
63 }
64 if name.to_string_lossy().ends_with(TMP_SUFFIX) {
65 continue;
66 }
67 let meta = std::fs::symlink_metadata(&path)?;
68 if meta.file_type().is_symlink() {
69 continue;
70 }
71 if meta.is_dir() {
72 walk(root, &path, out)?;
73 } else if meta.is_file() {
74 let rel = path
75 .strip_prefix(root)
76 .expect("walk stays under root")
77 .to_string_lossy()
78 .replace('\\', "/");
79 #[cfg(unix)]
80 let mode = {
81 use std::os::unix::fs::MetadataExt;
82 meta.mode()
83 };
84 #[cfg(not(unix))]
85 let mode = 0o644;
86 let (mtime_unix, mtime_nanos) = meta
87 .modified()
88 .ok()
89 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
90 .map(|d| (d.as_secs() as i64, d.subsec_nanos()))
91 .unwrap_or((0, 0));
92 out.insert(rel, ScanEntry { size: meta.len(), mtime_unix, mtime_nanos, mode });
93 }
94 }
95 Ok(())
96}
97
98#[derive(Debug, Default)]
99pub struct Classified {
100 /// Changed or new vs the baseline (by size/mtime; mtime-granularity
101 /// evasion is a stated v1 residual).
102 pub uploads: BTreeSet<String>,
103 /// Absent this scan AND the previous scan, present in the baseline.
104 pub deletes: BTreeSet<String>,
105 /// Absent this scan but present in the previous one: NOT yet
106 /// delete-eligible (first absence).
107 pub first_absence: BTreeSet<String>,
108}
109
110/// Legacy citations under the reserved control namespace (boundary-verbs
111/// plan D0.2). A workspace that legally published `files/.flint/...`
112/// under a pre-D0 syncer has those paths in its baseline; the new scan
113/// skips them, so two consecutive scans would classify them absent and
114/// publish their DELETION. An upgrade must never delete data: they are
115/// carried forward frozen — never re-uploaded, never deleted by us.
116pub fn is_control_path(path: &str) -> bool {
117 // Both reserved names (review 2026-09-12, inbox-8: only `.flint/`
118 // was refused, so a citation naming `.flint-sync/scope-intent.json`
119 // was materialised INTO the state directory and replayed).
120 [CONTROL_DIR, STATE_DIR].iter().any(|d| {
121 path == *d || path.strip_prefix(d).map(|r| r.starts_with('/')).unwrap_or(false)
122 })
123}
124
125pub fn classify(scan: &BTreeMap<String, ScanEntry>, baseline: &Baseline) -> Classified {
126 let mut c = Classified::default();
127 for (path, s) in scan {
128 match baseline.entries.get(path) {
129 None => {
130 c.uploads.insert(path.clone());
131 }
132 Some(b) => {
133 if stat_changed(b.size, b.mtime_unix, b.mtime_nanos, s.size, s.mtime_unix, s.mtime_nanos) {
134 c.uploads.insert(path.clone());
135 }
136 }
137 }
138 }
139 for path in baseline.entries.keys() {
140 if scan.contains_key(path) {
141 continue;
142 }
143 if is_control_path(path) {
144 // Frozen legacy citation (D0.2): absent from every scan by
145 // construction now, and NEVER delete-eligible.
146 continue;
147 }
148 if baseline.prev_scan.contains(path) {
149 // Present a scan ago: first observed absence.
150 c.first_absence.insert(path.clone());
151 } else {
152 c.deletes.insert(path.clone());
153 }
154 }
155 c
156}