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, PathBuf};
13use std::sync::{Condvar, Mutex};
14
15use super::{state::Baseline, LeanResult, CONTROL_DIR, STATE_DIR};
16use flint_store::crc64_to_b64;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ScanEntry {
20 pub size: u64,
21 pub mtime_unix: i64,
22 /// The sub-second part of mtime. Compared only against a baseline
23 /// that recorded one (review 2026-09-12, atomicity-4: a same-size
24 /// rewrite inside the recorded second was invisible forever).
25 pub mtime_nanos: u32,
26 pub mode: u32,
27 /// When this entry was stat'ed, in nanoseconds since the epoch: an
28 /// upper bound on when the scan saw the file and a LOWER bound on
29 /// when anything after it read the file's bytes. The racy-clean test
30 /// measures from here (`racy`).
31 pub seen_nanos: i128,
32 /// What only the kernel stamps (`ChangeStamp`).
33 pub change: ChangeStamp,
34}
35
36/// The stamp no agent can set: a file's ctime, which every write, chmod,
37/// rename and `utimensat` moves to now, and its inode. mtime is the
38/// agent's to set (`touch -d`, `cp -p`, `rsync -t`): a same-size rewrite
39/// under the recorded mtime left size and mtime as recorded, and was never
40/// published (AGENTS.md forbade setting timestamps for that reason). An
41/// entry whose change stamp moved while size, mtime and mode did not is
42/// UNSURE: its bytes decide (`confirm_racy`, `barrier::local_dirty`), so a
43/// stamp that moved with no change costs a hash, never an upload. The
44/// model's `StampMoved`, `CtimeConfirm` (`LeanScanRacy.tla`).
45///
46/// Kept as `[ctime, nanos, ino]` (a baseline of a million entries carries
47/// a million of them).
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(from = "(i64, u32, u64)", into = "(i64, u32, u64)")]
50pub struct ChangeStamp {
51 pub ctime: i64,
52 pub ctime_nanos: u32,
53 pub ino: u64,
54}
55
56impl From<(i64, u32, u64)> for ChangeStamp {
57 fn from((ctime, ctime_nanos, ino): (i64, u32, u64)) -> Self {
58 ChangeStamp { ctime, ctime_nanos, ino }
59 }
60}
61
62impl From<ChangeStamp> for (i64, u32, u64) {
63 fn from(c: ChangeStamp) -> Self {
64 (c.ctime, c.ctime_nanos, c.ino)
65 }
66}
67
68impl ChangeStamp {
69 pub fn of(m: &std::fs::Metadata) -> ChangeStamp {
70 use std::os::unix::fs::MetadataExt;
71 ChangeStamp { ctime: m.ctime(), ctime_nanos: m.ctime_nsec() as u32, ino: m.ino() }
72 }
73}
74
75/// Whether `b`'s change stamp is not `now`'s. `None`, a baseline entry
76/// written before the stamp, compares as unknown, as `stat_changed`'s
77/// optional fields do.
78pub fn stamp_moved(b: &super::state::BaselineEntry, now: &ChangeStamp) -> bool {
79 b.change.is_some_and(|c| c != *now)
80}
81
82/// The stamp the racy window is measured from: the LATER of mtime and
83/// ctime. Measured from an mtime the agent set into the past, a second
84/// same-size `cp -p` inside the tick of the first hid (the model's
85/// `RacyOnChange`).
86pub fn later(mtime: i64, mtime_nanos: u32, change: Option<&ChangeStamp>) -> (i64, u32) {
87 match change {
88 Some(c) if (c.ctime, c.ctime_nanos) > (mtime, mtime_nanos) => (c.ctime, c.ctime_nanos),
89 _ => (mtime, mtime_nanos),
90 }
91}
92
93/// Whether a baseline entry is racy measured at `at` (`racy`, from its
94/// later stamp). An entry recorded without sub-second mtime takes the end
95/// of its second.
96pub fn racy_entry(b: &super::state::BaselineEntry, at: i128) -> bool {
97 let (sec, nanos) = later(b.mtime_unix, b.mtime_nanos.unwrap_or(999_999_999), b.change.as_ref());
98 racy(sec, nanos, at)
99}
100
101/// RACY CLEAN (git's rule, `racy-git.txt`). A file's mtime comes from a
102/// coarse kernel clock (a few milliseconds a tick), so two same-size
103/// writes inside one tick leave identical stat. A baseline entry whose
104/// bytes were read within a tick of the file's mtime cannot vouch for
105/// the content: a second write in that tick, after the read, is
106/// invisible to `stat_changed` until the file changes again — if it
107/// never does, that write is never published. Such an entry is recorded
108/// `racy` and is believed only after its content hash agrees
109/// (`confirm_racy`, `barrier::local_dirty`). Two seconds covers every
110/// clock this runs on, coarse-second filesystems included.
111pub const RACY_WINDOW_NANOS: i128 = 2_000_000_000;
112
113pub fn now_nanos() -> i128 {
114 std::time::SystemTime::now()
115 .duration_since(std::time::UNIX_EPOCH)
116 .map(|d| d.as_nanos() as i128)
117 .unwrap_or(0)
118}
119
120/// Whether a stat with this mtime, observed at `at_nanos`, is racy.
121pub fn racy(mtime_unix: i64, mtime_nanos: u32, at_nanos: i128) -> bool {
122 at_nanos - (mtime_unix as i128 * 1_000_000_000 + mtime_nanos as i128) < RACY_WINDOW_NANOS
123}
124
125/// The consume's temp sibling (`safefs`): a crash between its create and
126/// its rename leaves it as a regular file in the tree, and the scan used
127/// to publish it as data (review 2026-09-12, atomicity-7).
128pub const TMP_SUFFIX: &str = ".flint-sync-tmp";
129
130/// The bits of `st_mode` a mode change can move and a checkout restores:
131/// permissions, setuid/setgid and sticky, never the file type.
132pub const PERM_BITS: u32 = 0o7777;
133
134/// Whether two modes differ in `PERM_BITS`. Never off unix, where the
135/// walk reports a constant and a cited mode could never be matched.
136pub fn mode_differs(a: u32, b: u32) -> bool {
137 cfg!(unix) && (a ^ b) & PERM_BITS != 0
138}
139
140/// Whether a file's stat differs from its baseline stamp. Size and
141/// seconds always; nanoseconds and permission bits only when the
142/// baseline recorded them — a baseline written before either was
143/// stamped has `None`, and comparing against a missing value would
144/// re-upload every file once. The permission bits are here because a
145/// `chmod` moves only ctime: without them `chmod +x` was never
146/// published, and no checkout ever saw it. `mode` is the full
147/// `st_mode`.
148pub fn stat_changed(b: &super::state::BaselineEntry, size: u64, mtime: i64, nanos: u32, mode: u32) -> bool {
149 b.size != size
150 || b.mtime_unix != mtime
151 || b.mtime_nanos.map(|n| n != nanos).unwrap_or(false)
152 || b.mode.is_some_and(|m| mode_differs(m, mode))
153}
154
155/// What a walk found: every regular file it could stat, and every path
156/// it was refused (`held`).
157#[derive(Debug, Default)]
158pub struct Scan {
159 pub entries: BTreeMap<String, ScanEntry>,
160 /// Paths the syncer may not read (EACCES or EPERM, which std reports
161 /// alike as `PermissionDenied`: either is a refusal the tree's owner
162 /// set), each standing for itself and everything under it: a
163 /// directory it may not list (written with
164 /// its trailing `/`), an entry it may not lstat (a name in a directory
165 /// it may list but not search, so even its type is unknown). And a
166 /// file or directory whose name is not UTF-8, which no key can name:
167 /// held under its lossy form (`read_dir_entries`). The
168 /// barrier adds the files it may not open and the absences it may not
169 /// confirm. Nothing held is published or deleted, and no boundary is
170 /// complete while anything is held.
171 ///
172 /// This is how each one is SHOWN (in the report, the ack, the log).
173 /// What each one stands for is its `Hold`, kept apart (`hold`): the
174 /// lossy form of a name that is not UTF-8 may spell a real name, and a
175 /// file has nothing under it.
176 pub held: BTreeSet<String>,
177 /// The held paths that stand for everything under them (`Hold::Tree`),
178 /// each without a trailing `/`.
179 held_trees: BTreeSet<String>,
180 /// The held paths that stand for themselves alone (`Hold::File`).
181 held_files: BTreeSet<String>,
182}
183
184/// What a held path stands for: which keys it makes unseen rather than
185/// absent (`Scan::holds`, `Scan::present`).
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum Hold {
188 /// It and everything under it: a directory the walk may not list, or
189 /// a name it may not stat, which may be a directory.
190 Tree,
191 /// Itself alone: a file the syncer may not read, a path too long for
192 /// a key, an absence it may not confirm. Nothing can be under a file:
193 /// held as a tree, a file standing where a cited directory was kept
194 /// every citation under it, a first absence at every barrier (L-147).
195 File,
196 /// No key at all: a name that is not UTF-8, shown with U+FFFD. No
197 /// cited path can be at or under it, and its lossy form may spell a
198 /// real name, which held as a tree it held too, withholding that
199 /// name's deletes (the collision L-141 accepted).
200 Unnamed,
201}
202
203impl Scan {
204 /// Hold the path shown as `shown` (a directory with its trailing `/`).
205 pub fn hold(&mut self, shown: String, kind: Hold) {
206 match kind {
207 Hold::Tree => {
208 self.held_trees.insert(shown.strip_suffix('/').unwrap_or(&shown).to_string());
209 }
210 Hold::File => {
211 self.held_files.insert(shown.clone());
212 }
213 Hold::Unnamed => {}
214 }
215 self.held.insert(shown);
216 }
217
218 /// Whether `path` is held: a held file itself, or at or under a held
219 /// tree.
220 pub fn holds(&self, path: &str) -> bool {
221 self.held_files.contains(path)
222 || self
223 .held_trees
224 .iter()
225 .any(|base| path.strip_prefix(base.as_str()).is_some_and(|r| r.is_empty() || r.starts_with('/')))
226 }
227
228 /// What `prev_scan` records after this scan, in order: every path the
229 /// walk saw, and every path in `cited` that it held. A held path is
230 /// unseen, not absent, so it is recorded PRESENT: a path that turns
231 /// out to be gone once it is readable again is then a first absence,
232 /// and the two-scan rule withholds its delete one more scan, exactly
233 /// as for a path seen a scan ago.
234 pub fn present<'a, V>(&'a self, cited: &'a BTreeMap<String, V>) -> impl Iterator<Item = &'a String> + 'a {
235 let mut unseen: Vec<&'a String> = self
236 .held_trees
237 .iter()
238 .flat_map(|h| under(cited, h))
239 .chain(self.held_files.iter().filter_map(|f| cited.get_key_value(f.as_str()).map(|(k, _)| k)))
240 .collect();
241 unseen.sort_unstable();
242 unseen.dedup();
243 let mut seen = self.entries.keys().peekable();
244 let mut unseen = unseen.into_iter().peekable();
245 std::iter::from_fn(move || match (seen.peek(), unseen.peek()) {
246 (Some(a), Some(b)) => match a.cmp(b) {
247 std::cmp::Ordering::Less => seen.next(),
248 std::cmp::Ordering::Greater => unseen.next(),
249 std::cmp::Ordering::Equal => {
250 unseen.next();
251 seen.next()
252 }
253 },
254 (Some(_), None) => seen.next(),
255 (None, _) => unseen.next(),
256 })
257 }
258}
259
260/// The keys of `cited` at or under the held path `held`. Two lookups,
261/// not one run: `d-1` and `d.txt` sort between `d` and `d/`.
262fn under<'a, V>(cited: &'a BTreeMap<String, V>, held: &'a str) -> impl Iterator<Item = &'a String> + 'a {
263 use std::ops::Bound;
264 let base = held.strip_suffix('/').unwrap_or(held);
265 let dir = format!("{base}/");
266 let exact = cited.get_key_value(base).map(|(k, _)| k);
267 let below: Vec<&'a String> = cited
268 .range::<str, _>((Bound::Included(dir.as_str()), Bound::Unbounded))
269 .map(|(k, _)| k)
270 .take_while(|k| k.starts_with(dir.as_str()))
271 .collect();
272 exact.into_iter().chain(below)
273}
274
275/// What an error on one name says about that name. Every look a barrier
276/// takes at the tree by name (the walk's stat and its open of a directory,
277/// the upload's open, the confirmation of an absence) sorts its error by
278/// this one rule, so an error about one name decides that name, and only
279/// an error about none of them fails the barrier (L-144). Before it, each
280/// call site kept its own list, and any errno not on it, ENOENT among
281/// them, failed the whole barrier: a tree in which anything made and
282/// removed files stopped publishing for as long as it did.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum NameError {
285 /// Nothing is at the name any more (ENOENT): it, or a directory above
286 /// it, was removed or renamed since it was listed.
287 Gone,
288 /// Something other than what was listed is there: a component that is
289 /// no longer a directory (ENOTDIR), a symlink where a file or a
290 /// directory was (ELOOP), or not a regular file (`safefs`'s
291 /// InvalidInput).
292 Replaced,
293 /// The tree's owner refused the syncer (EACCES or EPERM): held.
294 Refused,
295 /// Anything else (EIO, ENAMETOOLONG, EMFILE...): no standing condition
296 /// of one name, so it fails the barrier.
297 Other,
298}
299
300pub fn name_error(e: &std::io::Error) -> NameError {
301 match e.raw_os_error() {
302 Some(libc::ENOENT) => NameError::Gone,
303 Some(libc::ENOTDIR) | Some(libc::ELOOP) => NameError::Replaced,
304 _ if e.kind() == std::io::ErrorKind::PermissionDenied => NameError::Refused,
305 _ if e.kind() == std::io::ErrorKind::InvalidInput => NameError::Replaced,
306 _ => NameError::Other,
307 }
308}
309
310/// A test window inside the walk (`barrier::CONSUME_WINDOW_HOOK`), on the
311/// one-thread walk only: the hook is the calling thread's. `rel` is built
312/// only when a hook is armed.
313#[cfg(test)]
314fn walk_window(stage: &'static str, rel: impl FnOnce() -> String) {
315 if super::barrier::CONSUME_WINDOW_HOOK.with(|h| h.borrow().is_some()) {
316 super::barrier::consume_window(stage, &rel());
317 }
318}
319
320#[cfg(not(test))]
321#[inline(always)]
322fn walk_window(_: &'static str, _: impl FnOnce() -> String) {}
323
324/// Whether the syncer may read the file at `rel`: false only when the
325/// kernel answers EACCES or EPERM. Asked of each upload before the
326/// barrier's idle check, so a file it may not read is held there and an
327/// otherwise idle tick stays idle; `upload_one` holds one that turns
328/// unreadable after this. The syncer's real and effective uids are one,
329/// so `access` is the open's answer.
330pub fn may_read(root: &Path, rel: &str) -> bool {
331 #[cfg(unix)]
332 {
333 use std::os::unix::ffi::OsStrExt;
334 let Ok(c) = std::ffi::CString::new(root.join(rel).as_os_str().as_bytes()) else { return true };
335 if unsafe { libc::access(c.as_ptr(), libc::R_OK) } == 0 {
336 return true;
337 }
338 std::io::Error::last_os_error().kind() != std::io::ErrorKind::PermissionDenied
339 }
340 #[cfg(not(unix))]
341 {
342 let _ = (root, rel);
343 true
344 }
345}
346
347/// Walk the workspace. Skips the state dir, the control namespace and
348/// symlinks (v1 non-goal, as the tier manifest's).
349///
350/// What the syncer may not read (EACCES or EPERM) is HELD (`Scan::held`),
351/// never skipped: a directory it may not list, an entry it may not lstat.
352/// `classify` reads a path missing from two scans as the agent's delete,
353/// and the barrier then publishes the deletion and deletes the object,
354/// so a skipped directory would cost every file under it; a held one
355/// costs nothing, because `classify` neither uploads nor deletes a path
356/// at or under it, and `Scan::present` keeps it in `prev_scan`. The rest
357/// of the tree publishes. The walk used to FAIL instead (c24beeb7), and
358/// then one 0700 directory made by an app running as a uid other than
359/// the CR's stopped every publish in the workspace until it was readable.
360///
361/// A path's key is its names, byte for byte, so the barrier opens the
362/// file it scanned. A name that is not UTF-8 has no such key and is held
363/// too (L-141); a `\` in a name is a byte like any other (L-142). Either
364/// used to give a key that named another file or none, and the upload's
365/// ENOENT failed every barrier in the workspace.
366///
367/// A name that is GONE by the time the walk stats it, or a directory gone
368/// or replaced by the time the walk opens it, is absent from this scan
369/// (`NameError`, L-144): the agent removed or moved it while the walk ran,
370/// and the two-scan rule is what makes reading that as absence safe.
371///
372/// Every other error FAILS the walk, and with it the barrier: an EIO is
373/// not a standing condition the tree's owner set, and an unreadable ROOT
374/// fails too (nothing could publish; the error names why).
375pub fn scan(root: &Path) -> LeanResult<Scan> {
376 scan_with(root, default_threads())
377}
378
379/// Threads the walk uses unless told otherwise: the CPUs this process may
380/// use (the cgroup's quota and the affinity mask count), at most 8.
381pub fn default_threads() -> usize {
382 std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1).min(8)
383}
384
385/// `scan`, by `threads` workers (lever E). They take directories from one
386/// queue, so a tree that is one huge directory and a tree of thousands of
387/// small ones both spread. Each entry is stat'ed relative to its
388/// directory's descriptor, not by its full path (`read_dir_entries`). What
389/// it returns is what the one-thread walk returned:
390/// the same entries under the same exclusions, and the first error any
391/// worker meets fails the whole walk (`Queue::failed`), never dropped
392/// with the worker that met it. One worker walks on the calling thread.
393/// What it holds is the same set at any thread count.
394pub fn scan_with(root: &Path, threads: usize) -> LeanResult<Scan> {
395 let queue = Mutex::new(Queue { dirs: vec![(root.to_path_buf(), String::new())], busy: 0, failed: None });
396 let wake = Condvar::new();
397 let found: Vec<Found> = if threads <= 1 {
398 vec![walk_worker(&queue, &wake)]
399 } else {
400 std::thread::scope(|s| {
401 let workers: Vec<_> = (0..threads).map(|_| s.spawn(|| walk_worker(&queue, &wake))).collect();
402 workers.into_iter().map(|w| w.join().expect("a scan worker panicked")).collect()
403 })
404 };
405 if let Some(e) = queue.into_inner().unwrap_or_else(|p| p.into_inner()).failed {
406 return Err(e.into());
407 }
408 // The keys the map keeps are made here, on the calling thread, never
409 // in a worker. mimalloc, which flint-sync ships with, returns a freed
410 // block to the heap of the thread that allocated it, so a million keys
411 // made by workers left this thread's next large allocations (the
412 // commit's documents) on cold pages: at 1M files, 480 ms more across
413 // classify, load, merge, cas and retire_set (latency plan §13).
414 let mut all: Vec<(String, ScanEntry)> = Vec::with_capacity(found.iter().map(|f| f.files.len()).sum());
415 let mut scan = Scan::default();
416 for f in found {
417 for (shown, kind) in f.held {
418 scan.hold(shown, kind);
419 }
420 let mut start = 0;
421 for (end, e) in f.files {
422 all.push((f.names[start..end].to_string(), e));
423 start = end;
424 }
425 }
426 // No two files give one key: a key is the names on the file's path,
427 // byte for byte (`read_dir_entries`). Two did once, when a `\` became
428 // `/` and a name not UTF-8 was keyed by its lossy form, and the walk
429 // had to choose one by a rule of the tree. Sorted here, `from_iter`
430 // finds the order made and builds in bulk, not by a million inserts.
431 all.sort_unstable_by(|a, b| a.0.cmp(&b.0));
432 scan.entries = all.into_iter().collect();
433 Ok(scan)
434}
435
436/// The walk's shared state: directories not yet read (with the relative
437/// prefix of what is under each), how many are being read, and the first
438/// error.
439struct Queue {
440 dirs: Vec<(PathBuf, String)>,
441 busy: usize,
442 failed: Option<std::io::Error>,
443}
444
445/// What one worker found: the files' relative paths end to end in one
446/// buffer, and per file where its path ends there and what was stat'ed.
447/// A few large allocations per worker, not one per file. And what it
448/// held (`Scan::held`), which is rare.
449#[derive(Default)]
450struct Found {
451 names: String,
452 files: Vec<(usize, ScanEntry)>,
453 held: Vec<(String, Hold)>,
454}
455
456fn walk_worker(queue: &Mutex<Queue>, wake: &Condvar) -> Found {
457 let mut out = Found::default();
458 loop {
459 let (dir, prefix) = {
460 let mut q = queue.lock().unwrap_or_else(|p| p.into_inner());
461 loop {
462 if q.failed.is_some() {
463 return out;
464 }
465 if let Some(next) = q.dirs.pop() {
466 q.busy += 1;
467 break next;
468 }
469 if q.busy == 0 {
470 // Nothing queued and nobody reading: the tree is done.
471 wake.notify_all();
472 return out;
473 }
474 q = wake.wait(q).unwrap_or_else(|p| p.into_inner());
475 }
476 };
477 let mut subdirs = vec![];
478 let read = read_dir_entries(&dir, &prefix, &mut out, &mut subdirs);
479 let mut q = queue.lock().unwrap_or_else(|p| p.into_inner());
480 q.busy -= 1;
481 match read {
482 Ok(()) => q.dirs.extend(subdirs),
483 Err(e) => {
484 if q.failed.is_none() {
485 q.failed = Some(e);
486 }
487 }
488 }
489 wake.notify_all();
490 }
491}
492
493/// One directory: its files into `out`, its subdirectories onto `subdirs`.
494/// `prefix` is the directory's path relative to the root, with a trailing
495/// `/`; empty at the root.
496///
497/// The directory is read with `getdents64` and each name is stat'ed
498/// relative to the directory's own descriptor (`fstatat`,
499/// `AT_SYMLINK_NOFOLLOW`): no entry's full path is built, and the kernel
500/// resolves one name, not every component of the path. std's
501/// `DirEntry::metadata` does the same on glibc, but on musl, which
502/// flint-sync ships with, it joins the full path and `lstat`s it, and
503/// musl's `readdir` reads 2 KiB at a time (latency plan §13).
504fn read_dir_entries(
505 dir: &Path,
506 prefix: &str,
507 out: &mut Found,
508 subdirs: &mut Vec<(PathBuf, String)>,
509) -> std::io::Result<()> {
510 use rustix::fs::{AtFlags, FileType, Mode, OFlags};
511 use std::os::unix::ffi::OsStrExt;
512 walk_window("walk-before-open", || prefix.to_string());
513 let opened = rustix::fs::open(dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty());
514 let fd = match opened.map_err(std::io::Error::from) {
515 Ok(fd) => fd,
516 // Below the root (`scan`): a directory gone or replaced since it
517 // was listed is absent from this scan; one the syncer may not
518 // list is held, not failed.
519 Err(e) if !prefix.is_empty() => match name_error(&e) {
520 NameError::Gone | NameError::Replaced => return Ok(()),
521 NameError::Refused => {
522 out.held.push((prefix.to_string(), Hold::Tree));
523 return Ok(());
524 }
525 NameError::Other => return Err(e),
526 },
527 Err(e) => return Err(e),
528 };
529 let mut entries = rustix::fs::Dir::new(fd)?;
530 while let Some(entry) = entries.read() {
531 let entry = entry?;
532 let bytes = entry.file_name().to_bytes();
533 if bytes == b"." || bytes == b".." {
534 continue;
535 }
536 let name = String::from_utf8_lossy(bytes);
537 // A name that is not UTF-8 has no key. Its lossy form names another
538 // file or none, so the upload opened that and its ENOENT failed the
539 // barrier at every tick (L-141). Held, under the lossy form.
540 let keyless = matches!(name, std::borrow::Cow::Owned(_));
541 if prefix.is_empty() {
542 // The state dir and the control namespace (boundary-verbs
543 // plan D0). Without the second exclusion `.flint/publish`
544 // is live ammunition: an ordinary regular file, scanned and
545 // PUBLISHED to `<prefix>/files/.flint/publish`.
546 if name == STATE_DIR || name == CONTROL_DIR {
547 continue;
548 }
549 }
550 if name.ends_with(TMP_SUFFIX) {
551 continue;
552 }
553 walk_window("walk-before-stat", || format!("{prefix}{name}"));
554 let stat = rustix::fs::statat(entries.fd()?, entry.file_name(), AtFlags::SYMLINK_NOFOLLOW);
555 let st = match stat.map_err(std::io::Error::from) {
556 Ok(st) => st,
557 Err(e) => match name_error(&e) {
558 // Removed since `getdents` listed it: absent from this scan.
559 NameError::Gone | NameError::Replaced => continue,
560 // A directory the syncer may list but not search: each
561 // name in it is held, file or directory alike.
562 NameError::Refused => {
563 out.held.push((format!("{prefix}{name}"), if keyless { Hold::Unnamed } else { Hold::Tree }));
564 continue;
565 }
566 NameError::Other => return Err(e),
567 },
568 };
569 match FileType::from_raw_mode(st.st_mode as _) {
570 FileType::Directory => {
571 let rel = format!("{prefix}{name}/");
572 if keyless {
573 // Nothing under it has a key either.
574 out.held.push((rel, Hold::Unnamed));
575 } else {
576 subdirs.push((dir.join(std::ffi::OsStr::from_bytes(bytes)), rel));
577 }
578 }
579 FileType::RegularFile if keyless => out.held.push((format!("{prefix}{name}"), Hold::Unnamed)),
580 FileType::RegularFile => {
581 // As std's `modified()` and `duration_since(UNIX_EPOCH)`
582 // gave it: a time before the epoch is (0, 0).
583 let (mtime_unix, mtime_nanos) = if st.st_mtime >= 0 && (st.st_mtime_nsec as u64) < 1_000_000_000 {
584 (st.st_mtime as i64, st.st_mtime_nsec as u32)
585 } else {
586 (0, 0)
587 };
588 out.names.push_str(prefix);
589 out.names.push_str(&name);
590 out.files.push((
591 out.names.len(),
592 ScanEntry {
593 size: st.st_size as u64,
594 mtime_unix,
595 mtime_nanos,
596 mode: st.st_mode as u32,
597 seen_nanos: now_nanos(),
598 change: ChangeStamp {
599 ctime: st.st_ctime as i64,
600 ctime_nanos: st.st_ctime_nsec as u32,
601 ino: st.st_ino as u64,
602 },
603 },
604 ));
605 }
606 // Symlinks are never followed, and what is neither a
607 // directory nor a regular file is not data.
608 _ => {}
609 }
610 }
611 Ok(())
612}
613
614#[derive(Debug, Default)]
615pub struct Classified {
616 /// Changed or new vs the baseline (by size/mtime; an entry whose stat
617 /// is too close to its mtime to vouch for the bytes is settled by
618 /// `confirm_racy`, which callers run next).
619 pub uploads: BTreeSet<String>,
620 /// Absent this scan AND the previous scan, present in the baseline.
621 pub deletes: BTreeSet<String>,
622 /// Absent this scan but present in the previous one: NOT yet
623 /// delete-eligible (first absence).
624 pub first_absence: BTreeSet<String>,
625}
626
627/// Legacy citations under the reserved control namespace (boundary-verbs
628/// plan D0.2). A workspace that legally published `files/.flint/...`
629/// under a pre-D0 syncer has those paths in its baseline; the new scan
630/// skips them, so two consecutive scans would classify them absent and
631/// publish their DELETION. An upgrade must never delete data: they are
632/// carried forward frozen — never re-uploaded, never deleted by us.
633pub fn is_control_path(path: &str) -> bool {
634 // Both reserved names (review 2026-09-12, inbox-8: only `.flint/`
635 // was refused, so a citation naming `.flint-sync/scope-intent.json`
636 // was materialised INTO the state directory and replayed).
637 [CONTROL_DIR, STATE_DIR].iter().any(|d| {
638 path == *d || path.strip_prefix(d).map(|r| r.starts_with('/')).unwrap_or(false)
639 })
640}
641
642/// What a hash that vouched for an unsure entry leaves in its baseline
643/// entry (`confirm_racy`): the change stamp the scan saw, and whether the
644/// entry is still racy, measured now from its later stamp.
645#[derive(Debug, Clone)]
646pub struct Vouched {
647 pub path: String,
648 pub change: ChangeStamp,
649 pub racy: bool,
650}
651
652/// The unsure half of `classify`: an entry whose stat is unchanged but
653/// cannot vouch for its bytes (recorded `racy`, or its change stamp moved:
654/// `stamp_moved`) is hashed, and is an upload if its bytes are not the
655/// ones the baseline integrated (or cannot be read to tell). Returns the
656/// unsure entries the hash vouched for, each with the stamp to record and
657/// whether it is still racy; the caller writes the ones that changed. Run
658/// after `classify`, before anything trusts it. The model's `Confirm`.
659pub fn confirm_racy(
660 root: &Path,
661 scan: &Scan,
662 baseline: &Baseline,
663 c: &mut Classified,
664) -> Vec<Vouched> {
665 let now = now_nanos();
666 let mut vouched = vec![];
667 for (path, s) in &scan.entries {
668 let Some(b) = baseline.entries.get(path) else { continue };
669 if !(b.racy || stamp_moved(b, &s.change)) || c.uploads.contains(path) {
670 continue;
671 }
672 match super::barrier::file_crc(root, path) {
673 Ok(crc) if b.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) => {
674 let (sec, nanos) = later(s.mtime_unix, s.mtime_nanos, Some(&s.change));
675 vouched.push(Vouched { path: path.clone(), change: s.change, racy: racy(sec, nanos, now) });
676 }
677 _ => {
678 c.uploads.insert(path.clone());
679 }
680 }
681 }
682 vouched
683}
684
685pub fn classify(scan: &Scan, baseline: &Baseline) -> Classified {
686 let mut c = Classified::default();
687 for (path, s) in &scan.entries {
688 match baseline.entries.get(path) {
689 None => {
690 c.uploads.insert(path.clone());
691 }
692 Some(b) => {
693 if stat_changed(b, s.size, s.mtime_unix, s.mtime_nanos, s.mode) {
694 c.uploads.insert(path.clone());
695 }
696 }
697 }
698 }
699 for path in baseline.entries.keys() {
700 if scan.entries.contains_key(path) {
701 continue;
702 }
703 if scan.holds(path) {
704 // Unseen, not absent: neither a delete nor a first absence.
705 continue;
706 }
707 if is_control_path(path) {
708 // Frozen legacy citation (D0.2): absent from every scan by
709 // construction now, and NEVER delete-eligible.
710 continue;
711 }
712 if baseline.prev_scan.contains(path) {
713 // Present a scan ago: first observed absence.
714 c.first_absence.insert(path.clone());
715 } else {
716 c.deletes.insert(path.clone());
717 }
718 }
719 c
720}