Skip to main content

fdu_core/
snapshot.rs

1//! Persisting an index to disk and reading it back.
2//!
3//! # Status: format v5 is a bounded bootstrap format
4//!
5//! This module implements a flat, uncompressed writer and a bounded streaming reader.
6//! It exists so the cache *lifecycle* — semantic-scope invalidation, atomic replacement,
7//! complete-only persistence, resource limits, and corrupt-equals-empty — is nailed down
8//! before the optimized wire format is designed. The loader checks file size, trailer,
9//! and payload checksum before parsing, then checks the header, record count, and path
10//! lengths before allocating record data and rebuilds one entry at a time.
11//!
12//! The target format is different in every other respect: zstd-compressed blocks with an
13//! index block at the tail, so opening costs one small read and directory listings
14//! decompress on demand into a small LRU cache; item references encoded as
15//! `(block << k) | offset` and delta-encoded when they point inside the same block;
16//! sibling groups written contiguously so one directory listing costs one block
17//! decompression; front-coded names; and pre-computed roll-ups stored per directory so a
18//! query never re-aggregates. O(1) open with lazy materialization matters more than raw
19//! decode throughput, because it matches how a UI actually navigates: open now, expand
20//! later.
21//!
22//! Two things here are **not** placeholders and must survive the format change:
23//!
24//! - **A corrupt or unrecognized snapshot is treated as absent, never as an error and
25//!   never as data.** A cache that fails closed costs a rescan; a cache that fails open
26//!   silently lies.
27//! - **Replacement is atomic.** Write a temporary file, then rename over the target, so a
28//!   crash mid-write leaves the previous snapshot intact rather than a half-written one.
29
30use std::ffi::{OsStr, OsString};
31use std::fs::{self, OpenOptions};
32use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
33use std::path::{Component, Path, PathBuf};
34use std::sync::atomic::{AtomicU64, Ordering};
35
36use crate::engine_contract::{Attrs, EntryKind, Error, Observation, Op, Result, Source};
37use crate::index::{EntryId, Index, IndexHandle};
38use crate::stored_state::{
39    ControlTierIdentity, SNAPSHOT_IDENTITY_BYTES, Serves, SnapshotIdentity, serves_snapshot,
40};
41
42/// Result of loading a snapshot for one requested stored-state identity.
43#[derive(Debug)]
44#[allow(clippy::large_enum_variant)] // One transient load result; avoid boxing the returned index.
45pub enum LoadOutcome {
46    /// The snapshot supplied an index, either exactly or through a lawful projection.
47    Served {
48        /// The index in the requested scope.
49        index: Index,
50        /// The identity the snapshot declares, which is what a plan admits: a projected
51        /// index reports the requested identity, not this one.
52        stored: SnapshotIdentity,
53    },
54    /// The snapshot was valid, but its identity cannot answer this request.
55    Refused {
56        /// The validated identity that did not serve the request.
57        identity: SnapshotIdentity,
58        /// The validated snapshot root, used before explaining a scope mismatch.
59        root: PathBuf,
60    },
61    /// No valid snapshot was present.
62    Absent,
63}
64
65/// Leading magic. Distinguishes an fdu snapshot from any other file that lands here.
66const MAGIC: &[u8; 8] = b"FDUSNAP\x00";
67
68/// Trailing magic. Present only once the whole snapshot has been written, so a truncated
69/// file is detected on load rather than parsed into a plausible-looking partial tree.
70const TRAILER: &[u8; 8] = b"FDUEND\x00\x00";
71
72/// CRC-32C of every byte before the footer. This detects plausible payload corruption
73/// that remains structurally valid; it is an integrity check, not authentication.
74const CHECKSUM_BYTES: usize = std::mem::size_of::<u32>();
75
76/// Reversed Castagnoli polynomial used by CRC-32C.
77const CRC32C_POLYNOMIAL: u32 = 0x82f6_3b78;
78
79/// One table lookup per byte keeps integrity validation from dominating snapshot load.
80const CRC32C_TABLES: [[u32; 256]; 8] = make_crc32c_tables();
81
82/// On-disk format version. Bump on any layout change; old snapshots are then discarded
83/// rather than misread.
84///
85/// 4: the control section also carries both control limits, the budget and the line
86/// limit, and every refused control file, so a snapshot of an index that refused some
87/// reloads with the same coverage rather than claiming every rule applied.
88///
89/// 5: the header records the identity of each tier the snapshot holds, in the
90/// stored-state model's fixed-width encodings: the entry tier's scope, type rules, and
91/// reducer set, and the control tier's observation and limits. Observation and limits are
92/// no longer mixed into the scope as one hash, and the limits moved out of the control
93/// section, so a section is re-admitted under the header's limits and one they would not
94/// admit is refused. The header also records `writing_pass_started_at_ns`, the start of the
95/// pass that last wrote the image.
96///
97/// 6: the entry tier records ignored population and, when narrowed, the governing
98/// control policy. Narrowed scans cannot reuse broader metadata without projection.
99const FORMAT_VERSION: u32 = 6;
100
101/// Version of the rules that decide which bucket an entry's bytes are tallied under.
102///
103/// Separate from [`FORMAT_VERSION`] because the two answer different questions: the
104/// layout can be unchanged while the meaning of what was written moves. A snapshot stores
105/// the bucket an entry was assigned, not the name it was assigned from, so a rule change
106/// cannot be re-derived on load — the entries have to be discarded and re-walked.
107///
108/// 1: initial rules. 2: files with no extension are tallied under `(none)` instead of
109/// being left out of the extension roll-up entirely.
110const CLASSIFICATION_VERSION: u32 = 2;
111
112/// Version of the per-entry facts used to decide whether retained state is still valid.
113///
114/// Version 2 adds Windows change time, volume identity, and file index. A pre-v2 Windows
115/// snapshot stores zeroes in those fields and cannot safely serve cache-only as if those
116/// facts had been observed. This is mixed into the engine fingerprint on every platform
117/// so one build has one store identity.
118const VALIDITY_VERSION: u32 = 2;
119
120/// Snapshot path encoding used by Unix targets.
121#[cfg(unix)]
122const PATH_ENCODING_UNIX_BYTES: u8 = 1;
123
124/// Snapshot path encoding used by Windows targets.
125#[cfg(windows)]
126const PATH_ENCODING_WINDOWS_WIDE: u8 = 2;
127
128/// Portable UTF-8 fallback for targets outside Unix and Windows.
129#[cfg(not(any(unix, windows)))]
130const PATH_ENCODING_UTF8: u8 = 3;
131
132/// Marks the root's absent parent.
133const NO_PARENT: u32 = u32::MAX;
134
135/// Byte offset of `writing_pass_started_at_ns`: after the magic, the format version, the
136/// engine fingerprint, and the path encoding.
137///
138/// Fixed, so a save can read the stamp of the image already at its path without parsing
139/// the rest of it.
140const WRITING_PASS_STARTED_AT_OFFSET: usize = MAGIC.len() + 4 + 8 + 1;
141
142/// Byte offset of the tier identities, which follow the stamp.
143const IDENTITY_OFFSET: usize = WRITING_PASS_STARTED_AT_OFFSET + 8;
144
145/// Byte offset of the root path, which follows the tier identities.
146#[cfg(test)]
147const ROOT_OFFSET: usize = IDENTITY_OFFSET + SNAPSHOT_IDENTITY_BYTES;
148
149/// Smallest possible on-disk record: parent slot, kind, name length, and six 8-byte
150/// attribute fields, with a zero-length name. Used to sanity-check a declared entry
151/// count against the bytes actually present.
152const MIN_RECORD_BYTES: usize = 4 + 1 + 4 + 8 * 6;
153
154/// Binary size unit used by the snapshot resource limits.
155const GIBIBYTE: u64 = 1024 * 1024 * 1024;
156
157/// Largest snapshot image accepted by the bootstrap streaming reader.
158const MAX_SNAPSHOT_BYTES: u64 = 64 * GIBIBYTE;
159
160/// Process-local discriminator for exclusive sibling temporary files.
161static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0);
162
163/// Per-process entropy mixed into temporary file names.
164///
165/// Correctness never depended on this: `O_EXCL` is what guarantees one creator wins,
166/// and the counter above is what keeps two threads of this process from even trying the
167/// same name. Randomness closes two gaps the counter cannot.
168///
169/// A killed writer leaves its temporary behind and nothing reaps it. With a
170/// pid-and-counter name, a later process that recycles that pid restarts its counter at
171/// zero and collides with the corpse on every run — correct, because it retries, but it
172/// accumulates litter and in the pathological case walks the whole retry budget. And
173/// the names are otherwise entirely predictable, which matters because
174/// [`MAX_TEMP_CREATE_ATTEMPTS`] exists specifically to survive a hostile directory: an
175/// attacker who can guess the names can pre-create the whole budget and deny every save.
176///
177/// Seeded from `RandomState`, whose keys the standard library takes from the operating
178/// system, so this needs no dependency and no clock.
179static TEMP_FILE_ENTROPY: std::sync::LazyLock<u64> = std::sync::LazyLock::new(|| {
180    use std::hash::{BuildHasher, Hasher};
181    std::collections::hash_map::RandomState::new().build_hasher().finish()
182});
183
184/// Stale files can survive a killed writer. Try enough unique sequence values to step
185/// over them without turning an attacker-controlled directory into an unbounded loop.
186const MAX_TEMP_CREATE_ATTEMPTS: usize = 1024;
187
188/// How old an abandoned temporary must be before a later writer removes it.
189///
190/// Per-process entropy in the name means a corpse can never collide with a future
191/// writer — which also means no future writer will ever reuse or overwrite it. Unique
192/// names turn an occasional collision into permanent litter, and each corpse is a whole
193/// snapshot image, so something has to collect them.
194///
195/// A day is far longer than any real save and far shorter than "never". The threshold
196/// is what makes this safe without any liveness check: a temporary this old cannot
197/// belong to a writer that is still running, and pid-based liveness tests are both
198/// unportable and wrong under pid reuse.
199///
200/// Shared with the cache lifecycle, which reclaims the same corpses on request rather
201/// than waiting for the next writer, and must answer "old enough to be nobody's" the same
202/// way this does.
203pub(crate) const STALE_TEMP_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
204
205/// Largest encoded root or entry name accepted from a snapshot.
206const MAX_PATH_BYTES: u32 = 1024 * 1024;
207
208/// Upper bound on records accepted even when a sparse file could physically hold more.
209const MAX_SNAPSHOT_ENTRIES: u64 = 100_000_000;
210
211/// Binary size unit for the control-section ceiling.
212const MEBIBYTE: usize = 1024 * 1024;
213
214/// Largest control table, in retained charge and in total source bytes, a snapshot may
215/// carry.
216///
217/// A parser guard over untrusted lengths, deliberately independent of both control limits:
218/// they are a caller's runtime settings and may be unbounded, while this bounds what a
219/// corrupt or hostile file can make the loader allocate. [`save`] refuses a table above
220/// it, so a snapshot the loader would reject is never written as if it were usable.
221const SNAPSHOT_CONTROL_TABLE_CEILING: usize = 256 * MEBIBYTE;
222
223/// Encoded refusal reasons.
224const REFUSED_FOR_BUDGET: u8 = 1;
225const REFUSED_FOR_LINE_LIMIT: u8 = 2;
226
227/// A fingerprint of everything that would change how the engine interprets a tree.
228///
229/// When this does not match, the whole snapshot is discarded. That is the cheap,
230/// wholesale answer to "the rules changed, so every derived verdict in the cache might
231/// be wrong" — far simpler than trying to work out which entries a rule change affected.
232///
233/// Currently derived from the crate version, the format version, and the versions of the
234/// classification rules, of the per-entry validity facts, and of the fixed `.gitignore`
235/// semantics. When compiled type-recognition rules and reducer registrations arrive, their
236/// hashes belong here too.
237pub fn engine_fingerprint() -> u64 {
238    engine_fingerprint_under(crate::stored_state::IGNORE_RULES_VERSION)
239}
240
241/// [`engine_fingerprint`] as a build whose `.gitignore` semantics are
242/// `ignore_rules_version` computes it.
243///
244/// The rules version is the one input a snapshot's tier identities cannot tell apart for
245/// the default population, so a test forges a snapshot of another version through it.
246fn engine_fingerprint_under(ignore_rules_version: u64) -> u64 {
247    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
248    let mut mix = |bytes: &[u8]| {
249        for byte in bytes {
250            hash ^= u64::from(*byte);
251            hash = hash.wrapping_mul(0x1000_0000_01b3);
252        }
253    };
254    mix(env!("CARGO_PKG_VERSION").as_bytes());
255    mix(&FORMAT_VERSION.to_le_bytes());
256    mix(&CLASSIFICATION_VERSION.to_le_bytes());
257    mix(&VALIDITY_VERSION.to_le_bytes());
258    mix(&ignore_rules_version.to_le_bytes());
259    hash
260}
261
262/// Write `index` to `path`, replacing any existing snapshot atomically.
263pub fn save(index: &Index, path: &Path) -> Result<()> {
264    debug_assert!(!index.is_folded(), "a folded index omits files, so it is never persisted");
265    if !crate::stored_state::entries_writable(index) {
266        return Err(Error::Snapshot(
267            "refusing to persist an index that is stale, reconciling, or incomplete".into(),
268        ));
269    }
270    // The loader refuses a table whose limits its scope does not claim, so writing one
271    // would publish a snapshot that every later open discards.
272    index.require_control_limits_in_scope(index.control_table().limits())?;
273    let mut buf: Vec<u8> = Vec::new();
274    buf.extend_from_slice(MAGIC);
275    buf.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
276    buf.extend_from_slice(&engine_fingerprint().to_le_bytes());
277    buf.push(path_encoding());
278    debug_assert_eq!(buf.len(), WRITING_PASS_STARTED_AT_OFFSET);
279    buf.extend_from_slice(&index.writing_pass_started_at_ns().to_le_bytes());
280    // Every tier identity shares the prologue's engine fingerprint, which is this build's.
281    buf.extend_from_slice(&index.snapshot_identity().encode());
282
283    put_os_str(&mut buf, index.root_path().as_os_str())?;
284
285    // Pre-order, so a parent's record always precedes its children's and the loader can
286    // rebuild the tree in one forward pass with no fixups.
287    let mut records: Vec<(u32, EntryId)> = Vec::new();
288    let mut stack: Vec<(u32, EntryId)> = vec![(NO_PARENT, EntryId::ROOT)];
289    while let Some((parent_slot, id)) = stack.pop() {
290        let slot = u32::try_from(records.len())
291            .map_err(|_| Error::Snapshot("snapshot exceeds u32 entry capacity".into()))?;
292        records.push((parent_slot, id));
293        let children = index
294            .children_of(id)
295            .ok_or_else(|| Error::Snapshot("stale entry handle while saving".into()))?;
296        for (_, child) in children.rev() {
297            stack.push((slot, child));
298        }
299    }
300
301    let count = u64::try_from(records.len())
302        .map_err(|_| Error::Snapshot("snapshot entry count overflow".into()))?;
303    buf.extend_from_slice(&count.to_le_bytes());
304
305    for (parent_slot, id) in records {
306        buf.extend_from_slice(&parent_slot.to_le_bytes());
307        let kind = index
308            .kind_of(id)
309            .ok_or_else(|| Error::Snapshot("stale entry handle while saving".into()))?;
310        buf.push(kind as u8);
311        let name = index
312            .name_of(id)
313            .ok_or_else(|| Error::Snapshot("stale entry handle while saving".into()))?;
314        put_os_str(&mut buf, name)?;
315        let attrs = index
316            .attrs_of(id)
317            .ok_or_else(|| Error::Snapshot("stale entry handle while saving".into()))?;
318        buf.extend_from_slice(&attrs.size.to_le_bytes());
319        buf.extend_from_slice(&attrs.allocated.to_le_bytes());
320        buf.extend_from_slice(&attrs.mtime_ns.to_le_bytes());
321        buf.extend_from_slice(&attrs.ctime_ns.to_le_bytes());
322        buf.extend_from_slice(&attrs.inode.to_le_bytes());
323        buf.extend_from_slice(&attrs.dev.to_le_bytes());
324    }
325    put_controls(&mut buf, index.control_table())?;
326    publish(path, buf)
327}
328
329/// Seal a snapshot payload and write it to `path`, keeping an image already there that
330/// encodes the same facts.
331///
332/// Two passes over an unchanged tree encode identical images except for each pass's
333/// start, and rewriting for the stamp alone would give back the skip [`write_atomically`]
334/// exists for. A default run never reads its snapshot for a metadata query, so on that path
335/// the rewrite was the whole cost of having a cache: about 70 ms of a 375 ms run over 175k
336/// entries (exp-066), and a 14 MB write and `F_FULLFSYNC` on every run (exp-067). So an image at `path` that differs only in its stamp is kept, stamp
337/// included, and only its mtime moves. The kept stamp is the start of an earlier pass that
338/// wrote exactly these facts, which can understate how recently they were verified and
339/// never overstates it.
340fn publish(path: &Path, mut payload: Vec<u8>) -> Result<()> {
341    if keep_equivalent_image(path, &payload) {
342        return Ok(());
343    }
344    seal(&mut payload);
345    // Nothing at `path` is this image under any stamp, this pass's included, so comparing
346    // again before replacing it could only repeat the answer.
347    replace_atomically(path, &payload)
348}
349
350/// Keep the file at `path` when it is `payload` sealed under any pass's stamp, moving only
351/// its mtime, to the stamp in `payload`.
352///
353/// The mtime is cache-maintenance metadata, not filesystem-observation provenance: a copy
354/// or a touch must not change when the tree was observed. Moving it to the start of the pass
355/// that would have stamped the image lets the equivalent-image fast path retain its useful
356/// monotonic hint without changing the persisted observation stamp. It never moves backwards:
357/// a save of an index loaded under an older stamp leaves a later mtime in place.
358///
359/// One read through one handle, comparing every payload byte before computing the
360/// checksum, so a changed tree stops at its first difference and costs no checksum here,
361/// and an unchanged one costs the one checksum sealing it would have. The checksum is
362/// still verified before the file is kept: a file whose own checksum is wrong reads as
363/// absent, and keeping it would keep every later run cold.
364fn keep_equivalent_image(path: &Path, payload: &[u8]) -> bool {
365    const FOOTER_BYTES: usize = CHECKSUM_BYTES + TRAILER.len();
366    let Ok(mut file) = fs::File::open(path) else { return false };
367    let Ok(metadata) = file.metadata() else { return false };
368    let image_len = payload.len().checked_add(FOOTER_BYTES).and_then(|len| u64::try_from(len).ok());
369    if image_len != Some(metadata.len()) {
370        return false;
371    }
372    let (before_stamp, after_stamp) =
373        (&payload[..WRITING_PASS_STARTED_AT_OFFSET], &payload[IDENTITY_OFFSET..]);
374    let mut stamp = [0u8; 8];
375    if !reads_as(&mut file, before_stamp)
376        || file.read_exact(&mut stamp).is_err()
377        || !reads_as(&mut file, after_stamp)
378    {
379        return false;
380    }
381    let checksum =
382        ![before_stamp, &stamp[..], after_stamp].into_iter().fold(u32::MAX, crc32c_update);
383    let mut footer = [0u8; FOOTER_BYTES];
384    if file.read_exact(&mut footer).is_err()
385        || footer[..CHECKSUM_BYTES] != checksum.to_le_bytes()
386        || footer[CHECKSUM_BYTES..] != *TRAILER
387        // A file that grew under the comparison is not this image.
388        || !matches!(file.read(&mut [0u8; 1]), Ok(0))
389    {
390        return false;
391    }
392    let pass_started = payload
393        .get(WRITING_PASS_STARTED_AT_OFFSET..IDENTITY_OFFSET)
394        .and_then(|stamp| <[u8; 8]>::try_from(stamp).ok())
395        .map(i64::from_le_bytes)
396        .and_then(|nanos| u64::try_from(nanos).ok())
397        .and_then(|nanos| {
398            std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_nanos(nanos))
399        });
400    if let Some(pass_started) = pass_started {
401        if !matches!(metadata.modified(), Ok(modified) if modified >= pass_started) {
402            // Best effort, as for any kept image: a stale mtime understates freshness.
403            let _ = touch(path, pass_started);
404        }
405    }
406    true
407}
408
409/// Append the checksum of every byte so far, then the trailer.
410fn seal(payload: &mut Vec<u8>) {
411    let checksum = crc32c(payload);
412    payload.extend_from_slice(&checksum.to_le_bytes());
413    payload.extend_from_slice(TRAILER);
414}
415
416/// Capture and persist a coherent shared-index image without holding its lock during
417/// serialization or filesystem I/O.
418pub fn save_handle(index: &IndexHandle, path: &Path) -> Result<()> {
419    let snapshot = index.snapshot()?;
420    save(&snapshot, path)
421}
422
423/// Load a snapshot, or return `None` when there is nothing usable at `path`.
424///
425/// Returns `None` — not an error — for a missing file, a foreign file, a version or
426/// engine-fingerprint mismatch, and a truncated or corrupt file. Every one of those
427/// means the same thing to a caller: there is no warm cache, so scan.
428pub fn load(path: &Path) -> Result<Option<Index>> {
429    load_with_size_limit(path, MAX_SNAPSHOT_BYTES)
430}
431
432/// Load a snapshot under the registry that will classify and analyze its entries.
433///
434/// A snapshot made under different rules is a clean cache miss. The snapshot carries
435/// only their derived identity, so the caller must supply the matching registry rather
436/// than allowing the loader to attach unrelated default rules.
437pub fn load_with_types(
438    path: &Path,
439    types: std::sync::Arc<crate::classify::TypeRegistry>,
440) -> Result<Option<Index>> {
441    load_with_types_and_size_limit(path, MAX_SNAPSHOT_BYTES, types, None).map(|outcome| {
442        match outcome {
443            LoadOutcome::Served { index, .. } => Some(index),
444            LoadOutcome::Refused { .. } | LoadOutcome::Absent => None,
445        }
446    })
447}
448
449/// Load a snapshot that can serve `wanted`, projecting observed control state away when
450/// the entry tier is equal and the request does not observe controls.
451pub fn load_serving(
452    path: &Path,
453    types: std::sync::Arc<crate::classify::TypeRegistry>,
454    wanted: SnapshotIdentity,
455) -> Result<LoadOutcome> {
456    load_with_types_and_size_limit(path, MAX_SNAPSHOT_BYTES, types, Some(wanted))
457}
458
459fn load_with_size_limit(path: &Path, max_snapshot_bytes: u64) -> Result<Option<Index>> {
460    load_with_types_and_size_limit(
461        path,
462        max_snapshot_bytes,
463        crate::classify::TypeRegistry::compiled_shared(),
464        None,
465    )
466    .map(|outcome| match outcome {
467        LoadOutcome::Served { index, .. } => Some(index),
468        LoadOutcome::Refused { .. } | LoadOutcome::Absent => None,
469    })
470}
471
472fn load_with_types_and_size_limit(
473    path: &Path,
474    max_snapshot_bytes: u64,
475    types: std::sync::Arc<crate::classify::TypeRegistry>,
476    wanted: Option<SnapshotIdentity>,
477) -> Result<LoadOutcome> {
478    let mut file = match fs::File::open(path) {
479        Ok(file) => file,
480        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LoadOutcome::Absent),
481        Err(e) => return Err(Error::io(path, e)),
482    };
483    let file_len = file.metadata().map_err(|e| Error::io(path, e))?.len();
484    let footer_bytes = CHECKSUM_BYTES
485        .checked_add(TRAILER.len())
486        .and_then(|bytes| u64::try_from(bytes).ok())
487        .ok_or_else(|| Error::Snapshot("snapshot footer size overflow".into()))?;
488    if file_len > max_snapshot_bytes || file_len < footer_bytes {
489        return Ok(LoadOutcome::Absent);
490    }
491
492    let footer_offset = i64::try_from(footer_bytes)
493        .map_err(|_| Error::Snapshot("snapshot footer size overflow".into()))?;
494    file.seek(SeekFrom::End(-footer_offset)).map_err(|e| Error::io(path, e))?;
495    let expected_checksum = match read_footer_checksum(&mut file) {
496        Ok(checksum) => checksum,
497        Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
498            return Ok(LoadOutcome::Absent);
499        }
500        Err(error) => return Err(Error::io(path, error)),
501    };
502    let mut trailer = [0u8; TRAILER.len()];
503    match file.read_exact(&mut trailer) {
504        Ok(()) if &trailer == TRAILER => {}
505        Ok(()) => return Ok(LoadOutcome::Absent),
506        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(LoadOutcome::Absent),
507        Err(e) => return Err(Error::io(path, e)),
508    }
509    let payload_len = file_len
510        .checked_sub(footer_bytes)
511        .ok_or_else(|| Error::Snapshot("snapshot length underflow".into()))?;
512    file.seek(SeekFrom::Start(0)).map_err(|e| Error::io(path, e))?;
513
514    // One pass, not two: the checksum accumulates as the parser consumes, instead of
515    // a separate full read of the image before parsing begins. The verdict still
516    // gates the data — the parsed index is returned only after the digest over the
517    // complete payload matches, so nothing is ever served from bytes that failed
518    // their checksum. What changes is the failure mode for structurally-valid
519    // corruption: the parser may do work before the mismatch is known, and the
520    // result is then discarded. Structural corruption is caught by the parser's own
521    // bounds and consistency checks exactly as before, fail-closed either way.
522    let mut reader = Crc32cReader::new(BufReader::new(file.take(payload_len)));
523    let outcome = parse_stream(&mut reader, payload_len, types, wanted);
524    match outcome {
525        Ok(outcome) => {
526            // A successful parse consumed every payload byte (the trailing-byte check
527            // proves it), so the running digest covers the whole image.
528            if reader.finish() == expected_checksum { Ok(outcome) } else { Ok(LoadOutcome::Absent) }
529        }
530        Err(ParseError::Invalid) => Ok(LoadOutcome::Absent),
531        Err(ParseError::Io(source)) => Err(Error::io(path, source)),
532    }
533}
534
535/// A reader that folds CRC-32C over every byte the caller consumes.
536struct Crc32cReader<R> {
537    inner: R,
538    state: u32,
539}
540
541impl<R: Read> Crc32cReader<R> {
542    fn new(inner: R) -> Self {
543        Self { inner, state: u32::MAX }
544    }
545
546    fn finish(&self) -> u32 {
547        !self.state
548    }
549}
550
551impl<R: Read> Read for Crc32cReader<R> {
552    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
553        let read = self.inner.read(buf)?;
554        self.state = crc32c_update(self.state, &buf[..read]);
555        Ok(read)
556    }
557}
558
559fn read_footer_checksum(reader: &mut impl Read) -> std::io::Result<u32> {
560    let mut bytes = [0u8; CHECKSUM_BYTES];
561    reader.read_exact(&mut bytes)?;
562    Ok(u32::from_le_bytes(bytes))
563}
564
565pub(crate) fn crc32c(bytes: &[u8]) -> u32 {
566    !crc32c_update(u32::MAX, bytes)
567}
568
569/// Slicing-by-8: fold eight input bytes per step through eight derived tables
570/// instead of one byte through one. Table 0 is the classic byte table, so the
571/// remainder loop and the 8-byte path share one source of truth; the digest is
572/// bit-identical to the byte-at-a-time form, which a test asserts over uneven
573/// lengths alongside the standard check value.
574fn crc32c_update(mut state: u32, bytes: &[u8]) -> u32 {
575    let mut chunks = bytes.chunks_exact(8);
576    for chunk in &mut chunks {
577        let low = (state ^ u32::from_le_bytes(chunk[..4].try_into().expect("chunk holds 8 bytes")))
578            .to_le_bytes();
579        let high =
580            u32::from_le_bytes(chunk[4..].try_into().expect("chunk holds 8 bytes")).to_le_bytes();
581        state = CRC32C_TABLES[7][usize::from(low[0])]
582            ^ CRC32C_TABLES[6][usize::from(low[1])]
583            ^ CRC32C_TABLES[5][usize::from(low[2])]
584            ^ CRC32C_TABLES[4][usize::from(low[3])]
585            ^ CRC32C_TABLES[3][usize::from(high[0])]
586            ^ CRC32C_TABLES[2][usize::from(high[1])]
587            ^ CRC32C_TABLES[1][usize::from(high[2])]
588            ^ CRC32C_TABLES[0][usize::from(high[3])];
589    }
590    for byte in chunks.remainder() {
591        let index = usize::from(state.to_le_bytes()[0] ^ *byte);
592        state = CRC32C_TABLES[0][index] ^ (state >> 8);
593    }
594    state
595}
596
597const fn make_crc32c_tables() -> [[u32; 256]; 8] {
598    let mut tables = [[0u32; 256]; 8];
599    let mut index = 0usize;
600    let mut value = 0u32;
601    while index < 256 {
602        let mut crc = value;
603        let mut bit = 0;
604        while bit < u8::BITS {
605            crc = (crc >> 1) ^ (CRC32C_POLYNOMIAL & 0u32.wrapping_sub(crc & 1));
606            bit += 1;
607        }
608        tables[0][index] = crc;
609        index += 1;
610        value += 1;
611    }
612    // tables[k] advances a byte's contribution k further positions: one more
613    // byte-table step applied to the previous table's value.
614    let mut table = 1usize;
615    while table < tables.len() {
616        let mut index = 0usize;
617        while index < 256 {
618            let previous = tables[table - 1][index];
619            tables[table][index] = tables[0][(previous & 0xFF) as usize] ^ (previous >> 8);
620            index += 1;
621        }
622        table += 1;
623    }
624    tables
625}
626
627/// Read only a snapshot's header, without materializing its index.
628///
629/// Returns `None` for anything this build cannot serve — absent, truncated, foreign,
630/// a different format version, or a mismatched engine fingerprint. Corrupt equals
631/// absent here exactly as it does on the load path: a caller asking what is in the cache
632/// must not be stopped by one unreadable file.
633pub fn read_header(path: &Path) -> Result<Option<crate::cache::SnapshotInfo>> {
634    Ok(match identify(path)? {
635        Some(Identity::Current(info)) => Some(info),
636        Some(Identity::Stale(_) | Identity::Foreign) | None => None,
637    })
638}
639
640/// What a file's leading bytes and trailer say it is.
641#[derive(Debug)]
642pub(crate) enum Identity {
643    /// A snapshot this build reads.
644    Current(crate::cache::SnapshotInfo),
645    /// It begins with the snapshot magic, so fdu wrote it, but this build cannot serve it.
646    Stale(crate::cache::StaleReason),
647    /// It does not begin with the snapshot magic.
648    Foreign,
649}
650
651/// Identify the file at `path` by its contents, or return `None` when nothing is there.
652///
653/// The magic alone decides whether a file is fdu's; the rest of the prologue decides
654/// whether this build can serve it. Every format fdu has written puts the version and the
655/// engine fingerprint at the same offsets after the magic, so a snapshot from another
656/// release is recognised as stale rather than mistaken for a foreign file. That is what
657/// lets the cache lifecycle reclaim the snapshots an upgrade leaves behind.
658///
659/// Opening follows a symbolic link at `path`, so a caller that must not follow one checks
660/// the path's own metadata first.
661pub(crate) fn identify(path: &Path) -> Result<Option<Identity>> {
662    let file = match fs::File::open(path) {
663        Ok(file) => file,
664        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
665        Err(error) => return Err(Error::io(path, error)),
666    };
667    let trailer_intact = has_intact_trailer(&file).map_err(|error| Error::io(path, error))?;
668    // The trailer check left the cursor at the end; the header lives at the start.
669    let mut file = file;
670    file.seek(SeekFrom::Start(0)).map_err(|error| Error::io(path, error))?;
671    identify_prologue(&mut BufReader::new(file), trailer_intact)
672        .map(Some)
673        .map_err(|error| Error::io(path, error))
674}
675
676/// Classify a file from its prologue. Only an I/O failure is an error; every malformed
677/// byte is an answer.
678fn identify_prologue(reader: &mut impl Read, trailer_intact: bool) -> io::Result<Identity> {
679    use crate::cache::StaleReason;
680
681    match read_array::<_, 8>(reader) {
682        Ok(magic) if magic == *MAGIC => {}
683        Ok(_) | Err(ParseError::Invalid) => return Ok(Identity::Foreign),
684        Err(ParseError::Io(error)) => return Err(error),
685    }
686    let Some(version) = invalid_as_none(read_u32(reader))? else {
687        return Ok(Identity::Stale(StaleReason::Unreadable));
688    };
689    match version.cmp(&FORMAT_VERSION) {
690        std::cmp::Ordering::Less => {
691            return Ok(Identity::Stale(StaleReason::OlderFormat { version }));
692        }
693        std::cmp::Ordering::Greater => {
694            return Ok(Identity::Stale(StaleReason::NewerFormat { version }));
695        }
696        std::cmp::Ordering::Equal => {}
697    }
698    let engine = match invalid_as_none(read_u64(reader))? {
699        Some(fingerprint) if fingerprint == engine_fingerprint() => fingerprint,
700        Some(_) => return Ok(Identity::Stale(StaleReason::OtherEngine)),
701        None => return Ok(Identity::Stale(StaleReason::Unreadable)),
702    };
703    if !trailer_intact {
704        // Truncation removes the tail and leaves the prologue readable, so a header-only
705        // check would call a half-written file current.
706        return Ok(Identity::Stale(StaleReason::Unreadable));
707    }
708    Ok(match invalid_as_none(parse_header_fields(reader, engine))? {
709        Some(header) => Identity::Current(crate::cache::SnapshotInfo {
710            root: header.root,
711            identity: header.identity,
712            entries: header.entries,
713        }),
714        None => Identity::Stale(StaleReason::Unreadable),
715    })
716}
717
718/// Separate a malformed value, which is an answer, from an I/O failure, which is not.
719fn invalid_as_none<T>(result: ParseResult<T>) -> io::Result<Option<T>> {
720    match result {
721        Ok(value) => Ok(Some(value)),
722        Err(ParseError::Invalid) => Ok(None),
723        Err(ParseError::Io(error)) => Err(error),
724    }
725}
726
727/// Whether a file ends with the snapshot trailer.
728///
729/// Cheap enough for a status listing — one seek and eight bytes — and it is exactly what
730/// a truncated write destroys.
731fn has_intact_trailer(file: &fs::File) -> io::Result<bool> {
732    let footer_bytes = CHECKSUM_BYTES + TRAILER.len();
733    let file_len = file.metadata()?.len();
734    if file_len < u64::try_from(footer_bytes).unwrap_or(u64::MAX) {
735        return Ok(false);
736    }
737
738    let mut handle = file;
739    handle.seek(SeekFrom::End(-(i64::try_from(TRAILER.len()).unwrap_or(0))))?;
740    let mut trailer = [0u8; TRAILER.len()];
741    match handle.read_exact(&mut trailer) {
742        Ok(()) => Ok(&trailer == TRAILER),
743        Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(false),
744        Err(error) => Err(error),
745    }
746}
747
748/// The header fields after a snapshot's prologue.
749struct Header {
750    /// The start of the pass that last wrote the image.
751    ///
752    /// A later pass that verifies the same facts keeps the image and this stamp rather
753    /// than rewriting for the stamp alone, so the value is a lower bound on when the facts
754    /// were last verified: it can predate many verifying passes and never postdates the one
755    /// that wrote them. Until P1.4.4 stamps completed passes, reconciliation does not
756    /// advance it either.
757    writing_pass_started_at_ns: i64,
758    /// The identity of every tier the snapshot holds.
759    identity: SnapshotIdentity,
760    /// The absolute root the snapshot describes.
761    root: PathBuf,
762    /// How many entry records follow.
763    entries: u64,
764}
765
766/// Parse the header fields after the magic, format version, and `engine` fingerprint.
767fn parse_header_fields(reader: &mut impl Read, engine: u64) -> ParseResult<Header> {
768    if read_u8(reader)? != path_encoding() {
769        return Err(ParseError::Invalid);
770    }
771    let writing_pass_started_at_ns = read_i64(reader)?;
772    let identity =
773        SnapshotIdentity::decode(engine, &read_array::<_, SNAPSHOT_IDENTITY_BYTES>(reader)?)
774            .ok_or(ParseError::Invalid)?;
775    let root = PathBuf::from(read_os_string(reader)?);
776    let entries = read_u64(reader)?;
777    if entries == 0 || entries > MAX_SNAPSHOT_ENTRIES {
778        return Err(ParseError::Invalid);
779    }
780    Ok(Header { writing_pass_started_at_ns, identity, root, entries })
781}
782
783/// Parse a bounded payload. Records are applied one at a time so bootstrap paths are not
784/// retained in a second full-tree allocation.
785fn parse_stream(
786    reader: &mut impl Read,
787    payload_len: u64,
788    types: std::sync::Arc<crate::classify::TypeRegistry>,
789    wanted: Option<SnapshotIdentity>,
790) -> ParseResult<LoadOutcome> {
791    if read_array::<_, 8>(reader)? != *MAGIC {
792        return Err(ParseError::Invalid);
793    }
794    let engine = engine_fingerprint();
795    if read_u32(reader)? != FORMAT_VERSION || read_u64(reader)? != engine {
796        return Err(ParseError::Invalid);
797    }
798    let Header { writing_pass_started_at_ns, identity, root: root_path, entries: count } =
799        parse_header_fields(reader, engine)?;
800    let serves = wanted.map_or(Serves::Exact, |wanted| serves_snapshot(identity, wanted));
801    let mut scope = match (serves, wanted) {
802        (Serves::ProjectControlsOff, Some(wanted)) => wanted.scan_scope(),
803        _ => identity.scan_scope(),
804    };
805    if scope.type_rules_fingerprint != types.fingerprint() {
806        if serves != Serves::Refuse {
807            return Err(ParseError::Invalid);
808        }
809        // A foreign registry cannot be attached to a served index. For a refusal,
810        // reconstruct only to validate every record and the checksum, then discard it.
811        // The stored identity below remains unchanged for the caller's diagnostic.
812        //
813        // That costs a full index build for a diagnostic: every record is decoded and
814        // inserted so the checksum can be verified over the bytes it covers, and the
815        // result is dropped. It is paid only under a type-rules mismatch, which a
816        // cache-only request reports and every other policy answers by scanning, so it
817        // buys a truthful refusal (valid but foreign, rather than absent) at the price of
818        // one load on a path that has no answer anyway.
819        scope.type_rules_fingerprint = types.fingerprint();
820    }
821    let minimum_body = count
822        .checked_mul(u64::try_from(MIN_RECORD_BYTES).map_err(|_| ParseError::Invalid)?)
823        .ok_or(ParseError::Invalid)?;
824    if minimum_body > payload_len {
825        return Err(ParseError::Invalid);
826    }
827
828    let mut index = Index::new_with_scope_and_types(&root_path, scope, types);
829    // The facts below were verified by the pass that wrote them, not by this load.
830    index.set_writing_pass_started_at_ns(writing_pass_started_at_ns);
831    // Everything this loader inserts describes the tree as the snapshot found it, not
832    // as this process has seen it. Stamping the entries `Cached` is what lets a
833    // consumer paint them immediately and label them honestly; without it a loaded
834    // index claims to be fresh when nothing has been checked since the file was read.
835    index.set_applying_source(Source::Cached, writing_pass_started_at_ns);
836    // The record count is validated against the bytes actually present above, so it is
837    // safe to size from: reserving here removes the geometric regrowth of a 450k-element
838    // vector without letting a corrupt count drive the allocation.
839    let mut ids: Vec<EntryId> = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
840    // Roll-ups are u64 totals and every record is merged into them as it lands, so an
841    // image whose file sizes sum past u64 is corrupt however sound its checksum: it is
842    // refused before the record that would carry a total out of range is inserted, and
843    // the index built so far is dropped with the error (fdu-sqyk).
844    let mut bytes_total = 0_u64;
845    let mut allocated_total = 0_u64;
846    for slot in 0..count {
847        let parent_slot = read_u32(reader)?;
848        let kind = EntryKind::from_u8(read_u8(reader)?).ok_or(ParseError::Invalid)?;
849        let name = read_os_string(reader)?;
850        let attrs = Attrs {
851            size: read_u64(reader)?,
852            allocated: read_u64(reader)?,
853            mtime_ns: read_i64(reader)?,
854            ctime_ns: read_i64(reader)?,
855            inode: read_u64(reader)?,
856            dev: read_u64(reader)?,
857        };
858
859        if parent_slot == NO_PARENT {
860            if slot != 0 || kind != EntryKind::Dir || !name.is_empty() {
861                return Err(ParseError::Invalid);
862            }
863            index
864                .apply_baseline(&Observation::new(vec![Op::Upsert {
865                    path: PathBuf::new(),
866                    kind,
867                    attrs,
868                }]))
869                .map_err(|_| ParseError::Invalid)?;
870            ids.push(EntryId::ROOT);
871            continue;
872        }
873
874        let parent = *ids
875            .get(usize::try_from(parent_slot).map_err(|_| ParseError::Invalid)?)
876            .ok_or(ParseError::Invalid)?;
877        if !is_snapshot_name(&name) {
878            return Err(ParseError::Invalid);
879        }
880        if kind == EntryKind::File {
881            bytes_total = bytes_total.checked_add(attrs.size).ok_or(ParseError::Invalid)?;
882            allocated_total =
883                allocated_total.checked_add(attrs.allocated).ok_or(ParseError::Invalid)?;
884        }
885        // The parent's id is already in hand, so the record is inserted straight beneath
886        // it. Resolving a path to rediscover that parent, and then searching the parent's
887        // children to rediscover the id just created, were both work the format had
888        // already answered. A snapshot naming the same path twice, or parenting an entry
889        // to a non-directory, is corrupt, and `insert_loaded_child` fails closed on both.
890        let id = index.insert_loaded_child(parent, name, kind, attrs).ok_or(ParseError::Invalid)?;
891        ids.push(id);
892    }
893
894    let controls = read_controls(reader, identity.controls)?;
895    if serves == Serves::Exact {
896        index.install_controls(controls).map_err(|_| ParseError::Invalid)?;
897    }
898
899    let mut extra = [0u8; 1];
900    if reader.read(&mut extra).map_err(ParseError::Io)? != 0 {
901        return Err(ParseError::Invalid);
902    }
903    // Once, rather than once per record: the loaded tree is the process baseline, and
904    // nothing it inserted was journalled.
905    index.establish_baseline();
906    // Anything applied after the load is this process checking what the snapshot
907    // claimed, which is a revalidation rather than a first sighting.
908    index.set_applying_source(Source::Revalidated, 0);
909    // The image on disk is this index, so nothing is owed until a pass mutates it.
910    index.set_persistence_owed(false);
911    Ok(if serves == Serves::Refuse {
912        LoadOutcome::Refused { identity, root: root_path }
913    } else {
914        LoadOutcome::Served { index, stored: identity }
915    })
916}
917
918#[derive(Debug)]
919enum ParseError {
920    Invalid,
921    Io(std::io::Error),
922}
923
924type ParseResult<T> = std::result::Result<T, ParseError>;
925
926fn read_array<R: Read, const N: usize>(reader: &mut R) -> ParseResult<[u8; N]> {
927    let mut bytes = [0u8; N];
928    match reader.read_exact(&mut bytes) {
929        Ok(()) => Ok(bytes),
930        Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Err(ParseError::Invalid),
931        Err(error) => Err(ParseError::Io(error)),
932    }
933}
934
935fn read_u8(reader: &mut impl Read) -> ParseResult<u8> {
936    Ok(read_array::<_, 1>(reader)?[0])
937}
938
939fn read_u32(reader: &mut impl Read) -> ParseResult<u32> {
940    Ok(u32::from_le_bytes(read_array(reader)?))
941}
942
943fn read_u64(reader: &mut impl Read) -> ParseResult<u64> {
944    Ok(u64::from_le_bytes(read_array(reader)?))
945}
946
947fn read_i64(reader: &mut impl Read) -> ParseResult<i64> {
948    Ok(i64::from_le_bytes(read_array(reader)?))
949}
950
951fn read_bytes(reader: &mut impl Read) -> ParseResult<Vec<u8>> {
952    let len = read_u32(reader)?;
953    if len > MAX_PATH_BYTES {
954        return Err(ParseError::Invalid);
955    }
956    let mut bytes = vec![0u8; usize::try_from(len).map_err(|_| ParseError::Invalid)?];
957    match reader.read_exact(&mut bytes) {
958        Ok(()) => Ok(bytes),
959        Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Err(ParseError::Invalid),
960        Err(error) => Err(ParseError::Io(error)),
961    }
962}
963
964/// Read the control section, retained sources then refusals, for the control tier the
965/// header records.
966///
967/// Every source is admitted again under the header's limits. The charge does not depend on
968/// admission order, so a table a scan retained always fits; one that does not, a repeated
969/// path, a refusal naming a retained or repeated path, a refusal by a limit the header
970/// records as unbounded, or any source or refusal under a tier that observed nothing, was
971/// not written by [`save`].
972fn read_controls(
973    reader: &mut impl Read,
974    tier: ControlTierIdentity,
975) -> ParseResult<crate::control::ControlTable> {
976    let limits = match tier {
977        ControlTierIdentity::Observed { limits } => limits,
978        // Limits decide nothing when nothing was observed, and the section must be empty.
979        // So a loaded blind table holds the default limits where the scanning index kept
980        // the request's; no reader consults a blind table's limits, and a cache status or
981        // projection that reports them must not show that as a difference.
982        ControlTierIdentity::NotObserved => crate::control::ControlLimits::default(),
983    };
984    let control_count = read_u32(reader)?;
985    if control_count != 0 && !tier.is_observed() {
986        return Err(ParseError::Invalid);
987    }
988    if usize::try_from(control_count)
989        .map_err(|_| ParseError::Invalid)?
990        .saturating_mul(crate::control::CONTROL_SOURCE_OVERHEAD)
991        > SNAPSHOT_CONTROL_TABLE_CEILING
992    {
993        return Err(ParseError::Invalid);
994    }
995    let mut sources = Vec::new();
996    let mut source_bytes = 0_usize;
997    for _ in 0..control_count {
998        let path = PathBuf::from(read_os_string(reader)?);
999        let source = read_control_bytes(reader)?;
1000        source_bytes = source_bytes.saturating_add(source.len());
1001        if source_bytes > SNAPSHOT_CONTROL_TABLE_CEILING {
1002            return Err(ParseError::Invalid);
1003        }
1004        sources.push((path, source));
1005    }
1006    let mut controls = crate::control::ControlTable::with_limits(limits);
1007    for (path, source) in sources {
1008        let admission = controls.upsert(&path, source).map_err(|_| ParseError::Invalid)?;
1009        if admission != (crate::control::ControlAdmission::Retained { changed: true }) {
1010            return Err(ParseError::Invalid);
1011        }
1012    }
1013    if controls.retained_cost() > SNAPSHOT_CONTROL_TABLE_CEILING {
1014        return Err(ParseError::Invalid);
1015    }
1016    let refused = read_u32(reader)?;
1017    if refused != 0 && !tier.is_observed() {
1018        return Err(ParseError::Invalid);
1019    }
1020    for _ in 0..refused {
1021        let path = PathBuf::from(read_os_string(reader)?);
1022        let reason = match read_u8(reader)? {
1023            REFUSED_FOR_BUDGET => crate::control::ControlRefusalReason::Budget,
1024            REFUSED_FOR_LINE_LIMIT => crate::control::ControlRefusalReason::LineLimit,
1025            _ => return Err(ParseError::Invalid),
1026        };
1027        // An unbounded limit refuses nothing, so no save records a refusal by one.
1028        if !crate::control::is_control_file(&path)
1029            || controls.contains(&path)
1030            || limits.limit_for(reason).is_none()
1031        {
1032            return Err(ParseError::Invalid);
1033        }
1034        controls.record_refusal(&path, reason).map_err(|_| ParseError::Invalid)?;
1035    }
1036    Ok(controls)
1037}
1038
1039fn read_control_bytes(reader: &mut impl Read) -> ParseResult<Vec<u8>> {
1040    let len = read_u32(reader)?;
1041    if usize::try_from(len).map_err(|_| ParseError::Invalid)? > SNAPSHOT_CONTROL_TABLE_CEILING {
1042        return Err(ParseError::Invalid);
1043    }
1044    let mut bytes = vec![0u8; usize::try_from(len).map_err(|_| ParseError::Invalid)?];
1045    match reader.read_exact(&mut bytes) {
1046        Ok(()) => Ok(bytes),
1047        Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Err(ParseError::Invalid),
1048        Err(error) => Err(ParseError::Io(error)),
1049    }
1050}
1051
1052#[cfg(unix)]
1053fn read_os_string(reader: &mut impl Read) -> ParseResult<OsString> {
1054    Ok(os_string_from_bytes(&read_bytes(reader)?))
1055}
1056
1057#[cfg(not(unix))]
1058fn read_os_string(reader: &mut impl Read) -> ParseResult<OsString> {
1059    os_string_from_bytes(&read_bytes(reader)?).ok_or(ParseError::Invalid)
1060}
1061
1062fn put_bytes(buf: &mut Vec<u8>, bytes: &[u8]) -> Result<()> {
1063    let len = u32::try_from(bytes.len())
1064        .map_err(|_| Error::Snapshot("string too long for snapshot".into()))?;
1065    if len > MAX_PATH_BYTES {
1066        return Err(Error::Snapshot("path exceeds snapshot limit".into()));
1067    }
1068    buf.extend_from_slice(&len.to_le_bytes());
1069    buf.extend_from_slice(bytes);
1070    Ok(())
1071}
1072
1073/// Write the control section [`read_controls`] reads: sources, then refusals. The limits
1074/// they were admitted under are the header's control tier identity.
1075fn put_controls(buf: &mut Vec<u8>, controls: &crate::control::ControlTable) -> Result<()> {
1076    if controls.retained_cost() > SNAPSHOT_CONTROL_TABLE_CEILING
1077        || controls.source_bytes() > SNAPSHOT_CONTROL_TABLE_CEILING
1078    {
1079        return Err(Error::Snapshot(format!(
1080            "the control table retains {} bytes of charge, above the {} bytes a snapshot can \
1081             carry; set a control budget below it, or open without a cache",
1082            controls.retained_cost(),
1083            SNAPSHOT_CONTROL_TABLE_CEILING
1084        )));
1085    }
1086    let sources: Vec<_> = controls.sources().collect();
1087    let control_count = u32::try_from(sources.len())
1088        .map_err(|_| Error::Snapshot("control table exceeds u32 capacity".into()))?;
1089    buf.extend_from_slice(&control_count.to_le_bytes());
1090    for (path, source) in sources {
1091        put_os_str(buf, path.as_os_str())?;
1092        let len = u32::try_from(source.len())
1093            .map_err(|_| Error::Snapshot("control source exceeds u32 capacity".into()))?;
1094        buf.extend_from_slice(&len.to_le_bytes());
1095        buf.extend_from_slice(source);
1096    }
1097    let refused = u32::try_from(controls.refused_len())
1098        .map_err(|_| Error::Snapshot("refused controls exceed u32 capacity".into()))?;
1099    buf.extend_from_slice(&refused.to_le_bytes());
1100    for refusal in controls.refusals() {
1101        put_os_str(buf, refusal.path.as_os_str())?;
1102        buf.push(match refusal.reason {
1103            crate::control::ControlRefusalReason::Budget => REFUSED_FOR_BUDGET,
1104            crate::control::ControlRefusalReason::LineLimit => REFUSED_FOR_LINE_LIMIT,
1105        });
1106    }
1107    Ok(())
1108}
1109
1110/// A snapshot entry name is one filesystem component, spelled exactly as stored.
1111///
1112/// `Path::components` treats `a` and `a/` as the same `Normal("a")`. The child map
1113/// distinguishes the raw names, but a `PathBuf`-keyed restore map merges them and
1114/// shrinks the cache-only completeness denominator. The raw name must equal that
1115/// single normal component so a checksummed alias cannot load.
1116fn is_snapshot_name(name: &OsStr) -> bool {
1117    let mut components = Path::new(name).components();
1118    let Some(Component::Normal(component)) = components.next() else { return false };
1119    components.next().is_none() && component == name
1120}
1121
1122#[cfg(unix)]
1123pub(crate) fn path_encoding() -> u8 {
1124    PATH_ENCODING_UNIX_BYTES
1125}
1126
1127#[cfg(windows)]
1128pub(crate) fn path_encoding() -> u8 {
1129    PATH_ENCODING_WINDOWS_WIDE
1130}
1131
1132#[cfg(not(any(unix, windows)))]
1133pub(crate) fn path_encoding() -> u8 {
1134    PATH_ENCODING_UTF8
1135}
1136
1137#[cfg(unix)]
1138pub(crate) fn put_os_str(buf: &mut Vec<u8>, value: &OsStr) -> Result<()> {
1139    use std::os::unix::ffi::OsStrExt;
1140    put_bytes(buf, value.as_bytes())
1141}
1142
1143#[cfg(windows)]
1144pub(crate) fn put_os_str(buf: &mut Vec<u8>, value: &OsStr) -> Result<()> {
1145    use std::os::windows::ffi::OsStrExt;
1146    let mut bytes = Vec::new();
1147    for unit in value.encode_wide() {
1148        bytes.extend_from_slice(&unit.to_le_bytes());
1149    }
1150    put_bytes(buf, &bytes)
1151}
1152
1153#[cfg(not(any(unix, windows)))]
1154pub(crate) fn put_os_str(buf: &mut Vec<u8>, value: &OsStr) -> Result<()> {
1155    let text = value
1156        .to_str()
1157        .ok_or_else(|| Error::Snapshot("path is not valid UTF-8 on this platform".into()))?;
1158    put_bytes(buf, text.as_bytes())
1159}
1160
1161#[cfg(unix)]
1162fn os_string_from_bytes(bytes: &[u8]) -> OsString {
1163    use std::os::unix::ffi::OsStringExt;
1164    OsString::from_vec(bytes.to_vec())
1165}
1166
1167#[cfg(windows)]
1168fn os_string_from_bytes(bytes: &[u8]) -> Option<OsString> {
1169    use std::os::windows::ffi::OsStringExt;
1170    // Stable since 1.87, against an MSRV of 1.85 — see the note in content_cache.rs.
1171    if bytes.len() % std::mem::size_of::<u16>() != 0 {
1172        return None;
1173    }
1174    let units: Vec<u16> = bytes
1175        .chunks_exact(std::mem::size_of::<u16>())
1176        .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
1177        .collect();
1178    Some(OsString::from_wide(&units))
1179}
1180
1181#[cfg(not(any(unix, windows)))]
1182fn os_string_from_bytes(bytes: &[u8]) -> Option<OsString> {
1183    Some(OsString::from(String::from_utf8(bytes.to_vec()).ok()?))
1184}
1185
1186/// Write to a sibling temporary file, then rename over the target.
1187///
1188/// Rename is atomic within a filesystem, so a reader either sees the whole old snapshot
1189/// or the whole new one. The temporary must be a sibling for that to hold — a rename
1190/// across filesystems is a copy, and copies are not atomic.
1191pub(crate) fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
1192    // Serialization is deterministic, so unchanged input encodes to the bytes already on
1193    // disk, and replacing them is a full write, an `F_FULLFSYNC`, and a rename that leave
1194    // the file exactly as it was. Comparing against the page-cached file costs a few
1195    // milliseconds and cannot go stale, because the bytes are the same bytes. The metadata
1196    // snapshot, whose images also differ in their pass-start stamp, compares in `publish`
1197    // instead, which records what the skip is worth (exp-066, exp-067).
1198    if keep_identical(path, bytes) {
1199        return Ok(());
1200    }
1201    replace_atomically(path, bytes)
1202}
1203
1204/// [`write_atomically`] without first comparing against the file at `path`, for a caller
1205/// that has already compared.
1206fn replace_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
1207    let parent = parent_dir(path);
1208    fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
1209    let (tmp, mut file) = create_temp_file(path, parent)?;
1210    let write_then_sync = file.write_all(bytes).and_then(|()| file.sync_all());
1211    if let Err(e) = write_then_sync {
1212        let _ = fs::remove_file(&tmp);
1213        return Err(Error::io(&tmp, e));
1214    }
1215    drop(file);
1216
1217    if let Err(e) = fs::rename(&tmp, path) {
1218        let _ = fs::remove_file(&tmp);
1219        return Err(Error::io(path, e));
1220    }
1221    reap_stale_temporaries(parent, path, STALE_TEMP_AGE);
1222    Ok(())
1223}
1224
1225/// Leave `path` in place when it already holds exactly `bytes`, moving only its mtime to
1226/// now.
1227///
1228/// The bytes were just produced again, so now is when they were last known to be current,
1229/// and moving the mtime says so without writing the payload. Best effort: a stale mtime
1230/// understates that, which fails safe.
1231fn keep_identical(path: &Path, bytes: &[u8]) -> bool {
1232    if !same_bytes_on_disk(path, bytes) {
1233        return false;
1234    }
1235    let _ = touch(path, std::time::SystemTime::now());
1236    true
1237}
1238
1239/// Whether `path` already holds exactly `bytes`.
1240///
1241/// Streamed in 1 MiB pieces rather than read whole, so deciding not to write a 14 MB
1242/// snapshot does not cost a second 14 MB allocation beside the one being compared. Any
1243/// error -- missing file, short read, a file that grew under the comparison -- reads as
1244/// "different", and the caller writes; the comparison can only ever save work.
1245fn same_bytes_on_disk(path: &Path, bytes: &[u8]) -> bool {
1246    let Ok(mut file) = fs::File::open(path) else { return false };
1247    let Ok(metadata) = file.metadata() else { return false };
1248    if metadata.len() != u64::try_from(bytes.len()).unwrap_or(u64::MAX) {
1249        return false;
1250    }
1251    // A file that holds every byte and then more is not the same file.
1252    reads_as(&mut file, bytes) && matches!(file.read(&mut [0u8; 1]), Ok(0))
1253}
1254
1255/// Whether the next bytes `file` yields are exactly `bytes`.
1256///
1257/// Streamed in 1 MiB pieces, and stopping at the first piece that differs; any read error
1258/// or early end reads as "different".
1259fn reads_as(file: &mut fs::File, bytes: &[u8]) -> bool {
1260    let mut buffer = vec![0u8; bytes.len().min(1 << 20)];
1261    let mut offset = 0usize;
1262    while offset < bytes.len() {
1263        let want = buffer.len().min(bytes.len() - offset);
1264        let read = match file.read(&mut buffer[..want]) {
1265            Ok(0) | Err(_) => return false,
1266            Ok(read) => read,
1267        };
1268        let end = offset + read;
1269        if buffer[..read] != bytes[offset..end] {
1270            return false;
1271        }
1272        offset = end;
1273    }
1274    true
1275}
1276
1277/// Move `path`'s modification time to `when` without touching its contents.
1278fn touch(path: &Path, when: std::time::SystemTime) -> io::Result<()> {
1279    let file = OpenOptions::new().write(true).open(path)?;
1280    file.set_modified(when)
1281}
1282
1283/// Remove long-abandoned temporaries beside `path`.
1284///
1285/// Best effort and deliberately silent: this is housekeeping, and a snapshot that was
1286/// written correctly must not fail because a directory could not be tidied. Anything
1287/// that cannot be read or removed is left for the next writer.
1288fn reap_stale_temporaries(parent: &Path, path: &Path, older_than: std::time::Duration) {
1289    let Some(prefix) = temp_prefix(path) else { return };
1290    let Ok(entries) = fs::read_dir(parent) else { return };
1291    let now = std::time::SystemTime::now();
1292    for entry in entries.flatten() {
1293        let name = entry.file_name();
1294        if !name.as_encoded_bytes().starts_with(prefix.as_encoded_bytes()) {
1295            continue;
1296        }
1297        let stale = entry
1298            .metadata()
1299            .and_then(|meta| meta.modified())
1300            .ok()
1301            .and_then(|modified| now.duration_since(modified).ok())
1302            .is_some_and(|age| age >= older_than);
1303        if stale {
1304            let _ = fs::remove_file(entry.path());
1305        }
1306    }
1307}
1308
1309/// The shared leading portion of every temporary written for `path`.
1310///
1311/// Matching on this rather than on a full parse keeps the reaper from depending on the
1312/// pid, entropy, and sequence encoding: a temporary written by an older build with a
1313/// different suffix layout is still recognised and still collected.
1314fn temp_prefix(path: &Path) -> Option<OsString> {
1315    let mut prefix = OsString::from(".");
1316    prefix.push(path.file_name()?);
1317    prefix.push(".tmp.");
1318    Some(prefix)
1319}
1320
1321/// The directory a target lives in, as a path that can actually be opened.
1322///
1323/// A bare relative target such as `snap.fdu` has an *empty* parent rather than none, and
1324/// the empty path is not the current directory: `create_dir_all` and `rename` tolerate
1325/// it, but `read_dir` does not — which silently disabled the reaper for exactly those
1326/// targets while the write itself kept working.
1327fn parent_dir(path: &Path) -> &Path {
1328    match path.parent() {
1329        Some(parent) if !parent.as_os_str().is_empty() => parent,
1330        _ => Path::new("."),
1331    }
1332}
1333
1334/// The temporary name this process uses for `path` at `sequence`.
1335///
1336/// Split out so a test can plant a collision at a name the writer will actually try.
1337/// Guessing the shape does not work — the entropy is per process — and getting that
1338/// wrong is how the first version of the stale-temporary test came to exercise nothing.
1339fn temp_name(path: &Path, sequence: u64) -> OsString {
1340    let mut name = OsString::from(".");
1341    name.push(path.file_name().unwrap_or_else(|| OsStr::new("snapshot")));
1342    name.push(format!(".tmp.{}.{:016x}.{}", std::process::id(), *TEMP_FILE_ENTROPY, sequence));
1343    name
1344}
1345
1346fn create_temp_file(path: &Path, parent: &Path) -> Result<(PathBuf, fs::File)> {
1347    for _ in 0..MAX_TEMP_CREATE_ATTEMPTS {
1348        let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed);
1349        let tmp = parent.join(temp_name(path, sequence));
1350        let mut options = OpenOptions::new();
1351        options.write(true).create_new(true);
1352        #[cfg(unix)]
1353        {
1354            use std::os::unix::fs::OpenOptionsExt;
1355            options.mode(0o600);
1356        }
1357        match options.open(&tmp) {
1358            Ok(file) => return Ok((tmp, file)),
1359            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1360            Err(error) => return Err(Error::io(&tmp, error)),
1361        }
1362    }
1363    Err(Error::io(
1364        parent,
1365        std::io::Error::new(
1366            std::io::ErrorKind::AlreadyExists,
1367            "could not reserve a unique snapshot temporary file",
1368        ),
1369    ))
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375    use crate::engine_contract::Observation;
1376    use crate::index::ExtTally;
1377
1378    fn attrs(size: u64, mtime_ns: i64) -> Attrs {
1379        Attrs {
1380            size,
1381            allocated: size.div_ceil(512) * 512,
1382            mtime_ns,
1383            ctime_ns: mtime_ns,
1384            inode: size.wrapping_mul(7).wrapping_add(1),
1385            dev: 3,
1386        }
1387    }
1388
1389    fn sample_index() -> Index {
1390        let mut index = Index::new("/some/root");
1391        index.apply_ok(&Observation::new(vec![
1392            Op::Upsert { path: PathBuf::from("src"), kind: EntryKind::Dir, attrs: attrs(0, 1) },
1393            Op::Upsert {
1394                path: PathBuf::from("src/main.rs"),
1395                kind: EntryKind::File,
1396                attrs: attrs(100, 10),
1397            },
1398            Op::Upsert {
1399                path: PathBuf::from("src/deep"),
1400                kind: EntryKind::Dir,
1401                attrs: attrs(0, 2),
1402            },
1403            Op::Upsert {
1404                path: PathBuf::from("src/deep/nested.rs"),
1405                kind: EntryKind::File,
1406                attrs: attrs(50, 20),
1407            },
1408            Op::Upsert {
1409                path: PathBuf::from("notes.md"),
1410                kind: EntryKind::File,
1411                attrs: attrs(7, 30),
1412            },
1413        ]));
1414        index
1415    }
1416
1417    fn entry_count_offset(bytes: &[u8]) -> usize {
1418        let root_len_at = ROOT_OFFSET;
1419        let root_len = u32::from_le_bytes(
1420            bytes[root_len_at..root_len_at + 4]
1421                .try_into()
1422                .expect("saved snapshot has a root length"),
1423        );
1424        root_len_at + 4 + usize::try_from(root_len).expect("root length fits usize")
1425    }
1426
1427    fn rewrite_checksum(bytes: &mut [u8]) {
1428        let payload_len = bytes.len() - CHECKSUM_BYTES - TRAILER.len();
1429        let checksum = crc32c(&bytes[..payload_len]);
1430        bytes[payload_len..payload_len + CHECKSUM_BYTES].copy_from_slice(&checksum.to_le_bytes());
1431    }
1432
1433    /// The encoded name field and following attributes for every entry record.
1434    fn entry_record_fields(bytes: &[u8]) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
1435        let count_at = entry_count_offset(bytes);
1436        let count = u64::from_le_bytes(
1437            bytes[count_at..count_at + 8].try_into().expect("saved snapshot has an entry count"),
1438        );
1439        let mut at = count_at + 8;
1440        let mut fields = Vec::new();
1441        for _ in 0..count {
1442            let name_len_at = at + 5;
1443            let name_len = usize::try_from(u32::from_le_bytes(
1444                bytes[name_len_at..name_len_at + 4]
1445                    .try_into()
1446                    .expect("saved entry has a name length"),
1447            ))
1448            .expect("name length fits usize");
1449            let name_end = name_len_at + 4 + name_len;
1450            fields.push((name_len_at..name_end, name_end..name_end + 6 * 8));
1451            at = name_end + 6 * 8;
1452        }
1453        fields
1454    }
1455
1456    fn replace_entry_name(image: &[u8], record: usize, name: &OsStr) -> Vec<u8> {
1457        let mut encoded = Vec::new();
1458        put_os_str(&mut encoded, name).expect("encode replacement name");
1459        let field = entry_record_fields(image)[record].0.clone();
1460        let mut rewritten = image.to_vec();
1461        rewritten.splice(field, encoded);
1462        rewrite_checksum(&mut rewritten);
1463        rewritten
1464    }
1465
1466    #[test]
1467    fn crc32c_matches_the_standard_check_value() {
1468        assert_eq!(crc32c(b"123456789"), 0xe306_9283);
1469    }
1470
1471    #[test]
1472    fn crc32c_slicing_matches_the_byte_reference_on_uneven_lengths() {
1473        // The 8-byte path and the remainder loop must agree with the classic
1474        // byte-at-a-time recurrence at every alignment, or an old snapshot's digest
1475        // stops verifying. The reference below IS that recurrence, against table 0.
1476        fn reference(bytes: &[u8]) -> u32 {
1477            let mut state = u32::MAX;
1478            for byte in bytes {
1479                let index = usize::from(state.to_le_bytes()[0] ^ *byte);
1480                state = CRC32C_TABLES[0][index] ^ (state >> 8);
1481            }
1482            !state
1483        }
1484        let mut data = Vec::new();
1485        let mut seed = 0x9e37_79b9u32;
1486        for length in [0usize, 1, 7, 8, 9, 15, 16, 63, 64, 65, 1000] {
1487            data.clear();
1488            for _ in 0..length {
1489                seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
1490                data.push(seed.to_le_bytes()[0]);
1491            }
1492            assert_eq!(crc32c(&data), reference(&data), "length {length}");
1493        }
1494    }
1495
1496    #[test]
1497    fn snapshot_names_must_equal_their_single_normal_component() {
1498        assert!(is_snapshot_name(OsStr::new("notes.md")));
1499        assert!(!is_snapshot_name(OsStr::new("notes.md/")));
1500        assert!(!is_snapshot_name(OsStr::new("a/b")));
1501        assert!(!is_snapshot_name(OsStr::new("../bad")));
1502        assert!(!is_snapshot_name(OsStr::new("")));
1503        assert!(!is_snapshot_name(OsStr::new(".")));
1504        assert!(!is_snapshot_name(OsStr::new("..")));
1505        #[cfg(unix)]
1506        {
1507            use std::os::unix::ffi::OsStringExt;
1508            let native = OsString::from_vec(vec![b'n', 0x80]);
1509            assert!(is_snapshot_name(&native), "canonical validation is OsStr identity");
1510            let aliased = OsString::from_vec(vec![b'n', 0x80, b'/']);
1511            assert!(!is_snapshot_name(&aliased));
1512        }
1513    }
1514
1515    #[test]
1516    fn a_valid_forged_rename_loads_and_is_found_by_lookup() {
1517        let dir = tempfile::tempdir().expect("tempdir");
1518        let path = dir.path().join("snapshot.fdu");
1519        let mut index = Index::new("/some/root");
1520        index.apply_ok(&Observation::new(vec![Op::Upsert {
1521            path: PathBuf::from("valid"),
1522            kind: EntryKind::File,
1523            attrs: attrs(1, 1),
1524        }]));
1525        save(&index, &path).expect("save");
1526        let saved = fs::read(&path).expect("read");
1527        let forged = replace_entry_name(&saved, 1, OsStr::new("renamed"));
1528        fs::write(&path, forged).expect("write valid rename");
1529        let restored = load(&path).expect("load").expect("a valid rename is a snapshot");
1530        assert!(restored.lookup(Path::new("renamed")).is_some());
1531        assert!(restored.lookup(Path::new("valid")).is_none());
1532    }
1533
1534    #[test]
1535    fn noncanonical_entry_names_are_rejected_after_integrity_checks() {
1536        let dir = tempfile::tempdir().expect("tempdir");
1537        let path = dir.path().join("snapshot.fdu");
1538        let mut index = Index::new("/some/root");
1539        index.apply_ok(&Observation::new(vec![Op::Upsert {
1540            path: PathBuf::from("valid"),
1541            kind: EntryKind::File,
1542            attrs: attrs(1, 1),
1543        }]));
1544        save(&index, &path).expect("save");
1545        let saved = fs::read(&path).expect("read");
1546
1547        #[cfg(windows)]
1548        let names = ["a/", "a//", "a/.", "./a", r"a\", r"a\\", r"a\."];
1549        #[cfg(not(windows))]
1550        let names = ["a/", "a//", "a/.", "./a"];
1551        for name in names {
1552            let forged = replace_entry_name(&saved, 1, OsStr::new(name));
1553            fs::write(&path, forged).expect("write forged snapshot");
1554            assert!(load(&path).expect("malformed snapshot is a miss").is_none(), "{name:?}");
1555        }
1556    }
1557
1558    #[test]
1559    fn a_checksummed_name_alias_cannot_serve_cache_only_content() {
1560        let dir = tempfile::tempdir().expect("tempdir");
1561        let root = dir.path().join("root");
1562        fs::create_dir(&root).expect("create root");
1563        fs::write(root.join("a"), b"one two\n").expect("write first");
1564        fs::write(root.join("bb"), b"one two\n").expect("write second");
1565        let snapshot_path = dir.path().join("snapshot.fdu");
1566        let config = crate::OpenFixture {
1567            cache_path: Some(snapshot_path.clone()),
1568            policy: crate::CachePolicy::Auto,
1569            analysis: crate::content::AnalysisRequest {
1570                profile: crate::content::AnalysisSet::NONE.with_lines(),
1571                ..crate::content::AnalysisRequest::default()
1572            },
1573            ..crate::OpenFixture::default()
1574        };
1575        crate::open_fixture(&root, &config).expect("seed snapshot and sidecar");
1576
1577        let mut image = fs::read(&snapshot_path).expect("read snapshot");
1578        let fields = entry_record_fields(&image);
1579        assert_eq!(fields.len(), 3, "root and two files");
1580        let first_attrs = image[fields[1].1.clone()].to_vec();
1581        image[fields[2].1.clone()].copy_from_slice(&first_attrs);
1582        let forged = replace_entry_name(&image, 2, OsStr::new("a/"));
1583        fs::write(&snapshot_path, forged).expect("write checksummed alias");
1584
1585        let only = crate::OpenFixture { stale_ok: true, ..config };
1586        assert!(
1587            matches!(crate::open_fixture(&root, &only), Err(Error::Snapshot(_))),
1588            "a malformed snapshot cannot shrink the cache-only completeness denominator"
1589        );
1590    }
1591
1592    #[test]
1593    fn a_loaded_index_reports_cached_provenance_not_fresh() {
1594        // The gap that motivated the provenance model. A snapshot is complete when it
1595        // is written, so a loaded index used to claim `Fresh` — true of when the file
1596        // was made, and exactly backwards for a consumer painting on load, which needs
1597        // to know nothing has been checked since.
1598        let dir = tempfile::tempdir().expect("tempdir");
1599        let path = dir.path().join("snapshot.fdu");
1600        let original = sample_index();
1601        save(&original, &path).expect("save");
1602
1603        let restored = load(&path).expect("load").expect("snapshot present");
1604        let provenance =
1605            restored.provenance(Path::new("src/main.rs")).expect("the loaded entry is present");
1606        assert_eq!(provenance.source, crate::Source::Cached);
1607        assert!(!provenance.is_verified(), "nothing has been stat'd since the load");
1608        assert!(
1609            provenance.observed_at_ns > 0,
1610            "a cached value must say as of when, or a UI cannot label it"
1611        );
1612
1613        // A freshly scanned index is the contrasting case.
1614        assert_eq!(
1615            original.provenance(Path::new("src/main.rs")).expect("present").source,
1616            crate::Source::Scanned
1617        );
1618    }
1619
1620    #[test]
1621    fn a_loaded_root_reports_cached_provenance_not_fresh() {
1622        // The root is the one entry the child-only test above cannot cover, and it is
1623        // the one that matters most: whole-tree totals hang off it, so `fdu ~` reads
1624        // the root's provenance to label its headline number. `apply_upsert` handles
1625        // the root in a separate branch, and that branch used to skip the source stamp
1626        // entirely — leaving a snapshot-loaded root claiming `Scanned` and
1627        // `is_verified()`, the precise silent lie the model exists to prevent.
1628        let dir = tempfile::tempdir().expect("tempdir");
1629        let path = dir.path().join("snapshot.fdu");
1630        save(&sample_index(), &path).expect("save");
1631
1632        let restored = load(&path).expect("load").expect("snapshot present");
1633        let provenance = restored.provenance(Path::new("")).expect("the root is always present");
1634        assert_eq!(provenance.source, crate::Source::Cached, "the root came off disk too");
1635        assert!(!provenance.is_verified(), "nothing has been stat'd since the load");
1636        assert!(
1637            provenance.observed_at_ns > 0,
1638            "a cached total must say as of when, or a UI cannot label it"
1639        );
1640    }
1641
1642    #[test]
1643    fn cached_observation_time_comes_from_the_header_after_touch_or_copy() {
1644        use std::time::{Duration, UNIX_EPOCH};
1645
1646        let dir = tempfile::tempdir().expect("tempdir");
1647        let path = dir.path().join("snapshot.fdu");
1648        let copied = dir.path().join("copied.fdu");
1649        let mut original = sample_index();
1650        original.set_writing_pass_started_at_ns(1_000);
1651        save(&original, &path).expect("save");
1652
1653        // Cache files are routinely kept in place or copied between cache locations. Neither
1654        // operation observes the tree, so neither file mtime may become value provenance.
1655        touch(&path, UNIX_EPOCH + Duration::from_secs(10)).expect("touch cache image");
1656        fs::copy(&path, &copied).expect("copy cache image");
1657        touch(&copied, UNIX_EPOCH + Duration::from_secs(20)).expect("touch copied image");
1658
1659        for cache in [&path, &copied] {
1660            let restored = load(cache).expect("load").expect("present");
1661            for entry in [Path::new(""), Path::new("src/main.rs")] {
1662                assert_eq!(
1663                    restored.provenance(entry).expect("present").observed_at_ns,
1664                    1_000,
1665                    "{} uses its persisted pass start, not its cache-file mtime",
1666                    cache.display()
1667                );
1668            }
1669        }
1670    }
1671
1672    #[test]
1673    fn revalidating_a_loaded_index_promotes_entries_out_of_cached() {
1674        // The failure a reviewer caught on PR #6: entries were stamped only when
1675        // allocated, so a warm sweep could stat every entry and leave them all
1676        // reporting `Cached`. A consumer could then never clear a stale-value
1677        // indicator no matter how much verification ran, which defeats the point.
1678        let dir = tempfile::tempdir().expect("tempdir");
1679        let tree = dir.path().join("tree");
1680        std::fs::create_dir_all(tree.join("sub")).expect("create dirs");
1681        std::fs::write(tree.join("sub/file.txt"), b"contents").expect("write");
1682        let snapshot_path = dir.path().join("snapshot.fdu");
1683
1684        let config = crate::ScanConfig::default();
1685        let (original, report) = crate::scan::scan_into_index(&tree, &config).expect("scan");
1686        assert!(report.is_complete());
1687        save(&original, &snapshot_path).expect("save");
1688
1689        let mut restored = load(&snapshot_path).expect("load").expect("present");
1690        let target = Path::new("sub/file.txt");
1691        assert_eq!(
1692            restored.provenance(target).expect("present").source,
1693            crate::Source::Cached,
1694            "straight off disk, nothing has been checked"
1695        );
1696
1697        // A sweep that finds nothing changed still verified every entry it stat'd.
1698        let reconciled =
1699            crate::scan::reconcile(&mut restored, &config, &mut |_| {}).expect("reconcile");
1700        assert!(reconciled.is_complete());
1701        assert_eq!(reconciled.apply.updated, 0, "the tree did not change");
1702
1703        let provenance = restored.provenance(target).expect("present");
1704        assert_eq!(
1705            provenance.source,
1706            crate::Source::Revalidated,
1707            "an unchanged entry that was freshly stat'd has still been verified"
1708        );
1709        assert!(provenance.is_verified());
1710    }
1711
1712    /// Every entry, not just the root.
1713    ///
1714    /// The loader inserts beneath a known parent instead of replaying an observation, so
1715    /// it no longer shares a code path with the producer that built the original. Root
1716    /// totals cannot catch a roll-up that is wrong halfway down — the errors would have
1717    /// to cancel at the root to hide, but a single misplaced subtree does not touch the
1718    /// root at all. This walks both trees and compares each directory's own roll-up,
1719    /// each entry's kind, attributes and extension tallies, and the shape of the tree.
1720    #[test]
1721    fn round_trip_preserves_every_entry_not_just_the_root() {
1722        let dir = tempfile::tempdir().expect("tempdir");
1723        let tree = dir.path().join("tree");
1724        let snapshot_path = dir.path().join("cache").join("snap.fdu");
1725        // Depth and fan-out both matter: a parent-relative insert that mis-parents an
1726        // entry shows up as a wrong roll-up on an interior directory.
1727        for (relative, contents) in [
1728            ("a.rs", &b"fn main() {}"[..]),
1729            ("deep/one/two/three/leaf.txt", b"leaf"),
1730            ("deep/one/two/sibling.rs", b"sibling"),
1731            ("deep/one/other.md", b"# other"),
1732            ("wide/w1.txt", b"1"),
1733            ("wide/w2.txt", b"22"),
1734            ("wide/w3.rs", b"333"),
1735            ("empty/.keep", b""),
1736        ] {
1737            let path = tree.join(relative);
1738            fs::create_dir_all(path.parent().expect("parent")).expect("create dirs");
1739            fs::write(&path, contents).expect("write");
1740        }
1741
1742        let config = crate::ScanConfig::default();
1743        let (original, report) = crate::scan::scan_into_index(&tree, &config).expect("scan");
1744        assert!(report.is_complete());
1745        save(&original, &snapshot_path).expect("save");
1746        let restored = load(&snapshot_path).expect("load").expect("present");
1747
1748        assert_eq!(restored.len(), original.len(), "entry count");
1749
1750        // Walk the original and demand the same entry, in the same place, with the same
1751        // numbers, in the restored index.
1752        let mut stack = vec![crate::index::EntryId::ROOT];
1753        let mut compared = 0_u64;
1754        while let Some(id) = stack.pop() {
1755            let path = original.path_of(id).expect("original path");
1756            let mirrored = restored.lookup(&path).expect("restored entry at the same path");
1757            assert_eq!(
1758                original.kind_of(id),
1759                restored.kind_of(mirrored),
1760                "kind at {}",
1761                path.display()
1762            );
1763            assert_eq!(
1764                original.attrs_of(id),
1765                restored.attrs_of(mirrored),
1766                "attrs at {}",
1767                path.display()
1768            );
1769            // Roll-ups and children exist only on directories; a file legitimately has
1770            // neither, and demanding them would fail on the tree rather than the loader.
1771            if original.kind_of(id) == Some(crate::EntryKind::Dir) {
1772                let (before, after) = (
1773                    original.rollup_of(id).expect("original rollup"),
1774                    restored.rollup_of(mirrored).expect("restored rollup"),
1775                );
1776                assert_eq!(
1777                    (
1778                        before.files,
1779                        before.dirs,
1780                        before.bytes,
1781                        before.allocated,
1782                        before.newest_mtime_ns
1783                    ),
1784                    (after.files, after.dirs, after.bytes, after.allocated, after.newest_mtime_ns),
1785                    "rollup at {}",
1786                    path.display()
1787                );
1788                assert_eq!(before.by_ext, after.by_ext, "extension tallies at {}", path.display());
1789                let names: Vec<_> = original
1790                    .children_of(id)
1791                    .expect("original children")
1792                    .map(|(name, _)| name.to_os_string())
1793                    .collect();
1794                let mirrored_names: Vec<_> = restored
1795                    .children_of(mirrored)
1796                    .expect("restored children")
1797                    .map(|(name, _)| name.to_os_string())
1798                    .collect();
1799                assert_eq!(names, mirrored_names, "children of {}", path.display());
1800                stack.extend(original.children_of(id).expect("children").map(|(_, child)| child));
1801            }
1802            compared += 1;
1803        }
1804        assert_eq!(compared, original.len(), "every entry was compared");
1805
1806        // The loader must not leave the index looking like it has pending history.
1807        assert_eq!(restored.clock(), crate::Clock::ZERO);
1808        assert!(restored.since(crate::Clock::ZERO).commits.is_empty());
1809    }
1810
1811    #[test]
1812    fn round_trip_preserves_tree_and_rollups() {
1813        let dir = tempfile::tempdir().expect("tempdir");
1814        let path = dir.path().join("cache").join("snap.fdu");
1815        let original = sample_index();
1816
1817        save(&original, &path).expect("save");
1818        let restored = load(&path).expect("load").expect("snapshot present");
1819
1820        assert_eq!(restored.root_path(), Path::new("/some/root"));
1821        assert_eq!(restored.clock(), crate::Clock::ZERO);
1822        assert!(restored.since(crate::Clock::ZERO).commits.is_empty());
1823        assert_eq!(restored.len(), original.len());
1824        // Public roll-ups resolve assignment-ordered extension ids to stable names.
1825        let (restored_total, original_total) = (restored.total(), original.total());
1826        assert_eq!(
1827            (restored_total.files, restored_total.dirs, restored_total.bytes),
1828            (original_total.files, original_total.dirs, original_total.bytes)
1829        );
1830        assert_eq!(restored_total.allocated, original_total.allocated);
1831        assert_eq!(restored_total.newest_mtime_ns, original_total.newest_mtime_ns);
1832        assert_eq!(restored_total.by_ext, original_total.by_ext);
1833        assert!(!original.serving_indexes_enabled());
1834        assert!(!restored.serving_indexes_enabled());
1835        assert_eq!(restored.total().files, 3);
1836        assert_eq!(restored.total().dirs, 2);
1837        assert_eq!(restored.total().bytes, 157);
1838        assert_eq!(
1839            restored.total().by_ext[".rs"],
1840            ExtTally { files: 2, bytes: 150, allocated: 1024 }
1841        );
1842        assert_eq!(
1843            restored.attrs(Path::new("src/deep/nested.rs")),
1844            original.attrs(Path::new("src/deep/nested.rs"))
1845        );
1846    }
1847
1848    #[test]
1849    fn round_trip_preserves_exact_controls_and_fixed_partitions() {
1850        let dir = tempfile::tempdir().expect("tempdir");
1851        let path = dir.path().join("controls.fdu");
1852        let mut original =
1853            Index::new_with_scope("/some/root", crate::test_support::observing_controls());
1854        original.apply_ok(&Observation::new(vec![
1855            Op::Upsert {
1856                path: PathBuf::from(".gitignore"),
1857                kind: EntryKind::File,
1858                attrs: attrs(6, 1),
1859            },
1860            Op::Upsert {
1861                path: PathBuf::from("debug.log"),
1862                kind: EntryKind::File,
1863                attrs: attrs(10, 2),
1864            },
1865            Op::Upsert {
1866                path: PathBuf::from("keep.rs"),
1867                kind: EntryKind::File,
1868                attrs: attrs(20, 3),
1869            },
1870            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
1871        ]));
1872
1873        save(&original, &path).expect("save");
1874        let restored = load(&path).expect("load").expect("snapshot present");
1875
1876        assert!(
1877            restored
1878                .controls()
1879                .expect("control state observed")
1880                .source_is(Path::new(".gitignore"), b"*.log\n")
1881        );
1882        assert_eq!(restored.controls().expect("control state observed").source_bytes(), 6);
1883        assert_eq!(
1884            restored.is_ignored(Path::new("debug.log")).expect("control state observed"),
1885            Some(true)
1886        );
1887        assert_eq!(
1888            restored.is_ignored(Path::new("keep.rs")).expect("control state observed"),
1889            Some(false)
1890        );
1891        let partitions = restored.partition_total().expect("control state observed");
1892        assert_eq!(partitions, original.partition_total().expect("control state observed"));
1893        assert_eq!(partitions.all.files, 3);
1894        assert_eq!(partitions.unignored.files, 2);
1895    }
1896
1897    #[test]
1898    fn removing_the_last_control_before_save_round_trips_an_empty_table() {
1899        let dir = tempfile::tempdir().expect("tempdir");
1900        let path = dir.path().join("no-controls.fdu");
1901        let mut original =
1902            Index::new_with_scope("/some/root", crate::test_support::observing_controls());
1903        original.apply_ok(&Observation::new(vec![
1904            Op::Upsert {
1905                path: PathBuf::from(".gitignore"),
1906                kind: EntryKind::File,
1907                attrs: attrs(6, 1),
1908            },
1909            Op::Upsert {
1910                path: PathBuf::from("debug.log"),
1911                kind: EntryKind::File,
1912                attrs: attrs(10, 2),
1913            },
1914            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
1915        ]));
1916        original
1917            .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from(".gitignore") }]));
1918
1919        save(&original, &path).expect("save");
1920        let restored = load(&path).expect("load").expect("snapshot present");
1921
1922        assert!(restored.controls().expect("control state observed").is_empty());
1923        assert_eq!(
1924            restored.is_ignored(Path::new("debug.log")).expect("control state observed"),
1925            Some(false)
1926        );
1927        let partitions = restored.partition_total().expect("control state observed");
1928        assert_eq!(partitions.all, partitions.unignored);
1929    }
1930
1931    #[test]
1932    fn a_control_table_at_its_shared_bound_round_trips() {
1933        let dir = tempfile::tempdir().expect("tempdir");
1934        let path = dir.path().join("bounded-controls.fdu");
1935        let mut original =
1936            Index::new_with_scope("/some/root", crate::test_support::observing_controls());
1937        let source = crate::control::source_at_test_limit();
1938        original.apply_ok(&Observation::new(vec![Op::ControlUpsert {
1939            path: PathBuf::from(".gitignore"),
1940            source: source.clone(),
1941        }]));
1942        assert_eq!(
1943            original.controls().expect("control state observed").retained_cost(),
1944            crate::control::DEFAULT_CONTROL_BUDGET
1945        );
1946
1947        save(&original, &path).expect("save at bound");
1948        let restored = load(&path).expect("load").expect("snapshot present");
1949
1950        assert_eq!(
1951            restored.controls().expect("control state observed").retained_cost(),
1952            original.controls().expect("control state observed").retained_cost()
1953        );
1954        assert!(
1955            restored
1956                .controls()
1957                .expect("control state observed")
1958                .source_is(Path::new(".gitignore"), &source)
1959        );
1960    }
1961
1962    /// A control table must enforce the limits its scope was taken under. A hand-built index
1963    /// whose scope claims other limits is refused at save with a typed error, a snapshot
1964    /// whose control section its header's control tier would not admit fails closed at
1965    /// load, and `Index::new_with_config` builds an index whose table and scope agree.
1966    #[test]
1967    fn control_limits_that_disagree_with_the_scope_are_refused_at_save_and_load() {
1968        let dir = tempfile::tempdir().expect("tempdir");
1969        let path = dir.path().join("limits.fdu");
1970        let lifted = crate::ScanConfig {
1971            control_limits: crate::control::ControlLimits {
1972                budget: None,
1973                ..crate::control::ControlLimits::default()
1974            },
1975            ..crate::ScanConfig::default()
1976        };
1977
1978        let mismatched = Index::new_with_scope("/some/root", lifted.scope());
1979        let error = save(&mismatched, &path).expect_err("the scope claims no budget");
1980        assert!(
1981            matches!(
1982                error,
1983                Error::ControlLimitsOutsideScope { limits }
1984                    if limits == crate::control::ControlLimits::default()
1985            ),
1986            "{error}"
1987        );
1988        assert!(!path.exists(), "nothing is written");
1989
1990        let mut agreeing = Index::new_with_config("/some/root", &lifted);
1991        agreeing.apply_ok(&Observation::new(vec![Op::ControlUpsert {
1992            path: PathBuf::from(".gitignore"),
1993            source: b"*.log\n".to_vec(),
1994        }]));
1995        assert_eq!(agreeing.scope(), lifted.scope());
1996        save(&agreeing, &path).expect("save an index whose table enforces its scope's limits");
1997        let restored = load(&path).expect("load").expect("snapshot present");
1998        assert_eq!(restored.control_coverage(), agreeing.control_coverage());
1999        assert_eq!(restored.snapshot_identity(), lifted.snapshot_identity());
2000
2001        // The limits live only in the header's control tier. A CRC-valid snapshot whose
2002        // header claims a line limit the retained source exceeds, or claims no control
2003        // state beside a retained source, was written by no save, so it fails closed like
2004        // any other corruption.
2005        let saved = fs::read(&path).expect("read snapshot");
2006        let controls_at = IDENTITY_OFFSET + crate::stored_state::ENTRY_TIER_BYTES;
2007        let controls = controls_at..controls_at + crate::stored_state::CONTROL_TIER_BYTES;
2008        let blind = crate::ScanConfig { read_controls: false, ..lifted.clone() };
2009        let forge = |tier: ControlTierIdentity| {
2010            let mut forged = saved.clone();
2011            forged[controls.clone()].copy_from_slice(&tier.encode());
2012            rewrite_checksum(&mut forged);
2013            fs::write(&path, &forged).expect("write forged limits");
2014            (
2015                load(&path).expect("forged equals absent"),
2016                load_serving(&path, blind.types_shared(), blind.snapshot_identity())
2017                    .expect("projected forged equals absent"),
2018            )
2019        };
2020        let tight = crate::control::ControlLimits { line_limit: Some(1), ..lifted.control_limits };
2021        let (exact, projected) = forge(ControlTierIdentity::Observed { limits: tight });
2022        assert!(exact.is_none());
2023        assert!(matches!(projected, LoadOutcome::Absent));
2024        let (exact, projected) = forge(ControlTierIdentity::NotObserved);
2025        assert!(exact.is_none());
2026        assert!(matches!(projected, LoadOutcome::Absent));
2027        // The same splice with the saved tier restores, so the splice is not what failed.
2028        let (exact, projected) = forge(lifted.control_identity());
2029        assert!(exact.is_some());
2030        let LoadOutcome::Served { index: projected, stored } = projected else {
2031            panic!("the valid control payload projects");
2032        };
2033        assert_eq!(serves_snapshot(stored, blind.snapshot_identity()), Serves::ProjectControlsOff);
2034        assert_eq!(projected.snapshot_identity(), blind.snapshot_identity());
2035        assert_eq!(projected.scope(), blind.scope());
2036        assert!(matches!(projected.controls(), Err(Error::ControlStateNotObserved)));
2037    }
2038
2039    /// An index whose control table refused some sources, and its path-ordered tail.
2040    fn index_with_refused_controls() -> Index {
2041        let mut index =
2042            Index::new_with_scope("/some/root", crate::test_support::observing_controls());
2043        let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
2044        line.push(b'\n');
2045        index.apply_ok(&Observation::new(vec![
2046            Op::Upsert {
2047                path: PathBuf::from(".gitignore"),
2048                kind: EntryKind::File,
2049                attrs: attrs(6, 1),
2050            },
2051            Op::Upsert { path: PathBuf::from("a"), kind: EntryKind::Dir, attrs: attrs(0, 1) },
2052            Op::Upsert {
2053                path: PathBuf::from("a/.gitignore"),
2054                kind: EntryKind::File,
2055                attrs: attrs(1, 1),
2056            },
2057            Op::Upsert { path: PathBuf::from("b"), kind: EntryKind::Dir, attrs: attrs(0, 1) },
2058            Op::Upsert {
2059                path: PathBuf::from("b/.gitignore"),
2060                kind: EntryKind::File,
2061                attrs: attrs(1, 1),
2062            },
2063            Op::Upsert {
2064                path: PathBuf::from("b/debug.log"),
2065                kind: EntryKind::File,
2066                attrs: attrs(9, 1),
2067            },
2068            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
2069            Op::ControlUpsert { path: PathBuf::from("a/.gitignore"), source: line },
2070            Op::ControlUpsert {
2071                path: PathBuf::from("b/.gitignore"),
2072                source: b"y\n".repeat(crate::control::DEFAULT_CONTROL_BUDGET / 2),
2073            },
2074        ]));
2075        index
2076    }
2077
2078    /// A snapshot of an index that refused control files reloads with the same coverage,
2079    /// rather than as an index whose every rule applied.
2080    #[test]
2081    fn a_snapshot_with_refused_controls_reloads_its_coverage_exactly() {
2082        let dir = tempfile::tempdir().expect("tempdir");
2083        let path = dir.path().join("refused.fdu");
2084        let original = index_with_refused_controls();
2085        let crate::control::ControlCoverage::Observed(coverage) = original.control_coverage()
2086        else {
2087            panic!("observed");
2088        };
2089        assert_eq!((coverage.applied, coverage.refused), (1, 2));
2090
2091        save(&original, &path).expect("save a partially covered index");
2092        let restored = load(&path).expect("load").expect("snapshot present");
2093
2094        assert_eq!(restored.control_coverage(), original.control_coverage());
2095        assert_eq!(
2096            restored.is_ignored(Path::new("b/debug.log")).expect("observed"),
2097            original.is_ignored(Path::new("b/debug.log")).expect("observed")
2098        );
2099        assert_eq!(
2100            restored.partition_total().expect("observed"),
2101            original.partition_total().expect("observed")
2102        );
2103    }
2104
2105    /// The refusal records are parsed as strictly as the rest: an unknown reason, and a
2106    /// refusal naming a retained source, are corrupt.
2107    #[test]
2108    fn corrupt_refusal_records_fail_closed() {
2109        let dir = tempfile::tempdir().expect("tempdir");
2110        let path = dir.path().join("refused.fdu");
2111        save(&index_with_refused_controls(), &path).expect("save");
2112        let saved = fs::read(&path).expect("read snapshot");
2113        let footer = CHECKSUM_BYTES + TRAILER.len();
2114        // The last refusal is `b/.gitignore` and its one-byte reason ends the payload.
2115        let reason_at = saved.len() - footer - 1;
2116        assert_eq!(saved[reason_at], REFUSED_FOR_BUDGET);
2117
2118        let mut unknown_reason = saved;
2119        unknown_reason[reason_at] = 9;
2120        rewrite_checksum(&mut unknown_reason);
2121        fs::write(&path, &unknown_reason).expect("write corrupt reason");
2122        assert!(load(&path).expect("corrupt equals absent").is_none());
2123
2124        // A refusal naming a retained source, or one already refused, is corrupt, and so is a
2125        // refusal by a limit the section records as unbounded, which refuses nothing. Built
2126        // with the platform's own path encoding, so the same bytes mean the same on every
2127        // target.
2128        let defaults = crate::control::ControlLimits::default();
2129        let section = |refusals: &[(&str, u8)]| {
2130            let mut section = Vec::new();
2131            section.extend_from_slice(&1_u32.to_le_bytes());
2132            put_os_str(&mut section, OsStr::new(".gitignore")).expect("retained path");
2133            section.extend_from_slice(&6_u32.to_le_bytes());
2134            section.extend_from_slice(b"*.log\n");
2135            let count = u32::try_from(refusals.len()).expect("few refusals");
2136            section.extend_from_slice(&count.to_le_bytes());
2137            for (refusal, reason) in refusals {
2138                put_os_str(&mut section, OsStr::new(refusal)).expect("refused path");
2139                section.push(*reason);
2140            }
2141            section
2142        };
2143        let no_budget = crate::control::ControlLimits { budget: None, ..defaults };
2144        let no_line_limit = crate::control::ControlLimits { line_limit: None, ..defaults };
2145        for (limits, refusals) in [
2146            (defaults, &[("a/.gitignore", REFUSED_FOR_BUDGET)][..]),
2147            (no_budget, &[("a/.gitignore", REFUSED_FOR_LINE_LIMIT)][..]),
2148            (no_line_limit, &[("a/.gitignore", REFUSED_FOR_BUDGET)][..]),
2149        ] {
2150            let tier = ControlTierIdentity::Observed { limits };
2151            let valid = read_controls(&mut section(refusals).as_slice(), tier)
2152                .expect("a well-formed control section");
2153            assert_eq!((valid.len(), valid.refused_len()), (1, 1));
2154        }
2155        for (limits, refusals) in [
2156            (defaults, &[(".gitignore", REFUSED_FOR_BUDGET)][..]),
2157            (
2158                defaults,
2159                &[("a/.gitignore", REFUSED_FOR_BUDGET), ("a/.gitignore", REFUSED_FOR_BUDGET)][..],
2160            ),
2161            (no_budget, &[("a/.gitignore", REFUSED_FOR_BUDGET)][..]),
2162            (no_line_limit, &[("a/.gitignore", REFUSED_FOR_LINE_LIMIT)][..]),
2163        ] {
2164            let tier = ControlTierIdentity::Observed { limits };
2165            assert!(
2166                matches!(
2167                    read_controls(&mut section(refusals).as_slice(), tier),
2168                    Err(ParseError::Invalid)
2169                ),
2170                "{limits:?} {refusals:?}"
2171            );
2172        }
2173        // A tier that observed nothing holds no source and no refusal.
2174        for refusals in [&[][..], &[("a/.gitignore", REFUSED_FOR_BUDGET)][..]] {
2175            assert!(matches!(
2176                read_controls(&mut section(refusals).as_slice(), ControlTierIdentity::NotObserved),
2177                Err(ParseError::Invalid)
2178            ));
2179        }
2180    }
2181
2182    /// A snapshot with no control state loads without walking the tree to reclassify it.
2183    ///
2184    /// Installing the loaded control table re-evaluated every entry's ignored bit, allocating
2185    /// a path per entry, even when the table was empty and no bit could move. That is every
2186    /// warm open of a tree with no `.gitignore`, and it scaled with the tree.
2187    #[test]
2188    fn loading_a_snapshot_without_controls_skips_the_reclassification_walk() {
2189        fn visits_while_loading(path: &Path) -> (u64, Index) {
2190            crate::index::RECLASSIFY_VISITS.with(|visits| visits.set(0));
2191            let restored = load(path).expect("load").expect("snapshot present");
2192            (crate::index::RECLASSIFY_VISITS.with(std::cell::Cell::get), restored)
2193        }
2194
2195        let dir = tempfile::tempdir().expect("tempdir");
2196        let mut original =
2197            Index::new_with_scope("/some/root", crate::test_support::observing_controls());
2198        let mut ops = vec![Op::Upsert {
2199            path: PathBuf::from("src"),
2200            kind: EntryKind::Dir,
2201            attrs: attrs(0, 1),
2202        }];
2203        ops.extend((0..64).map(|sequence| Op::Upsert {
2204            path: PathBuf::from(format!("src/file-{sequence:02}.rs")),
2205            kind: EntryKind::File,
2206            attrs: attrs(sequence + 1, 2),
2207        }));
2208        original.apply_baseline_ok(&Observation::new(ops));
2209        let plain = dir.path().join("plain.fdu");
2210        save(&original, &plain).expect("save");
2211
2212        let (visits, restored) = visits_while_loading(&plain);
2213
2214        assert_eq!(visits, 0, "an empty control table has nothing to reclassify");
2215        assert!(restored.control_table().is_empty());
2216        // The load still answers as the saved index did.
2217        assert_eq!(
2218            restored.is_ignored(Path::new("src/file-00.rs")).expect("control state observed"),
2219            Some(false)
2220        );
2221        assert_eq!(
2222            restored.partition_total().expect("control state observed"),
2223            original.partition_total().expect("control state observed")
2224        );
2225
2226        // The probe sees the walk when there is something to walk for.
2227        original.apply_ok(&Observation::new(vec![Op::ControlUpsert {
2228            path: PathBuf::from("src/.gitignore"),
2229            source: b"file-0*.rs\n".to_vec(),
2230        }]));
2231        let controlled = dir.path().join("controlled.fdu");
2232        save(&original, &controlled).expect("save");
2233
2234        let (visits, restored) = visits_while_loading(&controlled);
2235
2236        assert!(visits > 64, "{visits}");
2237        assert_eq!(
2238            restored.is_ignored(Path::new("src/file-00.rs")).expect("control state observed"),
2239            Some(true)
2240        );
2241        assert_eq!(
2242            restored.is_ignored(Path::new("src/file-10.rs")).expect("control state observed"),
2243            Some(false)
2244        );
2245    }
2246
2247    #[test]
2248    fn round_trip_handles_wide_directory_fanout() {
2249        // Load resolves each record's id from its parent. Doing that by scanning the
2250        // parent's children costs O(width^2) per directory, which is invisible on the
2251        // handful of siblings every other test uses and dominates a real one — a
2252        // node_modules or a Maildir is thousands wide.
2253        const CHILDREN: u64 = 4_096;
2254        let dir = tempfile::tempdir().expect("tempdir");
2255        let path = dir.path().join("wide.fdu");
2256        let mut original = Index::new("/some/root");
2257        let ops = (0..CHILDREN)
2258            .map(|sequence| Op::Upsert {
2259                path: PathBuf::from(format!("child-{sequence:04}.dat")),
2260                kind: EntryKind::File,
2261                attrs: attrs(sequence + 1, i64::try_from(sequence).expect("fanout fits i64")),
2262            })
2263            .collect();
2264        original.apply_baseline_ok(&Observation::new(ops));
2265
2266        save(&original, &path).expect("save wide snapshot");
2267        let restored = load(&path).expect("load wide snapshot").expect("snapshot present");
2268
2269        assert_eq!(restored.total().files, CHILDREN);
2270        assert_eq!(restored.len(), original.len());
2271        // The last child is the one a linear scan reaches last, so it is the one that
2272        // proves the resolution used the parent's map rather than its sibling order.
2273        assert_eq!(
2274            restored.attrs(Path::new("child-4095.dat")),
2275            original.attrs(Path::new("child-4095.dat"))
2276        );
2277    }
2278
2279    #[test]
2280    fn shared_save_captures_before_filesystem_io() {
2281        let dir = tempfile::tempdir().expect("tempdir");
2282        let path = dir.path().join("shared.fdu");
2283        let handle = IndexHandle::new(sample_index());
2284
2285        save_handle(&handle, &path).expect("save shared snapshot");
2286        handle
2287            .apply(&Observation::new(vec![Op::Upsert {
2288                path: PathBuf::from("after.txt"),
2289                kind: EntryKind::File,
2290                attrs: attrs(9, 40),
2291            }]))
2292            .expect("mutate after capture");
2293
2294        let restored = load(&path).expect("load").expect("snapshot present");
2295        assert!(restored.lookup(Path::new("after.txt")).is_none());
2296        assert!(handle.kind(Path::new("after.txt")).expect("query").is_some());
2297    }
2298
2299    #[test]
2300    fn missing_snapshot_is_absent_not_an_error() {
2301        let dir = tempfile::tempdir().expect("tempdir");
2302        let loaded = load(&dir.path().join("nope.fdu")).expect("load must not error");
2303        assert!(loaded.is_none());
2304    }
2305
2306    #[test]
2307    fn configured_size_limit_rejects_before_body_allocation() {
2308        let dir = tempfile::tempdir().expect("tempdir");
2309        let path = dir.path().join("snap.fdu");
2310        save(&sample_index(), &path).expect("save");
2311        let file_len = fs::metadata(&path).expect("metadata").len();
2312
2313        assert!(load_with_size_limit(&path, file_len - 1).expect("load must not error").is_none());
2314    }
2315
2316    #[test]
2317    fn foreign_file_is_treated_as_absent() {
2318        let dir = tempfile::tempdir().expect("tempdir");
2319        let path = dir.path().join("snap.fdu");
2320        fs::write(&path, b"this is not a snapshot at all").expect("write");
2321        assert!(load(&path).expect("load must not error").is_none());
2322    }
2323
2324    #[test]
2325    fn truncated_snapshot_is_treated_as_absent() {
2326        let dir = tempfile::tempdir().expect("tempdir");
2327        let path = dir.path().join("snap.fdu");
2328        save(&sample_index(), &path).expect("save");
2329
2330        let full = fs::read(&path).expect("read");
2331        for cut in [full.len() - 1, full.len() / 2, MAGIC.len() + 2] {
2332            fs::write(&path, &full[..cut]).expect("truncate");
2333            assert!(
2334                load(&path).expect("load must not error").is_none(),
2335                "a snapshot truncated to {cut} bytes must not parse"
2336            );
2337        }
2338    }
2339
2340    #[test]
2341    fn corrupt_body_with_intact_magic_and_trailer_is_rejected() {
2342        let dir = tempfile::tempdir().expect("tempdir");
2343        let path = dir.path().join("snap.fdu");
2344        save(&sample_index(), &path).expect("save");
2345
2346        let mut bytes = fs::read(&path).expect("read");
2347        // Claim far more entries than the body holds.
2348        let count_at = entry_count_offset(&bytes);
2349        bytes[count_at..count_at + 8].copy_from_slice(&u64::MAX.to_le_bytes());
2350        rewrite_checksum(&mut bytes);
2351        fs::write(&path, &bytes).expect("write");
2352
2353        assert!(load(&path).expect("load must not error").is_none());
2354    }
2355
2356    #[test]
2357    fn a_snapshot_whose_sizes_sum_past_u64_fails_closed() {
2358        // The image is structurally valid and its checksum is right; only the arithmetic
2359        // is impossible. A load that summed it would panic in debug builds and wrap in
2360        // release builds, so it is refused like any other corrupt image (fdu-sqyk).
2361        let dir = tempfile::tempdir().expect("tempdir");
2362        let path = dir.path().join("snap.fdu");
2363        save(&sample_index(), &path).expect("save");
2364
2365        let mut bytes = fs::read(&path).expect("read");
2366        let mut files = 0;
2367        for (name, attrs) in entry_record_fields(&bytes) {
2368            let kind_at = name.start - 1;
2369            if EntryKind::from_u8(bytes[kind_at]) != Some(EntryKind::File) {
2370                continue;
2371            }
2372            bytes[attrs.start..attrs.start + 8].copy_from_slice(&u64::MAX.to_le_bytes());
2373            files += 1;
2374            if files == 2 {
2375                break;
2376            }
2377        }
2378        assert_eq!(files, 2, "the sample has two files to inflate");
2379        rewrite_checksum(&mut bytes);
2380        fs::write(&path, &bytes).expect("write");
2381
2382        assert!(load(&path).expect("load must not error").is_none());
2383    }
2384
2385    #[test]
2386    fn plausible_attribute_corruption_is_rejected() {
2387        let dir = tempfile::tempdir().expect("tempdir");
2388        let path = dir.path().join("snap.fdu");
2389        save(&sample_index(), &path).expect("save");
2390
2391        let mut bytes = fs::read(&path).expect("read");
2392        let records_at = entry_count_offset(&bytes) + 8;
2393        // The root record has an empty name, so its first attribute starts immediately
2394        // after parent, kind, and encoded-name length. Changing a low size byte keeps the
2395        // image structurally valid and would silently alter the restored state without an
2396        // integrity check.
2397        let root_size_at = records_at + 4 + 1 + 4;
2398        bytes[root_size_at] ^= 1;
2399        fs::write(&path, &bytes).expect("write");
2400
2401        assert!(load(&path).expect("load must not error").is_none());
2402    }
2403
2404    #[test]
2405    fn entry_names_with_path_components_are_rejected() {
2406        let dir = tempfile::tempdir().expect("tempdir");
2407        let path = dir.path().join("snap.fdu");
2408        save(&sample_index(), &path).expect("save");
2409
2410        let mut bytes = fs::read(&path).expect("read");
2411        let count_at = entry_count_offset(&bytes);
2412        let records_at = count_at + 8;
2413        let first_child_name_len_at = records_at + MIN_RECORD_BYTES + 4 + 1;
2414        let old_name_len = u32::from_le_bytes(
2415            bytes[first_child_name_len_at..first_child_name_len_at + 4]
2416                .try_into()
2417                .expect("saved snapshot has a child name length"),
2418        );
2419        let old_name_end = first_child_name_len_at
2420            + 4
2421            + usize::try_from(old_name_len).expect("name length fits usize");
2422        let mut invalid_name = Vec::new();
2423        put_os_str(&mut invalid_name, OsStr::new("../bad")).expect("encode invalid name");
2424        bytes.splice(first_child_name_len_at..old_name_end, invalid_name);
2425        rewrite_checksum(&mut bytes);
2426        fs::write(&path, &bytes).expect("write");
2427
2428        assert!(load(&path).expect("load must not error").is_none());
2429    }
2430
2431    #[test]
2432    fn oversized_declared_path_is_rejected_before_allocation() {
2433        let dir = tempfile::tempdir().expect("tempdir");
2434        let path = dir.path().join("snap.fdu");
2435        save(&sample_index(), &path).expect("save");
2436
2437        let mut bytes = fs::read(&path).expect("read");
2438        let root_len_at = ROOT_OFFSET;
2439        bytes[root_len_at..root_len_at + 4].copy_from_slice(&(MAX_PATH_BYTES + 1).to_le_bytes());
2440        rewrite_checksum(&mut bytes);
2441        fs::write(&path, &bytes).expect("write");
2442
2443        assert!(load(&path).expect("load must not error").is_none());
2444    }
2445
2446    #[test]
2447    fn engine_fingerprint_mismatch_discards_the_snapshot() {
2448        let dir = tempfile::tempdir().expect("tempdir");
2449        let path = dir.path().join("snap.fdu");
2450        save(&sample_index(), &path).expect("save");
2451
2452        let mut bytes = fs::read(&path).expect("read");
2453        let fp_at = MAGIC.len() + 4;
2454        bytes[fp_at] ^= 0xff;
2455        rewrite_checksum(&mut bytes);
2456        fs::write(&path, &bytes).expect("write");
2457
2458        assert!(load(&path).expect("load must not error").is_none());
2459    }
2460
2461    #[test]
2462    fn save_replaces_an_existing_snapshot_and_leaves_no_temp_files() {
2463        let dir = tempfile::tempdir().expect("tempdir");
2464        let path = dir.path().join("snap.fdu");
2465
2466        save(&sample_index(), &path).expect("first save");
2467        let mut smaller = Index::new("/some/root");
2468        smaller.apply_ok(&Observation::new(vec![Op::Upsert {
2469            path: PathBuf::from("only.txt"),
2470            kind: EntryKind::File,
2471            attrs: attrs(1, 1),
2472        }]));
2473        save(&smaller, &path).expect("second save");
2474
2475        let restored = load(&path).expect("load").expect("present");
2476        assert_eq!(restored.total().files, 1);
2477
2478        let leftovers: Vec<_> = fs::read_dir(dir.path())
2479            .expect("read_dir")
2480            .filter_map(std::result::Result::ok)
2481            .map(|e| e.file_name().to_string_lossy().into_owned())
2482            .filter(|name| name != "snap.fdu")
2483            .collect();
2484        assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
2485    }
2486
2487    #[test]
2488    fn an_abandoned_temporary_is_collected_once_it_is_old_enough() {
2489        // Per-process entropy means no future writer will ever generate a corpse's
2490        // name again, so nothing would otherwise reclaim it: unique names turn an
2491        // occasional collision into permanent litter, one whole snapshot image at a
2492        // time. The reaper closes that, and the age threshold is what makes it safe
2493        // without a liveness check — pid-based liveness is unportable and wrong under
2494        // pid reuse.
2495        //
2496        // The threshold is a parameter so both sides are testable without setting
2497        // mtimes, which the standard library cannot do and which is not worth a
2498        // dependency.
2499        let dir = tempfile::tempdir().expect("tempdir");
2500        let path = dir.path().join("snap.fdu");
2501        let prefix = temp_prefix(&path).expect("a named target has a prefix");
2502
2503        let mut corpse = prefix.clone();
2504        corpse.push("111.0123456789abcdef.7");
2505        let unrelated = OsString::from("notes.txt");
2506        fs::write(dir.path().join(&corpse), b"a writer killed long ago").expect("plant");
2507        fs::write(dir.path().join(&unrelated), b"not ours").expect("plant");
2508
2509        // The shipped threshold spares anything that could still be in flight.
2510        reap_stale_temporaries(dir.path(), &path, STALE_TEMP_AGE);
2511        assert!(dir.path().join(&corpse).exists(), "a fresh corpse must be left alone");
2512
2513        // Past the threshold it is collected, and only it.
2514        reap_stale_temporaries(dir.path(), &path, std::time::Duration::ZERO);
2515        assert!(!dir.path().join(&corpse).exists(), "an old corpse must be collected");
2516        assert!(dir.path().join(&unrelated).exists(), "unrelated files are never touched");
2517    }
2518
2519    #[test]
2520    fn the_reaper_only_matches_its_own_targets_temporaries() {
2521        // The prefix carries the target's file name, so two snapshots sharing a
2522        // directory cannot collect each other's work in progress.
2523        let mine = temp_prefix(Path::new("/cache/snap.fdu")).expect("prefix");
2524        let theirs = temp_prefix(Path::new("/cache/other.fdu")).expect("prefix");
2525        assert_eq!(mine, OsString::from(".snap.fdu.tmp."));
2526        assert_ne!(mine, theirs);
2527        assert!(temp_prefix(Path::new("/")).is_none(), "a rootless path has no name");
2528    }
2529
2530    #[test]
2531    fn a_stale_temporary_does_not_block_a_later_write() {
2532        // A killed writer leaves its temporary behind and nothing reaps it until it is
2533        // a day old, so a later write can find a corpse sitting exactly where it wants
2534        // to go. `O_EXCL` plus the retry loop is what makes that safe: the collision is
2535        // detected and stepped over, never shared.
2536        //
2537        // The corpse is planted at names this process will really try, via `temp_name`.
2538        // An earlier version of this test guessed the shape and planted
2539        // `.snap.fdu.tmp.{pid}.0`, which the entropy in the name means the writer can
2540        // never generate — so it collided with nothing and proved nothing, while
2541        // reading as though it had.
2542        let dir = tempfile::tempdir().expect("tempdir");
2543        let path = dir.path().join("snap.fdu");
2544
2545        // Block a window of upcoming sequence values. A window rather than one name
2546        // because the counter is process-global and other tests draw from it too;
2547        // whichever value this write lands on, it starts inside the blocked range.
2548        let first = NEXT_TEMP_FILE.load(Ordering::Relaxed);
2549        let planted: Vec<OsString> =
2550            (first..first + 32).map(|sequence| temp_name(&path, sequence)).collect();
2551        for name in &planted {
2552            fs::write(dir.path().join(name), b"corpse").expect("plant a stale temporary");
2553        }
2554
2555        write_atomically(&path, b"payload").expect("write past the stale temporaries");
2556        assert_eq!(fs::read(&path).expect("read back"), b"payload");
2557
2558        // Every corpse survives: the writer stepped over them rather than reusing or
2559        // removing one, and they are far too young for the reaper.
2560        let mut survivors: Vec<_> = fs::read_dir(dir.path())
2561            .expect("read_dir")
2562            .filter_map(std::result::Result::ok)
2563            .map(|entry| entry.file_name())
2564            .filter(|name| name != "snap.fdu")
2565            .collect();
2566        survivors.sort();
2567        let mut expected = planted;
2568        expected.sort();
2569        assert_eq!(survivors, expected, "the write must step over corpses, not consume them");
2570    }
2571
2572    #[test]
2573    fn an_identical_payload_leaves_the_file_in_place() {
2574        // The default command encodes an unchanged tree to the bytes already on disk.
2575        // Replacing them was a full write, an fsync and a rename for nothing; now the
2576        // file is left alone, which on a filesystem is observable as the same inode.
2577        let dir = tempfile::tempdir().expect("tempdir");
2578        let path = dir.path().join("snap.fdu");
2579        let payload: Vec<u8> =
2580            (0u32..(3 << 20)).map(|i| u8::try_from(i % 251).unwrap_or(0)).collect();
2581
2582        write_atomically(&path, &payload).expect("first write");
2583        let first = fs::metadata(&path).expect("metadata");
2584        let before = std::time::SystemTime::now();
2585        write_atomically(&path, &payload).expect("identical write");
2586        let second = fs::metadata(&path).expect("metadata");
2587
2588        assert_eq!(fs::read(&path).expect("read back"), payload);
2589        assert_same_file(&first, &second);
2590        // The "as of" moved even though the bytes did not: the loader reads the mtime
2591        // as every cached entry's observation time, and the tree was just verified.
2592        assert!(second.modified().expect("mtime") >= before);
2593        // No temporary was created and abandoned on the way to deciding not to write.
2594        let extras = fs::read_dir(dir.path())
2595            .expect("read_dir")
2596            .filter_map(std::result::Result::ok)
2597            .filter(|entry| entry.file_name() != "snap.fdu")
2598            .count();
2599        assert_eq!(extras, 0);
2600    }
2601
2602    #[test]
2603    fn a_different_payload_of_the_same_length_is_written() {
2604        // Length is the cheap pre-check, not the decision: two snapshots of the same
2605        // tree with one mtime changed are the same length and must not be confused.
2606        let dir = tempfile::tempdir().expect("tempdir");
2607        let path = dir.path().join("snap.fdu");
2608        write_atomically(&path, b"payload-a").expect("first write");
2609        write_atomically(&path, b"payload-b").expect("second write");
2610        assert_eq!(fs::read(&path).expect("read back"), b"payload-b");
2611
2612        // And a file that holds the payload as a prefix is not the payload.
2613        fs::write(&path, b"payload-b-and-more").expect("lengthen");
2614        assert!(!same_bytes_on_disk(&path, b"payload-b"));
2615        assert!(!same_bytes_on_disk(&path, b"payload-b-and-mor"));
2616        assert!(same_bytes_on_disk(&path, b"payload-b-and-more"));
2617        assert!(!same_bytes_on_disk(&dir.path().join("absent"), b""));
2618    }
2619
2620    #[cfg(unix)]
2621    fn assert_same_file(first: &fs::Metadata, second: &fs::Metadata) {
2622        use std::os::unix::fs::MetadataExt;
2623        assert_eq!(first.ino(), second.ino(), "the snapshot was replaced, not left in place");
2624    }
2625
2626    #[cfg(not(unix))]
2627    fn assert_same_file(first: &fs::Metadata, second: &fs::Metadata) {
2628        // Creation time survives a skipped write and changes across a rename of a fresh
2629        // temporary, on the platforms that report it.
2630        if let (Ok(a), Ok(b)) = (first.created(), second.created()) {
2631            assert_eq!(a, b, "the snapshot was replaced, not left in place");
2632        }
2633    }
2634
2635    #[test]
2636    fn a_bare_filename_target_resolves_to_an_openable_directory() {
2637        // `Path::parent` returns `Some("")` for a bare name, not `None`, so an
2638        // `unwrap_or(".")` fallback never fires. The write still worked — `rename`
2639        // accepts the empty path — but `read_dir("")` fails, so the reaper returned
2640        // immediately and collected nothing for those targets.
2641        assert_eq!(parent_dir(Path::new("snap.fdu")), Path::new("."));
2642        assert!(fs::read_dir(parent_dir(Path::new("snap.fdu"))).is_ok(), "must be openable");
2643        assert_eq!(parent_dir(Path::new("/cache/snap.fdu")), Path::new("/cache"));
2644        assert_eq!(parent_dir(Path::new("cache/snap.fdu")), Path::new("cache"));
2645    }
2646
2647    #[test]
2648    fn a_temporary_name_is_unique_per_sequence_and_carries_process_entropy() {
2649        // The two components that make a corpse unreachable by a future process, and a
2650        // writer unable to collide with itself.
2651        let path = Path::new("/cache/snap.fdu");
2652        assert_ne!(temp_name(path, 0), temp_name(path, 1), "the counter separates files");
2653        let name = temp_name(path, 0).to_string_lossy().into_owned();
2654        assert!(name.starts_with(".snap.fdu.tmp."), "reaper prefix must match: {name}");
2655        assert!(
2656            name.contains(&format!("{:016x}", *TEMP_FILE_ENTROPY)),
2657            "entropy must be in the name, or a recycled pid regenerates a corpse: {name}"
2658        );
2659    }
2660
2661    #[test]
2662    fn concurrent_atomic_writes_do_not_share_a_temporary_file() {
2663        use std::sync::{Arc, Barrier};
2664
2665        const WRITERS: usize = 8;
2666        const PAYLOAD_BYTES: usize = 1024 * 1024;
2667
2668        let dir = tempfile::tempdir().expect("tempdir");
2669        let path = dir.path().join("snap.fdu");
2670        let barrier = Arc::new(Barrier::new(WRITERS));
2671        let results = std::thread::scope(|scope| {
2672            let handles: Vec<_> = (0..WRITERS)
2673                .map(|writer| {
2674                    let barrier = Arc::clone(&barrier);
2675                    let path = path.clone();
2676                    scope.spawn(move || {
2677                        let byte = u8::try_from(writer + 1).expect("writer id fits");
2678                        let bytes = vec![byte; PAYLOAD_BYTES];
2679                        barrier.wait();
2680                        write_atomically(&path, &bytes)
2681                    })
2682                })
2683                .collect();
2684            handles.into_iter().map(std::thread::ScopedJoinHandle::join).collect::<Vec<_>>()
2685        });
2686
2687        for result in results {
2688            result.expect("writer thread did not panic").expect("concurrent atomic write");
2689        }
2690        let final_bytes = fs::read(&path).expect("read final image");
2691        assert_eq!(final_bytes.len(), PAYLOAD_BYTES);
2692        assert!(final_bytes.iter().all(|byte| *byte == final_bytes[0]));
2693
2694        let leftovers: Vec<_> = fs::read_dir(dir.path())
2695            .expect("read_dir")
2696            .filter_map(std::result::Result::ok)
2697            .map(|entry| entry.file_name())
2698            .filter(|name| name != "snap.fdu")
2699            .collect();
2700        assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
2701    }
2702
2703    #[test]
2704    fn concurrent_snapshot_reader_sees_only_a_complete_old_or_new_image() {
2705        use std::sync::{Arc, Barrier};
2706
2707        let dir = tempfile::tempdir().expect("tempdir");
2708        let path = dir.path().join("snap.fdu");
2709        let mut old_index = Index::new("/some/root");
2710        old_index.apply_ok(&Observation::new(vec![Op::Upsert {
2711            path: PathBuf::from("old.txt"),
2712            kind: EntryKind::File,
2713            attrs: attrs(11, 1),
2714        }]));
2715        let mut new_index = Index::new("/some/root");
2716        new_index.apply_ok(&Observation::new(vec![
2717            Op::Upsert {
2718                path: PathBuf::from("new-a.txt"),
2719                kind: EntryKind::File,
2720                attrs: attrs(20, 2),
2721            },
2722            Op::Upsert {
2723                path: PathBuf::from("new-b.txt"),
2724                kind: EntryKind::File,
2725                attrs: attrs(30, 3),
2726            },
2727        ]));
2728        save(&old_index, &path).expect("save old image");
2729
2730        let start: Arc<Barrier> = Arc::new(Barrier::new(2));
2731        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
2732        std::thread::scope(|scope| {
2733            let writer_start: Arc<Barrier> = Arc::clone(&start);
2734            let writer_path: PathBuf = path.clone();
2735            scope.spawn(move || {
2736                writer_start.wait();
2737                done_tx.send(save(&new_index, &writer_path)).expect("report snapshot write");
2738            });
2739
2740            let reader_start: Arc<Barrier> = Arc::clone(&start);
2741            let reader_path: PathBuf = path.clone();
2742            scope.spawn(move || {
2743                reader_start.wait();
2744                let deadline: std::time::Instant =
2745                    std::time::Instant::now() + std::time::Duration::from_secs(10);
2746                loop {
2747                    assert!(
2748                        std::time::Instant::now() < deadline,
2749                        "snapshot replacement did not finish before the deadline"
2750                    );
2751                    let image: Index =
2752                        load(&reader_path).expect("load during replacement").expect("image");
2753                    match image.total().files {
2754                        1 => {
2755                            assert_eq!(image.total().bytes, 11);
2756                            assert!(image.lookup(Path::new("old.txt")).is_some());
2757                            assert!(image.lookup(Path::new("new-a.txt")).is_none());
2758                        }
2759                        2 => {
2760                            assert_eq!(image.total().bytes, 50);
2761                            assert!(image.lookup(Path::new("old.txt")).is_none());
2762                            assert!(image.lookup(Path::new("new-a.txt")).is_some());
2763                            assert!(image.lookup(Path::new("new-b.txt")).is_some());
2764                        }
2765                        partial => panic!("reader observed partial image with {partial} files"),
2766                    }
2767
2768                    match done_rx.try_recv() {
2769                        Ok(result) => {
2770                            result.expect("replace snapshot");
2771                            break;
2772                        }
2773                        Err(std::sync::mpsc::TryRecvError::Empty) => {}
2774                        Err(std::sync::mpsc::TryRecvError::Disconnected) => {
2775                            panic!("snapshot writer disconnected")
2776                        }
2777                    }
2778                }
2779            });
2780        });
2781
2782        let final_image: Index = load(&path).expect("load final").expect("final image");
2783        assert_eq!(final_image.total().files, 2);
2784        assert_eq!(final_image.total().bytes, 50);
2785    }
2786
2787    #[cfg(unix)]
2788    #[test]
2789    fn saved_snapshot_is_owner_readable_only() {
2790        use std::os::unix::fs::PermissionsExt;
2791
2792        let dir = tempfile::tempdir().expect("tempdir");
2793        let path = dir.path().join("snap.fdu");
2794        save(&sample_index(), &path).expect("save");
2795
2796        let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
2797        assert_eq!(mode, 0o600);
2798    }
2799
2800    #[test]
2801    fn empty_index_round_trips() {
2802        let dir = tempfile::tempdir().expect("tempdir");
2803        let path = dir.path().join("snap.fdu");
2804        let empty = Index::new("/some/root");
2805
2806        save(&empty, &path).expect("save");
2807        let restored = load(&path).expect("load").expect("present");
2808        assert!(restored.is_empty());
2809        assert_eq!(restored.total(), empty.total());
2810    }
2811
2812    #[test]
2813    fn partial_index_is_never_persisted() {
2814        let dir = tempfile::tempdir().expect("tempdir");
2815        let path = dir.path().join("partial.fdu");
2816        save(&Index::new("/root"), &path).expect("complete baseline");
2817        let complete_bytes = fs::read(&path).expect("read complete snapshot");
2818
2819        let mut index = Index::new("/root");
2820        index.set_initial_freshness(false);
2821
2822        assert!(matches!(save(&index, &path), Err(Error::Snapshot(_))));
2823        assert_eq!(fs::read(&path).expect("old snapshot remains"), complete_bytes);
2824    }
2825
2826    #[test]
2827    fn semantic_scan_scope_round_trips() {
2828        let dir = tempfile::tempdir().expect("tempdir");
2829        let path = dir.path().join("snap.fdu");
2830        let entries = crate::EntryTierIdentity {
2831            engine: engine_fingerprint(),
2832            scope: crate::EntryScope {
2833                max_depth: Some(7),
2834                follow_symlinks: false,
2835                one_filesystem: true,
2836                hidden_fingerprint: 5,
2837                exclude_special: true,
2838                population: crate::query::IgnoredEntries::Include,
2839                control_fingerprint: 0,
2840            },
2841            type_rules_fingerprint: crate::classify::type_rule_fingerprint(),
2842            reducers_fingerprint: 33,
2843        };
2844        // The default control limits, which the table of an index built with
2845        // `new_with_scope` enforces; any other limits are refused at save.
2846        for controls in [
2847            ControlTierIdentity::Observed { limits: crate::control::ControlLimits::default() },
2848            ControlTierIdentity::NotObserved,
2849        ] {
2850            let identity = SnapshotIdentity { entries, controls };
2851            let index = Index::new_with_scope("/some/root", identity.scan_scope());
2852
2853            save(&index, &path).expect("save");
2854            let restored = load(&path).expect("load").expect("present");
2855            assert_eq!(restored.snapshot_identity(), identity);
2856            assert_eq!(restored.scope(), identity.scan_scope());
2857            let info = read_header(&path).expect("read header").expect("current");
2858            assert_eq!(info.identity, identity);
2859            assert_eq!(info.scope(), identity.scan_scope());
2860        }
2861    }
2862
2863    /// Reading control files decides which entries are ignored, never which entries exist,
2864    /// so the snapshots of one tree with observation on and off record one entry tier, byte
2865    /// for byte, and differ only in the control tier.
2866    #[test]
2867    fn controls_on_and_off_snapshots_share_the_entry_identity() {
2868        let tree = tempfile::tempdir().expect("tree");
2869        fs::write(tree.path().join(".gitignore"), b"*.log\n").expect("write");
2870        fs::write(tree.path().join("debug.log"), b"log").expect("write");
2871        fs::create_dir(tree.path().join("src")).expect("mkdir");
2872        fs::write(tree.path().join("src/main.rs"), b"fn main() {}\n").expect("write");
2873        let dir = tempfile::tempdir().expect("tempdir");
2874        let observed = crate::ScanConfig::default();
2875        let blind = crate::ScanConfig { read_controls: false, ..observed.clone() };
2876
2877        let saved = [(&observed, "on.fdu"), (&blind, "off.fdu")].map(|(config, name)| {
2878            let (index, _) = crate::scan::scan_into_index(tree.path(), config).expect("scan");
2879            let path = dir.path().join(name);
2880            save(&index, &path).expect("save");
2881            let restored = load(&path).expect("load").expect("present");
2882            assert_eq!(restored.snapshot_identity(), config.snapshot_identity());
2883            (restored.snapshot_identity(), fs::read(&path).expect("read"))
2884        });
2885        let [(on, on_bytes), (off, off_bytes)] = saved;
2886
2887        assert_eq!(on.entries, off.entries);
2888        assert_ne!(on.controls, off.controls);
2889        let entry_tier = IDENTITY_OFFSET..IDENTITY_OFFSET + crate::stored_state::ENTRY_TIER_BYTES;
2890        let control_tier = entry_tier.end..ROOT_OFFSET;
2891        assert_eq!(on_bytes[entry_tier.clone()], off_bytes[entry_tier]);
2892        assert_ne!(on_bytes[control_tier.clone()], off_bytes[control_tier]);
2893
2894        let projected = load_serving(
2895            &dir.path().join("on.fdu"),
2896            blind.types_shared(),
2897            blind.snapshot_identity(),
2898        )
2899        .expect("load projection");
2900        let LoadOutcome::Served { index: projected, stored } = projected else {
2901            panic!("an observed snapshot should project to the blind request");
2902        };
2903        assert_eq!(serves_snapshot(stored, blind.snapshot_identity()), Serves::ProjectControlsOff);
2904        let (cold, _) = crate::scan::scan_into_index(tree.path(), &blind).expect("blind scan");
2905        assert_eq!(projected.snapshot_identity(), blind.snapshot_identity());
2906        assert_eq!(projected.scope(), blind.scope());
2907        assert_eq!(projected.len(), cold.len());
2908        assert_eq!(projected.total(), cold.total());
2909        assert!(matches!(projected.controls(), Err(Error::ControlStateNotObserved)));
2910        assert_eq!(
2911            projected.path_state(Path::new("debug.log")),
2912            cold.path_state(Path::new("debug.log"))
2913        );
2914
2915        let mut corrupt = on_bytes;
2916        corrupt[WRITING_PASS_STARTED_AT_OFFSET] ^= 1;
2917        fs::write(dir.path().join("on.fdu"), corrupt).expect("corrupt checksum");
2918        assert!(matches!(
2919            load_serving(
2920                &dir.path().join("on.fdu"),
2921                blind.types_shared(),
2922                blind.snapshot_identity(),
2923            )
2924            .expect("corrupt projection is absent"),
2925            LoadOutcome::Absent
2926        ));
2927    }
2928
2929    /// A snapshot written under other `.gitignore` semantics is not served to the default
2930    /// population, although its tier identities cannot say so: the control tier records only
2931    /// its limits, from which a loading build recomputes the ignore-rules fingerprint under
2932    /// its own rules version, and an Include entry tier records no governing control policy.
2933    /// The engine fingerprint mixes the rules version, and it alone refuses the snapshot.
2934    #[test]
2935    fn a_snapshot_under_other_ignore_rules_is_refused_for_the_default_population() {
2936        use crate::stored_state::IGNORE_RULES_VERSION;
2937
2938        let tree = tempfile::tempdir().expect("tree");
2939        fs::write(tree.path().join(".gitignore"), b"*.log\n").expect("write");
2940        fs::write(tree.path().join("debug.log"), b"log").expect("write");
2941        let config = crate::ScanConfig::default();
2942        let wanted = config.snapshot_identity();
2943        assert_eq!(wanted.entries.scope.population, crate::query::IgnoredEntries::Include);
2944        assert!(wanted.controls.is_observed(), "the default population reads .gitignore");
2945
2946        let (index, _) = crate::scan::scan_into_index(tree.path(), &config).expect("scan");
2947        let dir = tempfile::tempdir().expect("tempdir");
2948        let path = dir.path().join("snap.fdu");
2949        save(&index, &path).expect("save");
2950        let current = fs::read(&path).expect("read");
2951        let serve = || load_serving(&path, config.types_shared(), wanted).expect("load");
2952        assert!(matches!(serve(), LoadOutcome::Served { .. }), "this build's snapshot serves");
2953
2954        let engine_at = MAGIC.len() + 4;
2955        let tiers = IDENTITY_OFFSET..ROOT_OFFSET;
2956        for version in [IGNORE_RULES_VERSION - 1, IGNORE_RULES_VERSION + 1] {
2957            let label = format!("rules version {version}");
2958            let other = engine_fingerprint_under(version);
2959            assert_ne!(other, engine_fingerprint(), "{label}");
2960            let mut forged = current.clone();
2961            forged[engine_at..engine_at + 8].copy_from_slice(&other.to_le_bytes());
2962            rewrite_checksum(&mut forged);
2963            fs::write(&path, &forged).expect("write forged");
2964
2965            // The tier identities are this build's, byte for byte, and would serve the
2966            // request exactly under this build's engine.
2967            assert_eq!(forged[tiers.clone()], current[tiers.clone()], "{label}");
2968            let bytes = forged[tiers.clone()].try_into().expect("the tier identities");
2969            let stored = SnapshotIdentity::decode(wanted.entries.engine, bytes).expect("decode");
2970            assert_eq!(serves_snapshot(stored, wanted), Serves::Exact, "{label}");
2971
2972            assert!(matches!(serve(), LoadOutcome::Absent), "{label}");
2973            assert!(
2974                matches!(
2975                    identify(&path).expect("identify"),
2976                    Some(Identity::Stale(crate::cache::StaleReason::OtherEngine))
2977                ),
2978                "{label}"
2979            );
2980            assert_eq!(
2981                crate::cache::cache_status(&path).expect("status").state,
2982                crate::cache::CacheState::Stale(crate::cache::StaleReason::OtherEngine),
2983                "{label}"
2984            );
2985        }
2986    }
2987
2988    /// A format-4 snapshot, laid out as that format wrote it, is fdu's and stale: the
2989    /// prologue kept its offsets, so the cache lifecycle names its version rather than
2990    /// calling it foreign, and loading it is a miss.
2991    #[test]
2992    fn a_v4_snapshot_is_older_format() {
2993        const V4: u32 = 4;
2994        let dir = tempfile::tempdir().expect("tempdir");
2995        let path = dir.path().join("v4.fdu");
2996
2997        let mut image = MAGIC.to_vec();
2998        image.extend_from_slice(&V4.to_le_bytes());
2999        // The v4 engine fingerprint. Recognition reads the version before the engine, so a
3000        // format-4 image is older whatever these eight bytes hold.
3001        image.extend_from_slice(&0x4444_4444_4444_4444_u64.to_le_bytes());
3002        image.push(path_encoding());
3003        // The v4 scope: depth, flags, and the hidden, ignore-rules, type-rules, and reducer
3004        // fingerprints.
3005        image.extend_from_slice(&u64::MAX.to_le_bytes());
3006        image.push(0);
3007        let scope = crate::ScanConfig::default().scope();
3008        for fingerprint in [
3009            scope.hidden_fingerprint,
3010            scope.ignore_rules_fingerprint,
3011            scope.type_rules_fingerprint,
3012            scope.reducers_fingerprint,
3013        ] {
3014            image.extend_from_slice(&fingerprint.to_le_bytes());
3015        }
3016        put_os_str(&mut image, OsStr::new("/some/root")).expect("root");
3017        image.extend_from_slice(&1_u64.to_le_bytes());
3018        image.extend_from_slice(&NO_PARENT.to_le_bytes());
3019        image.push(EntryKind::Dir as u8);
3020        put_os_str(&mut image, OsStr::new("")).expect("root name");
3021        image.extend_from_slice(&[0; 6 * 8]);
3022        // The v4 control section: no sources, both default limits, no refusals.
3023        image.extend_from_slice(&0_u32.to_le_bytes());
3024        for limit in [crate::DEFAULT_CONTROL_BUDGET, crate::DEFAULT_CONTROL_LINE_LIMIT] {
3025            image.push(1);
3026            image.extend_from_slice(&u64::try_from(limit).expect("limit").to_le_bytes());
3027        }
3028        image.extend_from_slice(&0_u32.to_le_bytes());
3029        let checksum = crc32c(&image);
3030        image.extend_from_slice(&checksum.to_le_bytes());
3031        image.extend_from_slice(TRAILER);
3032        fs::write(&path, &image).expect("write v4 image");
3033
3034        assert!(matches!(
3035            identify(&path).expect("identify"),
3036            Some(Identity::Stale(crate::cache::StaleReason::OlderFormat { version: V4 }))
3037        ));
3038        assert!(read_header(&path).expect("read header").is_none());
3039        assert!(load(&path).expect("load").is_none());
3040        assert_eq!(
3041            crate::cache::cache_status(&path).expect("status").state,
3042            crate::cache::CacheState::Stale(crate::cache::StaleReason::OlderFormat { version: V4 })
3043        );
3044    }
3045
3046    /// A later pass over an unchanged tree encodes the same facts under a newer pass start.
3047    /// The image already on disk is kept, its stamp included, rather than rewritten for the
3048    /// stamp alone, and its mtime moves to the later pass's start, never backwards; a
3049    /// changed tree is written with the new pass's stamp.
3050    #[test]
3051    fn a_later_pass_over_the_same_facts_keeps_the_snapshot_in_place() {
3052        use std::time::{Duration, UNIX_EPOCH};
3053
3054        let dir = tempfile::tempdir().expect("tempdir");
3055        let path = dir.path().join("snap.fdu");
3056        let stamp_at = |bytes: &[u8]| {
3057            i64::from_le_bytes(
3058                bytes[WRITING_PASS_STARTED_AT_OFFSET..IDENTITY_OFFSET].try_into().expect("stamp"),
3059            )
3060        };
3061        let mtime = || fs::metadata(&path).expect("metadata").modified().expect("mtime");
3062        let nanos = |time: std::time::SystemTime| {
3063            i64::try_from(time.duration_since(UNIX_EPOCH).expect("after the epoch").as_nanos())
3064                .expect("nanoseconds")
3065        };
3066
3067        let mut first = sample_index();
3068        first.set_writing_pass_started_at_ns(1_000);
3069        save(&first, &path).expect("first save");
3070        let written = fs::metadata(&path).expect("metadata");
3071        assert_eq!(stamp_at(&fs::read(&path).expect("read")), 1_000);
3072        assert_eq!(
3073            load(&path).expect("load").expect("present").writing_pass_started_at_ns(),
3074            1_000
3075        );
3076
3077        // A pass that started after the image was written, on a whole second so every
3078        // filesystem's mtime holds the instant exactly.
3079        let later_started = UNIX_EPOCH
3080            + Duration::from_secs(
3081                mtime().duration_since(UNIX_EPOCH).expect("after the epoch").as_secs() + 1,
3082            );
3083        let mut later = sample_index();
3084        later.set_writing_pass_started_at_ns(nanos(later_started));
3085        save(&later, &path).expect("unchanged save");
3086        let kept = fs::metadata(&path).expect("metadata");
3087        assert_same_file(&written, &kept);
3088        assert_eq!(kept.modified().expect("mtime"), later_started, "the kept image's as-of");
3089        assert_eq!(stamp_at(&fs::read(&path).expect("read")), 1_000);
3090        assert!(load(&path).expect("load").is_some(), "the kept image is still valid");
3091
3092        // An equivalent save under an older stamp, as of an index loaded from an earlier
3093        // image, keeps the later mtime, which was already true of these facts.
3094        save(&first, &path).expect("older unchanged save");
3095        assert_same_file(&written, &fs::metadata(&path).expect("metadata"));
3096        assert_eq!(mtime(), later_started);
3097
3098        // A kept image must still verify: one whose own checksum is wrong reads as absent,
3099        // so it is replaced rather than kept under an older stamp forever.
3100        let mut corrupt = fs::read(&path).expect("read");
3101        let checksum_at = corrupt.len() - TRAILER.len() - CHECKSUM_BYTES;
3102        corrupt[checksum_at] ^= 0xff;
3103        fs::write(&path, &corrupt).expect("corrupt the checksum");
3104        assert!(load(&path).expect("load").is_none());
3105        save(&later, &path).expect("save over a corrupt image");
3106        assert_eq!(stamp_at(&fs::read(&path).expect("read")), nanos(later_started));
3107        assert!(load(&path).expect("load").is_some());
3108
3109        let mut changed = sample_index();
3110        changed.set_writing_pass_started_at_ns(3_000);
3111        changed.apply_ok(&Observation::new(vec![Op::Upsert {
3112            path: PathBuf::from("notes.md"),
3113            kind: EntryKind::File,
3114            attrs: attrs(8, 30),
3115        }]));
3116        save(&changed, &path).expect("changed save");
3117        let rewritten = fs::read(&path).expect("read");
3118        assert_eq!(stamp_at(&rewritten), 3_000);
3119        let restored = load(&path).expect("load").expect("present");
3120        assert_eq!(restored.writing_pass_started_at_ns(), 3_000);
3121        assert_eq!(restored.total(), changed.total());
3122    }
3123
3124    #[cfg(unix)]
3125    #[test]
3126    fn non_utf8_names_round_trip_without_aliasing() {
3127        use std::os::unix::ffi::OsStringExt;
3128
3129        let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
3130        let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
3131        let mut root = PathBuf::from("/some");
3132        root.push(OsString::from_vec(vec![b'r', 0x82]));
3133        let mut index = Index::new(&root);
3134        index.apply_baseline_ok(&Observation::new(vec![
3135            Op::Upsert { path: first.clone(), kind: EntryKind::File, attrs: attrs(10, 1) },
3136            Op::Upsert { path: second.clone(), kind: EntryKind::File, attrs: attrs(20, 2) },
3137        ]));
3138
3139        let dir = tempfile::tempdir().expect("tempdir");
3140        let path = dir.path().join("snap.fdu");
3141        save(&index, &path).expect("save");
3142        let restored = load(&path).expect("load").expect("present");
3143
3144        assert_eq!(restored.root_path(), root);
3145        assert_eq!(restored.total().files, 2);
3146        assert_eq!(restored.total().bytes, 30);
3147        assert!(restored.lookup(&first).is_some());
3148        assert!(restored.lookup(&second).is_some());
3149        assert!(!restored.serving_indexes_enabled());
3150    }
3151}