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}