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}
33
34/// RACY CLEAN (git's rule, `racy-git.txt`). A file's mtime comes from a
35/// coarse kernel clock (a few milliseconds a tick), so two same-size
36/// writes inside one tick leave identical stat. A baseline entry whose
37/// bytes were read within a tick of the file's mtime cannot vouch for
38/// the content: a second write in that tick, after the read, is
39/// invisible to `stat_changed` until the file changes again — if it
40/// never does, that write is never published. Such an entry is recorded
41/// `racy` and is believed only after its content hash agrees
42/// (`confirm_racy`, `barrier::local_dirty`). Two seconds covers every
43/// clock this runs on, coarse-second filesystems included.
44pub const RACY_WINDOW_NANOS: i128 = 2_000_000_000;
45
46pub fn now_nanos() -> i128 {
47    std::time::SystemTime::now()
48        .duration_since(std::time::UNIX_EPOCH)
49        .map(|d| d.as_nanos() as i128)
50        .unwrap_or(0)
51}
52
53/// Whether a stat with this mtime, observed at `at_nanos`, is racy.
54pub fn racy(mtime_unix: i64, mtime_nanos: u32, at_nanos: i128) -> bool {
55    at_nanos - (mtime_unix as i128 * 1_000_000_000 + mtime_nanos as i128) < RACY_WINDOW_NANOS
56}
57
58/// The consume's temp sibling (`safefs`): a crash between its create and
59/// its rename leaves it as a regular file in the tree, and the scan used
60/// to publish it as data (review 2026-09-12, atomicity-7).
61pub const TMP_SUFFIX: &str = ".flint-sync-tmp";
62
63/// The bits of `st_mode` a mode change can move and a checkout restores:
64/// permissions, setuid/setgid and sticky, never the file type.
65pub const PERM_BITS: u32 = 0o7777;
66
67/// Whether two modes differ in `PERM_BITS`. Never off unix, where the
68/// walk reports a constant and a cited mode could never be matched.
69pub fn mode_differs(a: u32, b: u32) -> bool {
70    cfg!(unix) && (a ^ b) & PERM_BITS != 0
71}
72
73/// Whether a file's stat differs from its baseline stamp. Size and
74/// seconds always; nanoseconds and permission bits only when the
75/// baseline recorded them — a baseline written before either was
76/// stamped has `None`, and comparing against a missing value would
77/// re-upload every file once. The permission bits are here because a
78/// `chmod` moves only ctime: without them `chmod +x` was never
79/// published, and no checkout ever saw it. `mode` is the full
80/// `st_mode`.
81pub fn stat_changed(b: &super::state::BaselineEntry, size: u64, mtime: i64, nanos: u32, mode: u32) -> bool {
82    b.size != size
83        || b.mtime_unix != mtime
84        || b.mtime_nanos.map(|n| n != nanos).unwrap_or(false)
85        || b.mode.is_some_and(|m| mode_differs(m, mode))
86}
87
88/// What a walk found: every regular file it could stat, and every path
89/// it was refused (`held`).
90#[derive(Debug, Default)]
91pub struct Scan {
92    pub entries: BTreeMap<String, ScanEntry>,
93    /// Paths the syncer may not read (EACCES or EPERM, which std reports
94    /// alike as `PermissionDenied`: either is a refusal the tree's owner
95    /// set), each standing for itself and everything under it: a
96    /// directory it may not list (written with
97    /// its trailing `/`), an entry it may not lstat (a name in a directory
98    /// it may list but not search, so even its type is unknown). And a
99    /// file or directory whose name is not UTF-8, which no key can name:
100    /// held under its lossy form (`read_dir_entries`). The
101    /// barrier adds the files it may not open and the absences it may not
102    /// confirm. Nothing held is published or deleted, and no boundary is
103    /// complete while anything is held.
104    pub held: BTreeSet<String>,
105}
106
107impl Scan {
108    /// Whether `path` is held: a held path itself, or under one.
109    pub fn holds(&self, path: &str) -> bool {
110        !self.held.is_empty()
111            && self.held.iter().any(|h| {
112                let base = h.strip_suffix('/').unwrap_or(h);
113                path.strip_prefix(base).is_some_and(|r| r.is_empty() || r.starts_with('/'))
114            })
115    }
116
117    /// What `prev_scan` records after this scan, in order: every path the
118    /// walk saw, and every path in `cited` that it held. A held path is
119    /// unseen, not absent, so it is recorded PRESENT: a path that turns
120    /// out to be gone once it is readable again is then a first absence,
121    /// and the two-scan rule withholds its delete one more scan, exactly
122    /// as for a path seen a scan ago.
123    pub fn present<'a, V>(&'a self, cited: &'a BTreeMap<String, V>) -> impl Iterator<Item = &'a String> + 'a {
124        let mut unseen: Vec<&'a String> = self.held.iter().flat_map(|h| under(cited, h)).collect();
125        unseen.sort_unstable();
126        unseen.dedup();
127        let mut seen = self.entries.keys().peekable();
128        let mut unseen = unseen.into_iter().peekable();
129        std::iter::from_fn(move || match (seen.peek(), unseen.peek()) {
130            (Some(a), Some(b)) => match a.cmp(b) {
131                std::cmp::Ordering::Less => seen.next(),
132                std::cmp::Ordering::Greater => unseen.next(),
133                std::cmp::Ordering::Equal => {
134                    unseen.next();
135                    seen.next()
136                }
137            },
138            (Some(_), None) => seen.next(),
139            (None, _) => unseen.next(),
140        })
141    }
142}
143
144/// The keys of `cited` at or under the held path `held`. Two lookups,
145/// not one run: `d-1` and `d.txt` sort between `d` and `d/`.
146fn under<'a, V>(cited: &'a BTreeMap<String, V>, held: &'a str) -> impl Iterator<Item = &'a String> + 'a {
147    use std::ops::Bound;
148    let base = held.strip_suffix('/').unwrap_or(held);
149    let dir = format!("{base}/");
150    let exact = cited.get_key_value(base).map(|(k, _)| k);
151    let below: Vec<&'a String> = cited
152        .range::<str, _>((Bound::Included(dir.as_str()), Bound::Unbounded))
153        .map(|(k, _)| k)
154        .take_while(|k| k.starts_with(dir.as_str()))
155        .collect();
156    exact.into_iter().chain(below)
157}
158
159/// Whether the syncer may read the file at `rel`: false only when the
160/// kernel answers EACCES or EPERM. Asked of each upload before the
161/// barrier's idle check, so a file it may not read is held there and an
162/// otherwise idle tick stays idle; `upload_one` holds one that turns
163/// unreadable after this. The syncer's real and effective uids are one,
164/// so `access` is the open's answer.
165pub fn may_read(root: &Path, rel: &str) -> bool {
166    #[cfg(unix)]
167    {
168        use std::os::unix::ffi::OsStrExt;
169        let Ok(c) = std::ffi::CString::new(root.join(rel).as_os_str().as_bytes()) else { return true };
170        if unsafe { libc::access(c.as_ptr(), libc::R_OK) } == 0 {
171            return true;
172        }
173        std::io::Error::last_os_error().kind() != std::io::ErrorKind::PermissionDenied
174    }
175    #[cfg(not(unix))]
176    {
177        let _ = (root, rel);
178        true
179    }
180}
181
182/// Walk the workspace. Skips the state dir, the control namespace and
183/// symlinks (v1 non-goal, as the tier manifest's).
184///
185/// What the syncer may not read (EACCES or EPERM) is HELD (`Scan::held`),
186/// never skipped: a directory it may not list, an entry it may not lstat.
187/// `classify` reads a path missing from two scans as the agent's delete,
188/// and the barrier then publishes the deletion and deletes the object,
189/// so a skipped directory would cost every file under it; a held one
190/// costs nothing, because `classify` neither uploads nor deletes a path
191/// at or under it, and `Scan::present` keeps it in `prev_scan`. The rest
192/// of the tree publishes. The walk used to FAIL instead (c24beeb7), and
193/// then one 0700 directory made by an app running as a uid other than
194/// the CR's stopped every publish in the workspace until it was readable.
195///
196/// A path's key is its names, byte for byte, so the barrier opens the
197/// file it scanned. A name that is not UTF-8 has no such key and is held
198/// too (L-141); a `\` in a name is a byte like any other (L-142). Either
199/// used to give a key that named another file or none, and the upload's
200/// ENOENT failed every barrier in the workspace.
201///
202/// Every other error FAILS the walk, and with it the barrier: an EIO is
203/// not a standing condition the tree's owner set, and an unreadable ROOT
204/// fails too (nothing could publish; the error names why).
205pub fn scan(root: &Path) -> LeanResult<Scan> {
206    scan_with(root, default_threads())
207}
208
209/// Threads the walk uses unless told otherwise: the CPUs this process may
210/// use (the cgroup's quota and the affinity mask count), at most 8.
211pub fn default_threads() -> usize {
212    std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1).min(8)
213}
214
215/// `scan`, by `threads` workers (lever E). They take directories from one
216/// queue, so a tree that is one huge directory and a tree of thousands of
217/// small ones both spread. Each entry is stat'ed relative to its
218/// directory's descriptor, not by its full path (`read_dir_entries`). What
219/// it returns is what the one-thread walk returned:
220/// the same entries under the same exclusions, and the first error any
221/// worker meets fails the whole walk (`Queue::failed`), never dropped
222/// with the worker that met it. One worker walks on the calling thread.
223/// What it holds is the same set at any thread count.
224pub fn scan_with(root: &Path, threads: usize) -> LeanResult<Scan> {
225    let queue = Mutex::new(Queue { dirs: vec![(root.to_path_buf(), String::new())], busy: 0, failed: None });
226    let wake = Condvar::new();
227    let found: Vec<Found> = if threads <= 1 {
228        vec![walk_worker(&queue, &wake)]
229    } else {
230        std::thread::scope(|s| {
231            let workers: Vec<_> = (0..threads).map(|_| s.spawn(|| walk_worker(&queue, &wake))).collect();
232            workers.into_iter().map(|w| w.join().expect("a scan worker panicked")).collect()
233        })
234    };
235    if let Some(e) = queue.into_inner().unwrap_or_else(|p| p.into_inner()).failed {
236        return Err(e.into());
237    }
238    // The keys the map keeps are made here, on the calling thread, never
239    // in a worker. mimalloc, which flint-sync ships with, returns a freed
240    // block to the heap of the thread that allocated it, so a million keys
241    // made by workers left this thread's next large allocations (the
242    // commit's documents) on cold pages: at 1M files, 480 ms more across
243    // classify, load, merge, cas and retire_set (latency plan §13).
244    let mut all: Vec<(String, ScanEntry)> = Vec::with_capacity(found.iter().map(|f| f.files.len()).sum());
245    let mut held = BTreeSet::new();
246    for f in found {
247        held.extend(f.held);
248        let mut start = 0;
249        for (end, e) in f.files {
250            all.push((f.names[start..end].to_string(), e));
251            start = end;
252        }
253    }
254    // No two files give one key: a key is the names on the file's path,
255    // byte for byte (`read_dir_entries`). Two did once, when a `\` became
256    // `/` and a name not UTF-8 was keyed by its lossy form, and the walk
257    // had to choose one by a rule of the tree. Sorted here, `from_iter`
258    // finds the order made and builds in bulk, not by a million inserts.
259    all.sort_unstable_by(|a, b| a.0.cmp(&b.0));
260    Ok(Scan { entries: all.into_iter().collect(), held })
261}
262
263/// The walk's shared state: directories not yet read (with the relative
264/// prefix of what is under each), how many are being read, and the first
265/// error.
266struct Queue {
267    dirs: Vec<(PathBuf, String)>,
268    busy: usize,
269    failed: Option<std::io::Error>,
270}
271
272/// What one worker found: the files' relative paths end to end in one
273/// buffer, and per file where its path ends there and what was stat'ed.
274/// A few large allocations per worker, not one per file. And what it
275/// held (`Scan::held`), which is rare.
276#[derive(Default)]
277struct Found {
278    names: String,
279    files: Vec<(usize, ScanEntry)>,
280    held: Vec<String>,
281}
282
283fn walk_worker(queue: &Mutex<Queue>, wake: &Condvar) -> Found {
284    let mut out = Found::default();
285    loop {
286        let (dir, prefix) = {
287            let mut q = queue.lock().unwrap_or_else(|p| p.into_inner());
288            loop {
289                if q.failed.is_some() {
290                    return out;
291                }
292                if let Some(next) = q.dirs.pop() {
293                    q.busy += 1;
294                    break next;
295                }
296                if q.busy == 0 {
297                    // Nothing queued and nobody reading: the tree is done.
298                    wake.notify_all();
299                    return out;
300                }
301                q = wake.wait(q).unwrap_or_else(|p| p.into_inner());
302            }
303        };
304        let mut subdirs = vec![];
305        let read = read_dir_entries(&dir, &prefix, &mut out, &mut subdirs);
306        let mut q = queue.lock().unwrap_or_else(|p| p.into_inner());
307        q.busy -= 1;
308        match read {
309            Ok(()) => q.dirs.extend(subdirs),
310            Err(e) => {
311                if q.failed.is_none() {
312                    q.failed = Some(e);
313                }
314            }
315        }
316        wake.notify_all();
317    }
318}
319
320/// One directory: its files into `out`, its subdirectories onto `subdirs`.
321/// `prefix` is the directory's path relative to the root, with a trailing
322/// `/`; empty at the root.
323///
324/// The directory is read with `getdents64` and each name is stat'ed
325/// relative to the directory's own descriptor (`fstatat`,
326/// `AT_SYMLINK_NOFOLLOW`): no entry's full path is built, and the kernel
327/// resolves one name, not every component of the path. std's
328/// `DirEntry::metadata` does the same on glibc, but on musl, which
329/// flint-sync ships with, it joins the full path and `lstat`s it, and
330/// musl's `readdir` reads 2 KiB at a time (latency plan §13).
331fn read_dir_entries(
332    dir: &Path,
333    prefix: &str,
334    out: &mut Found,
335    subdirs: &mut Vec<(PathBuf, String)>,
336) -> std::io::Result<()> {
337    use rustix::fs::{AtFlags, FileType, Mode, OFlags};
338    use std::os::unix::ffi::OsStrExt;
339    let opened = rustix::fs::open(dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty());
340    let fd = match opened.map_err(std::io::Error::from) {
341        Ok(fd) => fd,
342        // Held, not failed, below the root (`scan`).
343        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && !prefix.is_empty() => {
344            out.held.push(prefix.to_string());
345            return Ok(());
346        }
347        Err(e) => return Err(e),
348    };
349    let mut entries = rustix::fs::Dir::new(fd)?;
350    while let Some(entry) = entries.read() {
351        let entry = entry?;
352        let bytes = entry.file_name().to_bytes();
353        if bytes == b"." || bytes == b".." {
354            continue;
355        }
356        let name = String::from_utf8_lossy(bytes);
357        // A name that is not UTF-8 has no key. Its lossy form names another
358        // file or none, so the upload opened that and its ENOENT failed the
359        // barrier at every tick (L-141). Held, under the lossy form.
360        let keyless = matches!(name, std::borrow::Cow::Owned(_));
361        if prefix.is_empty() {
362            // The state dir and the control namespace (boundary-verbs
363            // plan D0). Without the second exclusion `.flint/publish`
364            // is live ammunition: an ordinary regular file, scanned and
365            // PUBLISHED to `<prefix>/files/.flint/publish`.
366            if name == STATE_DIR || name == CONTROL_DIR {
367                continue;
368            }
369        }
370        if name.ends_with(TMP_SUFFIX) {
371            continue;
372        }
373        let stat = rustix::fs::statat(entries.fd()?, entry.file_name(), AtFlags::SYMLINK_NOFOLLOW);
374        let st = match stat.map_err(std::io::Error::from) {
375            Ok(st) => st,
376            // A directory the syncer may list but not search: each name
377            // in it is held, file or directory alike.
378            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
379                out.held.push(format!("{prefix}{name}"));
380                continue;
381            }
382            Err(e) => return Err(e),
383        };
384        match FileType::from_raw_mode(st.st_mode as _) {
385            FileType::Directory => {
386                let rel = format!("{prefix}{name}/");
387                if keyless {
388                    // Nothing under it has a key either.
389                    out.held.push(rel);
390                } else {
391                    subdirs.push((dir.join(std::ffi::OsStr::from_bytes(bytes)), rel));
392                }
393            }
394            FileType::RegularFile if keyless => out.held.push(format!("{prefix}{name}")),
395            FileType::RegularFile => {
396                // As std's `modified()` and `duration_since(UNIX_EPOCH)`
397                // gave it: a time before the epoch is (0, 0).
398                let (mtime_unix, mtime_nanos) = if st.st_mtime >= 0 && (st.st_mtime_nsec as u64) < 1_000_000_000 {
399                    (st.st_mtime as i64, st.st_mtime_nsec as u32)
400                } else {
401                    (0, 0)
402                };
403                out.names.push_str(prefix);
404                out.names.push_str(&name);
405                out.files.push((
406                    out.names.len(),
407                    ScanEntry {
408                        size: st.st_size as u64,
409                        mtime_unix,
410                        mtime_nanos,
411                        mode: st.st_mode as u32,
412                        seen_nanos: now_nanos(),
413                    },
414                ));
415            }
416            // Symlinks are never followed, and what is neither a
417            // directory nor a regular file is not data.
418            _ => {}
419        }
420    }
421    Ok(())
422}
423
424#[derive(Debug, Default)]
425pub struct Classified {
426    /// Changed or new vs the baseline (by size/mtime; an entry whose stat
427    /// is too close to its mtime to vouch for the bytes is settled by
428    /// `confirm_racy`, which callers run next).
429    pub uploads: BTreeSet<String>,
430    /// Absent this scan AND the previous scan, present in the baseline.
431    pub deletes: BTreeSet<String>,
432    /// Absent this scan but present in the previous one: NOT yet
433    /// delete-eligible (first absence).
434    pub first_absence: BTreeSet<String>,
435}
436
437/// Legacy citations under the reserved control namespace (boundary-verbs
438/// plan D0.2). A workspace that legally published `files/.flint/...`
439/// under a pre-D0 syncer has those paths in its baseline; the new scan
440/// skips them, so two consecutive scans would classify them absent and
441/// publish their DELETION. An upgrade must never delete data: they are
442/// carried forward frozen — never re-uploaded, never deleted by us.
443pub fn is_control_path(path: &str) -> bool {
444    // Both reserved names (review 2026-09-12, inbox-8: only `.flint/`
445    // was refused, so a citation naming `.flint-sync/scope-intent.json`
446    // was materialised INTO the state directory and replayed).
447    [CONTROL_DIR, STATE_DIR].iter().any(|d| {
448        path == *d || path.strip_prefix(d).map(|r| r.starts_with('/')).unwrap_or(false)
449    })
450}
451
452/// The racy half of `classify`: an entry whose stat is unchanged but was
453/// recorded `racy` is hashed, and is an upload if its bytes are not the
454/// ones the baseline integrated (or cannot be read to tell). Returns the
455/// racy entries the hash vouched for AND whose mtime is now outside the
456/// window — those can no longer hide a same-tick write, and the caller
457/// may clear the flag. Run after `classify`, before anything trusts it.
458pub fn confirm_racy(
459    root: &Path,
460    scan: &Scan,
461    baseline: &Baseline,
462    c: &mut Classified,
463) -> Vec<String> {
464    let now = now_nanos();
465    let mut cleared = vec![];
466    for (path, s) in &scan.entries {
467        let Some(b) = baseline.entries.get(path) else { continue };
468        if !b.racy || c.uploads.contains(path) {
469            continue;
470        }
471        match super::barrier::file_crc(root, path) {
472            Ok(crc) if b.crc64_b64.as_deref() == Some(crc64_to_b64(crc).as_str()) => {
473                if !racy(s.mtime_unix, s.mtime_nanos, now) {
474                    cleared.push(path.clone());
475                }
476            }
477            _ => {
478                c.uploads.insert(path.clone());
479            }
480        }
481    }
482    cleared
483}
484
485pub fn classify(scan: &Scan, baseline: &Baseline) -> Classified {
486    let mut c = Classified::default();
487    for (path, s) in &scan.entries {
488        match baseline.entries.get(path) {
489            None => {
490                c.uploads.insert(path.clone());
491            }
492            Some(b) => {
493                if stat_changed(b, s.size, s.mtime_unix, s.mtime_nanos, s.mode) {
494                    c.uploads.insert(path.clone());
495                }
496            }
497        }
498    }
499    for path in baseline.entries.keys() {
500        if scan.entries.contains_key(path) {
501            continue;
502        }
503        if scan.holds(path) {
504            // Unseen, not absent: neither a delete nor a first absence.
505            continue;
506        }
507        if is_control_path(path) {
508            // Frozen legacy citation (D0.2): absent from every scan by
509            // construction now, and NEVER delete-eligible.
510            continue;
511        }
512        if baseline.prev_scan.contains(path) {
513            // Present a scan ago: first observed absence.
514            c.first_absence.insert(path.clone());
515        } else {
516            c.deletes.insert(path.clone());
517        }
518    }
519    c
520}