Skip to main content

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}