Skip to main content

fdu_core/content/
content_cache.rs

1//! Versioned content sidecar, independent of the metadata snapshot format.
2
3use std::ffi::OsString;
4use std::fs;
5use std::io::{self, Read, Seek, SeekFrom};
6use std::path::{Component, Path, PathBuf};
7use std::time::{Duration, Instant};
8
9use crate::classify::{
10    ClassificationFlags, ContentFamily, DetectionConfidence, DetectionSource, FileTypeId,
11};
12use crate::stored_state::{
13    AnalyzerProvenance, ContentTierIdentity, ENTRY_TIER_BYTES, EntryTierIdentity,
14};
15use crate::{Error, Fingerprint, Index, Result};
16
17use super::{
18    AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerId, AnalyzerOutcome,
19    AnalyzerVersion, BasicMetrics, CodeMetrics, ContentDetection, CoverageReason, FileAnalysis,
20    LogicalWordStats, WordMetrics,
21};
22
23const MAGIC: &[u8; 8] = b"FDUCTNT\0";
24const TRAILER: &[u8; 8] = b"FDUCTEND";
25/// On-disk format version. Bump on any layout change or any change to what a record means;
26/// a sidecar of another version is a clean miss.
27///
28/// 7: coverage distinguishes recognized unsupported encodings from binary data.
29///
30/// 6: records store detection separately from name classification and one outcome block
31/// per requested analyzer unit.
32///
33/// 5: the header records the engine fingerprint beside the version, at the offset a
34/// snapshot's prologue gives it, and the content tier identity after the path encoding:
35/// the entry tier the records were analyzed over, which holds their type rules, then the
36/// analyzer set, the options fingerprint, and the analyzers.
37const FORMAT_VERSION: u32 = 7;
38const CHECKSUM_BYTES: usize = 4;
39const MAX_CACHE_BYTES: u64 = 512 * 1024 * 1024;
40const MAX_RECORDS: u64 = 5_000_000;
41const MAX_PATH_BYTES: usize = 1024 * 1024;
42const MAX_TYPE_BYTES: usize = 256;
43const MAX_ANALYZERS: usize = 16;
44const MAX_ANALYZER_ID_BYTES: usize = 128;
45const MAX_ERROR_BYTES: usize = 512;
46
47/// Result of conditionally restoring one content sidecar.
48#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
49pub struct ContentCacheLoad {
50    /// Whether the sidecar header and integrity checks matched this request.
51    pub usable: bool,
52    /// Records accepted by the current metadata index.
53    pub hits: u64,
54    /// Apparent bytes represented by accepted records.
55    pub bytes: u64,
56    /// Accepted records with an expected non-analyzed coverage outcome.
57    pub coverage_exclusions: u64,
58    /// Records that no longer matched a live candidate.
59    pub stale: u64,
60    /// Candidates this restore walked. Cache-only completeness compares `hits` to this
61    /// instead of walking `analysis_candidates` again.
62    pub(crate) candidates: u64,
63}
64
65#[derive(Default)]
66struct RestoreTimings {
67    parse: Duration,
68    apply: Duration,
69}
70
71impl RestoreTimings {
72    fn add_parse(&mut self, started: Instant) {
73        add_duration(&mut self.parse, started.elapsed());
74    }
75
76    fn add_apply(&mut self, started: Instant) {
77        add_duration(&mut self.apply, started.elapsed());
78    }
79
80    fn publish(self) {
81        crate::counters::bump(|counts| {
82            counts.content_sidecar_parse_us =
83                counts.content_sidecar_parse_us.saturating_add(duration_micros(self.parse));
84            counts.content_sidecar_apply_us =
85                counts.content_sidecar_apply_us.saturating_add(duration_micros(self.apply));
86        });
87    }
88}
89
90fn add_duration(total: &mut Duration, elapsed: Duration) {
91    *total = total.saturating_add(elapsed);
92}
93
94fn duration_micros(duration: Duration) -> u64 {
95    u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
96}
97
98/// Derive the content-sidecar path without changing the metadata snapshot name.
99pub fn content_cache_path(snapshot_path: &Path) -> PathBuf {
100    let mut name = snapshot_path.as_os_str().to_os_string();
101    name.push(".content");
102    PathBuf::from(name)
103}
104
105/// Persist the content tier's sparse records as a separately invalidated sidecar, under the
106/// identity the tier holds.
107///
108/// The tier's own identity decides everything a request could have said: an index with no
109/// prepared tier writes nothing, and a prepared tier names an enabled analyzer set. A
110/// request argument could only disagree with it, and a sidecar labelled with the tier's set
111/// after a caller asked for another is worse than no argument at all.
112pub fn save_content_cache(index: &Index, path: &Path) -> Result<()> {
113    let Some(content) = index.content() else {
114        return Ok(());
115    };
116    let Some(identity) = content.identity() else {
117        return Ok(());
118    };
119    // The tier states its records' type rules once, in its entry tier, and a save writes it
120    // only as the identity this index gives records of that set, so records produced under
121    // any other, such as other type rules, never reach a sidecar under its label.
122    let wanted = index.content_identity(identity.analysis);
123    let content = content.admit(&wanted).ok_or_else(|| {
124        Error::Snapshot(
125            "content records were produced under another identity than their index".into(),
126        )
127    })?;
128    let identity = content.identity();
129    // Every record the tier holds carries its identity, because the tier refuses any other,
130    // so which records are written is decided per record: those this pass verified.
131    let records = content
132        .records()
133        .filter(|(path, record)| crate::stored_state::content_record_writable(index, path, record))
134        .collect::<Vec<_>>();
135    let record_count = u64::try_from(records.len())
136        .map_err(|_| Error::Snapshot("content sidecar record count overflow".into()))?;
137    if record_count > MAX_RECORDS {
138        return Err(Error::Snapshot("content sidecar exceeds record limit".into()));
139    }
140
141    let mut buffer = Vec::new();
142    buffer.extend_from_slice(MAGIC);
143    buffer.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
144    buffer.extend_from_slice(&identity.entries.engine.to_le_bytes());
145    buffer.push(crate::snapshot::path_encoding());
146    put_identity(&mut buffer, identity)?;
147    crate::snapshot::put_os_str(&mut buffer, index.root_path().as_os_str())?;
148    buffer.extend_from_slice(&record_count.to_le_bytes());
149    for (relative_path, record) in records {
150        put_record(&mut buffer, relative_path, record)?;
151    }
152    let checksum = crate::snapshot::crc32c(&buffer);
153    buffer.extend_from_slice(&checksum.to_le_bytes());
154    buffer.extend_from_slice(TRAILER);
155    crate::snapshot::write_atomically(path, &buffer)
156}
157
158/// Restore the records of a sidecar whose identity equals `wanted`, returning a miss for an
159/// absent, corrupt, or foreign sidecar and for one of any other identity.
160///
161/// Equality, not containment: a sidecar of a wider analyzer set holds metrics a narrower
162/// request did not ask for, and one produced under other analyzer versions, options, type
163/// rules, entries, or engine counts differently. An identity this index cannot hold,
164/// because it names another entry tier or type rules, restores nothing into it.
165///
166/// A header that fails to parse leaves `index` untouched. A failure later in the record
167/// stream calls [`Index::clear_content`]: every mutation after `prepare_content_analysis`
168/// is confined to the content tier, and a late miss must not leave a partial one. An
169/// outside caller that already holds content and then passes a corrupt sidecar therefore
170/// sees that tier wiped. In-crate loads start with `content: None`.
171pub fn load_content_cache(
172    index: &mut Index,
173    wanted: &ContentTierIdentity,
174    path: &Path,
175) -> Result<ContentCacheLoad> {
176    let current = index.content_identity(wanted.analysis);
177    let Some(admission) = current.admit(wanted).filter(|_| wanted.analysis.is_enabled()) else {
178        return Ok(ContentCacheLoad::default());
179    };
180    let wanted = admission.identity();
181    let metadata = match fs::metadata(path) {
182        Ok(metadata) => metadata,
183        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
184            return Ok(ContentCacheLoad::default());
185        }
186        Err(error) => return Err(Error::io(path, error)),
187    };
188    if metadata.len() > MAX_CACHE_BYTES {
189        return Ok(ContentCacheLoad::default());
190    }
191    let read_started = crate::counters::enabled().then(std::time::Instant::now);
192    let image = fs::read(path).map_err(|error| Error::io(path, error))?;
193    crate::counters::add_elapsed(read_started, |counts, elapsed| {
194        counts.content_sidecar_read_us = counts.content_sidecar_read_us.saturating_add(elapsed);
195    });
196    let mut timings = crate::counters::enabled().then(RestoreTimings::default);
197    let parse_started = timings.as_ref().map(|_| Instant::now());
198    let Some(mut stream) = parse_header(&image, index.root_path(), wanted) else {
199        if let (Some(timings), Some(started)) = (&mut timings, parse_started) {
200            timings.add_parse(started);
201        }
202        if let Some(timings) = timings {
203            timings.publish();
204        }
205        return Ok(ContentCacheLoad::default());
206    };
207    if let (Some(timings), Some(started)) = (&mut timings, parse_started) {
208        timings.add_parse(started);
209    }
210    index.prepare_content_analysis(AnalysisRequest {
211        profile: wanted.analysis,
212        ..AnalysisRequest::default()
213    });
214    // Keyed by hash rather than by order. This map is only ever drained by lookup —
215    // nothing iterates it — so its ordering was never observable, and ordering a
216    // `PathBuf` costs more than it looks: `Ord` walks components, so building a tree
217    // pays about log2(n) comparisons per insert and each one walks the paths again.
218    //
219    // Worth about 3%, not more. A flat callgrind profile of a warm 14,542-file open
220    // shows `compare_components` at 34% of instructions and it is tempting to read that
221    // as this map; the caller tree says this map's sort is about 0.9%, and the measured
222    // change was −3.03% [−4.62%, −1.62%]. The 34% was mostly
223    // `classify::classify_path_with_prefix`. Cache-only restore now walks file
224    // identities without classifying (`restore_analysis_candidates`); the sidecar
225    // already stores the classification that would have replaced the live result.
226    let candidates_started = crate::counters::enabled().then(std::time::Instant::now);
227    let (mut candidates, visited) = index.restore_analysis_candidates(wanted.analysis);
228    crate::counters::add_elapsed(candidates_started, |counts, elapsed| {
229        counts.content_sidecar_candidates_us =
230            counts.content_sidecar_candidates_us.saturating_add(elapsed);
231    });
232    // Completeness is files visited, not unique `PathBuf` keys. Trailing-separator
233    // aliases collapse in the map and would otherwise shrink the denominator.
234    let mut loaded =
235        ContentCacheLoad { usable: true, candidates: visited, ..ContentCacheLoad::default() };
236    for _ in 0..stream.remaining {
237        let decode_started = timings.as_ref().map(|_| Instant::now());
238        let Some((relative_path, analysis)) = read_record(&mut stream) else {
239            if let (Some(timings), Some(started)) = (&mut timings, decode_started) {
240                timings.add_parse(started);
241            }
242            index.clear_content();
243            if let Some(timings) = timings {
244                timings.publish();
245            }
246            return Ok(ContentCacheLoad::default());
247        };
248        if let (Some(timings), Some(started)) = (&mut timings, decode_started) {
249            timings.add_parse(started);
250        }
251        // Apply includes the HashMap remove and fingerprint compare so the restore
252        // timers cover the whole load. Decode stays in parse. exp-109's apply 63.3%
253        // wrapped the whole loop; exp-120 used the narrower apply-only bucket that
254        // arrived with e667b739, which left the remove and fingerprint compare between
255        // decode and apply. "post-H112" named the wrong change: H112 is exp-109, the
256        // measurement with the wide bucket.
257        // Failed analysis is never a reusable cache hit; leave it pending for retry.
258        let apply_started = timings.as_ref().map(|_| Instant::now());
259        match candidates.remove(&relative_path) {
260            Some(candidate)
261                if candidate.attrs.fingerprint() == analysis.value().fingerprint
262                    && analysis.value().is_reusable() =>
263            {
264                let coverage_exclusion = analysis_outcomes(analysis.value()).any(|outcome| {
265                    !matches!(outcome, CoverageReason::Analyzed | CoverageReason::Binary)
266                });
267                let bytes = analysis.value().bytes;
268                match index.apply_restored_analysis(candidate, analysis) {
269                    AnalysisApplyOutcome::Applied => {
270                        loaded.hits = loaded.hits.saturating_add(1);
271                        loaded.bytes = loaded.bytes.saturating_add(bytes);
272                        loaded.coverage_exclusions = loaded
273                            .coverage_exclusions
274                            .saturating_add(u64::from(coverage_exclusion));
275                    }
276                    AnalysisApplyOutcome::Stale => {
277                        loaded.stale = loaded.stale.saturating_add(1);
278                    }
279                }
280            }
281            Some(_) | None => loaded.stale = loaded.stale.saturating_add(1),
282        }
283        if let (Some(timings), Some(started)) = (&mut timings, apply_started) {
284            timings.add_apply(started);
285        }
286    }
287    if !stream.reader.is_empty() {
288        index.clear_content();
289        if let Some(timings) = timings {
290            timings.publish();
291        }
292        return Ok(ContentCacheLoad::default());
293    }
294    let rebuild_started = timings.as_ref().map(|_| Instant::now());
295    index.rebuild_content_rollups();
296    if let (Some(timings), Some(started)) = (&mut timings, rebuild_started) {
297        timings.add_apply(started);
298    }
299    if let Some(timings) = timings {
300        timings.publish();
301    }
302    // The sidecar's mtime names the container write, not the content observation.
303    index.set_content_tier_state(crate::Source::Cached, crate::Freshness::Stale, None);
304    Ok(loaded)
305}
306
307/// The size of the content sidecar at `path`, when fdu wrote the file there.
308///
309/// Decided by the magic alone, deliberately not by the integrity check: a truncated or
310/// older-format sidecar is still fdu's, and it goes when its snapshot goes. Only a regular
311/// file is opened, so a symbolic link is never followed out of the cache directory.
312pub(crate) fn content_sidecar_bytes(path: &Path) -> Result<Option<u64>> {
313    let metadata = match fs::symlink_metadata(path) {
314        Ok(metadata) => metadata,
315        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
316        Err(error) => return Err(Error::io(path, error)),
317    };
318    if !metadata.file_type().is_file() {
319        return Ok(None);
320    }
321    let mut file = match fs::File::open(path) {
322        Ok(file) => file,
323        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
324        Err(error) => return Err(Error::io(path, error)),
325    };
326    let mut magic = [0u8; MAGIC.len()];
327    match file.read_exact(&mut magic) {
328        Ok(()) => Ok((&magic == MAGIC).then_some(metadata.len())),
329        Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None),
330        Err(error) => Err(Error::io(path, error)),
331    }
332}
333
334/// Identify the content sidecar at `path` by its contents, or return `None` when no sidecar
335/// fdu wrote is there.
336///
337/// Mirrors [`crate::snapshot::identify`]. The magic alone decides whether the file is fdu's
338/// sidecar, as [`content_sidecar_bytes`] decides it for pairing and clearing; the rest of
339/// the header decides whether this build can serve it. Every sidecar format puts its
340/// version after the magic, and format 5 put the engine fingerprint after the version, so
341/// a sidecar from another release is recognized as stale rather than mistaken for a
342/// foreign file. Only the bounded header and the trailer are read, never the records, and
343/// only a regular file is opened, so a symbolic link is never followed out of the cache
344/// directory.
345pub(crate) fn identify_sidecar(path: &Path) -> Result<Option<crate::cache::ContentStatus>> {
346    let metadata = match fs::symlink_metadata(path) {
347        Ok(metadata) => metadata,
348        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
349        Err(error) => return Err(Error::io(path, error)),
350    };
351    if !metadata.file_type().is_file() {
352        return Ok(None);
353    }
354    let mut file = match fs::File::open(path) {
355        Ok(file) => file,
356        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
357        Err(error) => return Err(Error::io(path, error)),
358    };
359    let state = identify_sidecar_contents(&mut file).map_err(|error| Error::io(path, error))?;
360    Ok(state.map(|state| crate::cache::ContentStatus { bytes: metadata.len(), state }))
361}
362
363/// Classify a sidecar from its header and trailer, or `None` when it lacks the sidecar
364/// magic. Only an I/O failure is an error; every malformed byte is an answer.
365fn identify_sidecar_contents(
366    file: &mut fs::File,
367) -> io::Result<Option<crate::cache::ContentState>> {
368    use crate::cache::{ContentInfo, ContentState, StaleReason};
369
370    let stale = |reason| Ok(Some(ContentState::Stale(reason)));
371    let trailer_intact = sidecar_trailer_intact(file)?;
372    file.seek(SeekFrom::Start(0))?;
373    let mut header = Vec::new();
374    if !read_more(file, &mut header, MAGIC.len())? || header != MAGIC {
375        return Ok(None);
376    }
377    if !read_more(file, &mut header, 4)? {
378        return stale(StaleReason::Unreadable);
379    }
380    let version = u32::from_le_bytes(header[MAGIC.len()..].try_into().expect("four bytes"));
381    match version.cmp(&FORMAT_VERSION) {
382        std::cmp::Ordering::Less => return stale(StaleReason::OlderFormat { version }),
383        std::cmp::Ordering::Greater => return stale(StaleReason::NewerFormat { version }),
384        std::cmp::Ordering::Equal => {}
385    }
386    if !read_more(file, &mut header, 8)? {
387        return stale(StaleReason::Unreadable);
388    }
389    let engine = u64::from_le_bytes(header[MAGIC.len() + 4..].try_into().expect("eight bytes"));
390    if engine != crate::snapshot::engine_fingerprint() {
391        return stale(StaleReason::OtherEngine);
392    }
393    // Truncation removes the tail and leaves the header readable, so a header-only check
394    // would call a half-written sidecar current.
395    if !trailer_intact || !read_sidecar_header(file, &mut header)? {
396        return stale(StaleReason::Unreadable);
397    }
398    let parsed = (|| {
399        let mut reader = Reader::new(header.get(MAGIC.len() + 4 + 8..)?);
400        if reader.u8()? != crate::snapshot::path_encoding() {
401            return None;
402        }
403        let identity = read_identity(&mut reader, engine)?;
404        reader.os_string()?;
405        let records = reader.u64()?;
406        (records <= MAX_RECORDS && reader.is_empty()).then_some(ContentInfo { identity, records })
407    })();
408    Ok(Some(parsed.map_or(ContentState::Stale(StaleReason::Unreadable), ContentState::Current)))
409}
410
411/// Read the header fields after a format-5 prologue into `header`: each field's length is
412/// bounded before it is read. `false` when the file ends first or a bound is exceeded.
413fn read_sidecar_header(file: &mut fs::File, header: &mut Vec<u8>) -> io::Result<bool> {
414    // The path encoding, the entry tier (which holds the type rules), the analyzer set, the
415    // options fingerprint, and the analyzer count.
416    if !read_more(file, header, 1 + ENTRY_TIER_BYTES + 1 + 8 + 1)? {
417        return Ok(false);
418    }
419    let analyzers = usize::from(*header.last().expect("the analyzer count"));
420    if analyzers > MAX_ANALYZERS {
421        return Ok(false);
422    }
423    for _ in 0..analyzers {
424        let Some(length) = read_length(file, header, MAX_ANALYZER_ID_BYTES)? else {
425            return Ok(false);
426        };
427        if !read_more(file, header, length + 2)? {
428            return Ok(false);
429        }
430    }
431    let Some(root) = read_length(file, header, MAX_PATH_BYTES)? else { return Ok(false) };
432    Ok(read_more(file, header, root)? && read_more(file, header, 8)?)
433}
434
435/// Read a four-byte length into `header` and return it, when the file holds it and it is at
436/// most `max`.
437fn read_length(file: &mut fs::File, header: &mut Vec<u8>, max: usize) -> io::Result<Option<usize>> {
438    if !read_more(file, header, 4)? {
439        return Ok(None);
440    }
441    let bytes = header[header.len() - 4..].try_into().expect("four bytes");
442    Ok(usize::try_from(u32::from_le_bytes(bytes)).ok().filter(|length| *length <= max))
443}
444
445/// Append exactly `count` more bytes of `file` to `buffer`, or return `false` when it ends
446/// first.
447fn read_more(file: &mut fs::File, buffer: &mut Vec<u8>, count: usize) -> io::Result<bool> {
448    let start = buffer.len();
449    buffer.resize(start + count, 0);
450    match file.read_exact(&mut buffer[start..]) {
451        Ok(()) => Ok(true),
452        Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => {
453            buffer.truncate(start);
454            Ok(false)
455        }
456        Err(error) => Err(error),
457    }
458}
459
460/// Whether a file ends with the sidecar trailer, which is what a truncated write destroys.
461fn sidecar_trailer_intact(file: &mut fs::File) -> io::Result<bool> {
462    let footer = u64::try_from(CHECKSUM_BYTES + TRAILER.len()).expect("a small footer");
463    if file.metadata()?.len() < footer {
464        return Ok(false);
465    }
466    file.seek(SeekFrom::End(-i64::try_from(TRAILER.len()).expect("a small trailer")))?;
467    let mut trailer = [0u8; TRAILER.len()];
468    match file.read_exact(&mut trailer) {
469        Ok(()) => Ok(&trailer == TRAILER),
470        Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(false),
471        Err(error) => Err(error),
472    }
473}
474
475fn put_record(buffer: &mut Vec<u8>, path: &Path, record: &FileAnalysis) -> Result<()> {
476    crate::snapshot::put_os_str(buffer, path.as_os_str())?;
477    put_fingerprint(buffer, record.fingerprint);
478    buffer.extend_from_slice(&record.bytes.to_le_bytes());
479    put_bounded_bytes(buffer, record.detection.file_type.as_str().as_bytes(), MAX_TYPE_BYTES)?;
480    buffer.push(family_code(record.detection.family));
481    buffer.push(source_code(record.detection.source));
482    buffer.push(confidence_code(record.detection.confidence));
483    buffer.push(flags_code(record.detection.flags));
484    put_outcome(buffer, record.lines, put_basic_metrics);
485    put_optional_outcome(buffer, record.code, put_code_metrics);
486    put_optional_outcome(buffer, record.words, put_word_metrics);
487    put_bounded_bytes(buffer, record.error.as_deref().unwrap_or("").as_bytes(), MAX_ERROR_BYTES)
488}
489
490/// Write the content tier identity after the prologue and path encoding: the entry tier's
491/// fixed-width fields, which hold the type rules, then the analyzer set, the options
492/// fingerprint, and the analyzers.
493fn put_identity(buffer: &mut Vec<u8>, identity: &ContentTierIdentity) -> Result<()> {
494    buffer.extend_from_slice(&identity.entries.encode());
495    put_profile(buffer, identity.analysis);
496    buffer.extend_from_slice(&identity.provenance.options_fingerprint.0.to_le_bytes());
497    put_analyzers(buffer, &identity.provenance.analyzers)
498}
499
500/// Read what [`put_identity`] wrote, under the prologue's `engine` fingerprint.
501fn read_identity(reader: &mut Reader<'_>, engine: u64) -> Option<ContentTierIdentity> {
502    let entries =
503        EntryTierIdentity::decode(engine, reader.take(ENTRY_TIER_BYTES)?.try_into().ok()?)?;
504    Some(ContentTierIdentity {
505        entries,
506        analysis: read_profile(reader.u8()?)?,
507        provenance: AnalyzerProvenance {
508            options_fingerprint: super::OptionsFingerprint(reader.u64()?),
509            analyzers: read_analyzers(reader)?,
510        },
511    })
512}
513
514/// Header-only sidecar parse. Records are decoded one at a time by [`read_record`].
515struct RecordStream<'a, 'identity> {
516    reader: Reader<'a>,
517    remaining: u64,
518    admission: crate::stored_state::ContentAdmission<'identity>,
519}
520
521/// Parse a sidecar for `root` whose content tier identity equals `wanted`.
522fn parse_header<'a, 'identity>(
523    image: &'a [u8],
524    root: &Path,
525    wanted: &'identity ContentTierIdentity,
526) -> Option<RecordStream<'a, 'identity>> {
527    let payload = integrity_payload(image)?;
528    let mut reader = Reader::new(payload.get(MAGIC.len()..)?);
529    if reader.u32()? != FORMAT_VERSION {
530        return None;
531    }
532    let engine = reader.u64()?;
533    if reader.u8()? != crate::snapshot::path_encoding() {
534        return None;
535    }
536    // Records of any other identity answer another request: a miss, whether they came
537    // from another engine or entry tier, which holds their type rules, or another analyzer
538    // set, version, or option.
539    let identity = read_identity(&mut reader, engine)?;
540    let admission = wanted.admit(&identity)?;
541    if reader.os_string()?.as_os_str() != root.as_os_str() {
542        return None;
543    }
544    let count = reader.u64()?;
545    if count > MAX_RECORDS {
546        return None;
547    }
548    Some(RecordStream { reader, remaining: count, admission })
549}
550
551fn read_record<'identity>(
552    stream: &mut RecordStream<'_, 'identity>,
553) -> Option<(PathBuf, crate::stored_state::AdmittedRecord<'identity>)> {
554    let relative_path = PathBuf::from(stream.reader.os_string()?);
555    if !record_path_stays_inside_root(&relative_path) {
556        return None;
557    }
558    let fingerprint = read_fingerprint(&mut stream.reader)?;
559    let bytes = stream.reader.u64()?;
560    let file_type = String::from_utf8(stream.reader.bytes(MAX_TYPE_BYTES)?).ok()?;
561    if file_type.is_empty() {
562        return None;
563    }
564    let detection = ContentDetection {
565        file_type: FileTypeId::from_cache(file_type),
566        family: read_family(stream.reader.u8()?)?,
567        source: read_source(stream.reader.u8()?)?,
568        confidence: read_confidence(stream.reader.u8()?)?,
569        flags: read_flags(stream.reader.u8()?)?,
570    };
571    let lines = read_outcome(&mut stream.reader, read_basic_metrics)?;
572    let code = read_optional_outcome(&mut stream.reader, read_code_metrics)?;
573    let words = read_optional_outcome(&mut stream.reader, read_word_metrics)?;
574    let error = String::from_utf8(stream.reader.bytes(MAX_ERROR_BYTES)?).ok()?;
575    stream.remaining = stream.remaining.saturating_sub(1);
576    Some((
577        relative_path,
578        stream.admission.record(FileAnalysis {
579            fingerprint,
580            bytes,
581            detection,
582            lines,
583            code,
584            words,
585            error: (!error.is_empty()).then_some(error),
586        })?,
587    ))
588}
589
590/// Whether a sidecar record's path is relative and never ascends, so it names an entry
591/// under the root the sidecar claims.
592///
593/// The sidecar is untrusted input: anything on disk can have written it. So the question
594/// is asked of components, where every one must be `Normal` or `CurDir`, rather than of
595/// `is_absolute`, which answers it wrongly. `..` is not absolute on any platform, and on
596/// Windows neither is a rooted path with no drive (`\x`) nor a drive-relative one
597/// (`C:x`), yet each names a path outside the root.
598///
599/// Private and stated here rather than borrowed from the index, whose own path
600/// validation is free to change shape: this guard's contract is the untrusted image.
601fn record_path_stays_inside_root(path: &Path) -> bool {
602    path.components().all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
603}
604
605fn integrity_payload(image: &[u8]) -> Option<&[u8]> {
606    let footer = CHECKSUM_BYTES.checked_add(TRAILER.len())?;
607    if image.len() < MAGIC.len() + footer || image.get(..MAGIC.len())? != MAGIC {
608        return None;
609    }
610    let payload_len = image.len().checked_sub(footer)?;
611    if image.get(payload_len + CHECKSUM_BYTES..)? != TRAILER {
612        return None;
613    }
614    let expected_checksum =
615        u32::from_le_bytes(image.get(payload_len..payload_len + CHECKSUM_BYTES)?.try_into().ok()?);
616    let payload = image.get(..payload_len)?;
617    (crate::snapshot::crc32c(payload) == expected_checksum).then_some(payload)
618}
619
620fn put_fingerprint(buffer: &mut Vec<u8>, value: Fingerprint) {
621    buffer.extend_from_slice(&value.size.to_le_bytes());
622    buffer.extend_from_slice(&value.mtime_ns.to_le_bytes());
623    buffer.extend_from_slice(&value.ctime_ns.to_le_bytes());
624    buffer.extend_from_slice(&value.inode.to_le_bytes());
625    buffer.extend_from_slice(&value.dev.to_le_bytes());
626}
627
628fn read_fingerprint(reader: &mut Reader<'_>) -> Option<Fingerprint> {
629    Some(Fingerprint {
630        size: reader.u64()?,
631        mtime_ns: reader.i64()?,
632        ctime_ns: reader.i64()?,
633        inode: reader.u64()?,
634        dev: reader.u64()?,
635    })
636}
637
638fn put_basic_metrics(buffer: &mut Vec<u8>, value: BasicMetrics) {
639    for metric in [value.physical_lines, value.blank_lines, value.nonblank_lines, value.raw_words] {
640        buffer.extend_from_slice(&metric.to_le_bytes());
641    }
642}
643
644fn read_basic_metrics(reader: &mut Reader<'_>) -> Option<BasicMetrics> {
645    Some(BasicMetrics {
646        physical_lines: reader.u64()?,
647        blank_lines: reader.u64()?,
648        nonblank_lines: reader.u64()?,
649        raw_words: reader.u64()?,
650    })
651}
652
653fn put_code_metrics(buffer: &mut Vec<u8>, value: CodeMetrics) {
654    for metric in [value.code_lines, value.comment_lines, value.code_blank_lines] {
655        buffer.extend_from_slice(&metric.to_le_bytes());
656    }
657}
658
659fn read_code_metrics(reader: &mut Reader<'_>) -> Option<CodeMetrics> {
660    Some(CodeMetrics {
661        code_lines: reader.u64()?,
662        comment_lines: reader.u64()?,
663        code_blank_lines: reader.u64()?,
664    })
665}
666
667fn put_word_metrics(buffer: &mut Vec<u8>, value: WordMetrics) {
668    for metric in [
669        value.paragraphs,
670        value.visible_words,
671        value.logical_word_stats.wide_chars,
672        value.logical_word_stats.nonwide_tokens,
673        value.logical_word_stats.nonwide_chars,
674        value.visible_logical_word_stats.wide_chars,
675        value.visible_logical_word_stats.nonwide_tokens,
676        value.visible_logical_word_stats.nonwide_chars,
677    ] {
678        buffer.extend_from_slice(&metric.to_le_bytes());
679    }
680}
681
682fn read_word_metrics(reader: &mut Reader<'_>) -> Option<WordMetrics> {
683    Some(WordMetrics {
684        paragraphs: reader.u64()?,
685        visible_words: reader.u64()?,
686        logical_word_stats: LogicalWordStats {
687            wide_chars: reader.u64()?,
688            nonwide_tokens: reader.u64()?,
689            nonwide_chars: reader.u64()?,
690        },
691        visible_logical_word_stats: LogicalWordStats {
692            wide_chars: reader.u64()?,
693            nonwide_tokens: reader.u64()?,
694            nonwide_chars: reader.u64()?,
695        },
696    })
697}
698
699fn put_outcome<T: Copy>(
700    buffer: &mut Vec<u8>,
701    outcome: AnalyzerOutcome<T>,
702    put_value: fn(&mut Vec<u8>, T),
703) {
704    buffer.push(coverage_code(outcome.coverage()));
705    if let Some(value) = outcome.value() {
706        put_value(buffer, value);
707    }
708}
709
710fn read_outcome<T>(
711    reader: &mut Reader<'_>,
712    read_value: fn(&mut Reader<'_>) -> Option<T>,
713) -> Option<AnalyzerOutcome<T>> {
714    let coverage = read_coverage(reader.u8()?)?;
715    let value = if coverage == CoverageReason::Analyzed { Some(read_value(reader)?) } else { None };
716    AnalyzerOutcome::from_parts(coverage, value)
717}
718
719fn put_optional_outcome<T: Copy>(
720    buffer: &mut Vec<u8>,
721    outcome: Option<AnalyzerOutcome<T>>,
722    put_value: fn(&mut Vec<u8>, T),
723) {
724    buffer.push(u8::from(outcome.is_some()));
725    if let Some(outcome) = outcome {
726        put_outcome(buffer, outcome, put_value);
727    }
728}
729
730#[expect(
731    clippy::option_option,
732    reason = "Outer None rejects malformed bytes; inner None is an absent unit."
733)]
734fn read_optional_outcome<T>(
735    reader: &mut Reader<'_>,
736    read_value: fn(&mut Reader<'_>) -> Option<T>,
737) -> Option<Option<AnalyzerOutcome<T>>> {
738    match reader.u8()? {
739        0 => Some(None),
740        1 => Some(Some(read_outcome(reader, read_value)?)),
741        _ => None,
742    }
743}
744
745fn analysis_outcomes(analysis: &FileAnalysis) -> impl Iterator<Item = CoverageReason> + '_ {
746    std::iter::once(analysis.lines.coverage())
747        .chain(analysis.code.map(|outcome| outcome.coverage()))
748        .chain(analysis.words.map(|outcome| outcome.coverage()))
749}
750
751fn put_analyzers(buffer: &mut Vec<u8>, analyzers: &[(AnalyzerId, AnalyzerVersion)]) -> Result<()> {
752    let count = u8::try_from(analyzers.len())
753        .map_err(|_| Error::Snapshot("too many content analyzers".into()))?;
754    buffer.push(count);
755    for (id, version) in analyzers {
756        put_bounded_bytes(buffer, id.0.as_bytes(), MAX_ANALYZER_ID_BYTES)?;
757        buffer.extend_from_slice(&version.0.to_le_bytes());
758    }
759    Ok(())
760}
761
762fn read_analyzers(reader: &mut Reader<'_>) -> Option<Vec<(AnalyzerId, AnalyzerVersion)>> {
763    let count = usize::from(reader.u8()?);
764    if count > MAX_ANALYZERS {
765        return None;
766    }
767    let mut analyzers = Vec::with_capacity(count);
768    for _ in 0..count {
769        let id = String::from_utf8(reader.bytes(MAX_ANALYZER_ID_BYTES)?).ok()?;
770        let known = match id.as_str() {
771            "content-basic-v1" => super::CONTENT_BASIC,
772            "code-sloc-v1" => super::CODE_SLOC,
773            "text-logical-v1" => super::TEXT_LOGICAL,
774            "markdown-prose-v1" => super::MARKDOWN_PROSE,
775            _ => return None,
776        };
777        analyzers.push((known, AnalyzerVersion(reader.u16()?)));
778    }
779    Some(analyzers)
780}
781
782fn put_bounded_bytes(buffer: &mut Vec<u8>, bytes: &[u8], max: usize) -> Result<()> {
783    if bytes.len() > max {
784        return Err(Error::Snapshot("content sidecar string exceeds limit".into()));
785    }
786    let length = u32::try_from(bytes.len())
787        .map_err(|_| Error::Snapshot("content sidecar string length overflow".into()))?;
788    buffer.extend_from_slice(&length.to_le_bytes());
789    buffer.extend_from_slice(bytes);
790    Ok(())
791}
792
793fn put_profile(buffer: &mut Vec<u8>, profile: AnalysisSet) {
794    buffer.push(profile.bits());
795}
796
797fn read_profile(code: u8) -> Option<AnalysisSet> {
798    AnalysisSet::from_bits(code)
799}
800
801fn family_code(value: ContentFamily) -> u8 {
802    match value {
803        ContentFamily::Code => 0,
804        ContentFamily::Prose => 1,
805        ContentFamily::Markup => 2,
806        ContentFamily::Data => 3,
807        ContentFamily::Binary => 4,
808        ContentFamily::Unknown => 5,
809    }
810}
811
812fn read_family(code: u8) -> Option<ContentFamily> {
813    match code {
814        0 => Some(ContentFamily::Code),
815        1 => Some(ContentFamily::Prose),
816        2 => Some(ContentFamily::Markup),
817        3 => Some(ContentFamily::Data),
818        4 => Some(ContentFamily::Binary),
819        5 => Some(ContentFamily::Unknown),
820        _ => None,
821    }
822}
823
824fn source_code(value: DetectionSource) -> u8 {
825    match value {
826        DetectionSource::ExactFilename => 0,
827        DetectionSource::CompoundExtension => 1,
828        DetectionSource::Extension => 2,
829        DetectionSource::Shebang => 3,
830        DetectionSource::ContentProbe => 4,
831        DetectionSource::Unknown => 5,
832        DetectionSource::Modeline => 6,
833        DetectionSource::AmbiguousContent => 7,
834        DetectionSource::FormatSignature => 8,
835    }
836}
837
838fn read_source(code: u8) -> Option<DetectionSource> {
839    match code {
840        0 => Some(DetectionSource::ExactFilename),
841        1 => Some(DetectionSource::CompoundExtension),
842        2 => Some(DetectionSource::Extension),
843        3 => Some(DetectionSource::Shebang),
844        4 => Some(DetectionSource::ContentProbe),
845        5 => Some(DetectionSource::Unknown),
846        6 => Some(DetectionSource::Modeline),
847        7 => Some(DetectionSource::AmbiguousContent),
848        8 => Some(DetectionSource::FormatSignature),
849        _ => None,
850    }
851}
852
853fn flags_code(value: ClassificationFlags) -> u8 {
854    u8::from(value.generated)
855        | (u8::from(value.vendored) << 1)
856        | (u8::from(value.documentation) << 2)
857}
858
859fn read_flags(code: u8) -> Option<ClassificationFlags> {
860    (code & !0b111 == 0).then_some(ClassificationFlags {
861        generated: code & 0b001 != 0,
862        vendored: code & 0b010 != 0,
863        documentation: code & 0b100 != 0,
864    })
865}
866
867fn confidence_code(value: DetectionConfidence) -> u8 {
868    match value {
869        DetectionConfidence::Certain => 0,
870        DetectionConfidence::High => 1,
871        DetectionConfidence::Heuristic => 2,
872    }
873}
874
875fn read_confidence(code: u8) -> Option<DetectionConfidence> {
876    match code {
877        0 => Some(DetectionConfidence::Certain),
878        1 => Some(DetectionConfidence::High),
879        2 => Some(DetectionConfidence::Heuristic),
880        _ => None,
881    }
882}
883
884fn coverage_code(value: CoverageReason) -> u8 {
885    match value {
886        CoverageReason::Analyzed => 0,
887        CoverageReason::Binary => 1,
888        CoverageReason::InvalidUtf8 => 2,
889        CoverageReason::UnsupportedEncoding => 3,
890        CoverageReason::Unsupported => 4,
891        CoverageReason::IoError => 5,
892        CoverageReason::ChangedDuringRead => 6,
893    }
894}
895
896fn read_coverage(code: u8) -> Option<CoverageReason> {
897    match code {
898        0 => Some(CoverageReason::Analyzed),
899        1 => Some(CoverageReason::Binary),
900        2 => Some(CoverageReason::InvalidUtf8),
901        3 => Some(CoverageReason::UnsupportedEncoding),
902        4 => Some(CoverageReason::Unsupported),
903        5 => Some(CoverageReason::IoError),
904        6 => Some(CoverageReason::ChangedDuringRead),
905        _ => None,
906    }
907}
908
909struct Reader<'a> {
910    remaining: &'a [u8],
911}
912
913impl<'a> Reader<'a> {
914    fn new(remaining: &'a [u8]) -> Self {
915        Self { remaining }
916    }
917
918    fn take(&mut self, count: usize) -> Option<&'a [u8]> {
919        let (value, rest) = self.remaining.split_at_checked(count)?;
920        self.remaining = rest;
921        Some(value)
922    }
923
924    fn u8(&mut self) -> Option<u8> {
925        self.take(1).map(|value| value[0])
926    }
927
928    fn u16(&mut self) -> Option<u16> {
929        Some(u16::from_le_bytes(self.take(2)?.try_into().ok()?))
930    }
931
932    fn u32(&mut self) -> Option<u32> {
933        Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
934    }
935
936    fn u64(&mut self) -> Option<u64> {
937        Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
938    }
939
940    fn i64(&mut self) -> Option<i64> {
941        Some(i64::from_le_bytes(self.take(8)?.try_into().ok()?))
942    }
943
944    fn bytes(&mut self, max: usize) -> Option<Vec<u8>> {
945        let length = usize::try_from(self.u32()?).ok()?;
946        (length <= max).then(|| self.take(length).map(<[u8]>::to_vec)).flatten()
947    }
948
949    fn os_string(&mut self) -> Option<OsString> {
950        let bytes = self.bytes(MAX_PATH_BYTES)?;
951        #[cfg(unix)]
952        {
953            Some(decode_os_string(&bytes))
954        }
955        #[cfg(not(unix))]
956        {
957            decode_os_string(&bytes)
958        }
959    }
960
961    fn is_empty(&self) -> bool {
962        self.remaining.is_empty()
963    }
964}
965
966#[cfg(unix)]
967fn decode_os_string(bytes: &[u8]) -> OsString {
968    use std::os::unix::ffi::OsStringExt;
969    OsString::from_vec(bytes.to_vec())
970}
971
972#[cfg(windows)]
973fn decode_os_string(bytes: &[u8]) -> Option<OsString> {
974    use std::os::windows::ffi::OsStringExt;
975    // `usize::is_multiple_of` is stable since 1.87 and this crate's MSRV is 1.85, so a
976    // Windows user on the declared minimum could not build it. Nothing caught that
977    // because the MSRV job runs on ubuntu, where this function does not exist.
978    if bytes.len() % 2 != 0 {
979        return None;
980    }
981    let units = bytes
982        .chunks_exact(2)
983        .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
984        .collect::<Vec<_>>();
985    Some(OsString::from_wide(&units))
986}
987
988#[cfg(not(any(unix, windows)))]
989fn decode_os_string(bytes: &[u8]) -> Option<OsString> {
990    String::from_utf8(bytes.to_vec()).ok().map(OsString::from)
991}
992
993#[cfg(test)]
994mod tests {
995    use super::*;
996    use std::fs;
997
998    use crate::scan::ScanConfig;
999
1000    fn analyzed_index() -> (tempfile::TempDir, Index, AnalysisRequest) {
1001        let root = tempfile::tempdir().expect("root");
1002        fs::write(root.path().join("notes.md"), "<!-- @generated -->\none two\n").expect("write");
1003        let (mut index, _) =
1004            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1005        let request = AnalysisRequest {
1006            profile: AnalysisSet::NONE.with_lines(),
1007            ..AnalysisRequest::default()
1008        };
1009        super::super::analyze_index(&mut index, request);
1010        (root, index, request)
1011    }
1012
1013    /// A prose file and a code file, so a `code`-only request is genuinely narrower than
1014    /// `all` rather than accidentally equivalent on this fixture.
1015    ///
1016    /// The sidecar lives outside the scanned tree: writing it inside would make the cache
1017    /// file itself an unanalyzed candidate and quietly defeat any "opened no file" claim.
1018    fn containment_fixture(profile: AnalysisSet) -> (tempfile::TempDir, tempfile::TempDir, Index) {
1019        let root = tempfile::tempdir().expect("root");
1020        let cache_dir = tempfile::tempdir().expect("cache dir");
1021        fs::write(root.path().join("notes.md"), "<!-- @generated -->\none two\n").expect("write");
1022        fs::write(root.path().join("main.rs"), "fn main() {\n    // hi\n}\n").expect("write");
1023        let (mut index, _) =
1024            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1025        super::super::analyze_index(&mut index, request_for(profile));
1026        (root, cache_dir, index)
1027    }
1028
1029    fn request_for(profile: AnalysisSet) -> AnalysisRequest {
1030        AnalysisRequest { profile, ..AnalysisRequest::default() }
1031    }
1032
1033    /// Load the sidecar at `cache` for `request`'s identity in `index`.
1034    fn load(index: &mut Index, request: AnalysisRequest, cache: &Path) -> ContentCacheLoad {
1035        let wanted = index.content_identity(request.profile);
1036        load_content_cache(index, &wanted, cache).expect("load")
1037    }
1038
1039    #[test]
1040    fn fractional_record_durations_are_converted_after_accumulation() {
1041        let mut total = Duration::ZERO;
1042        for _ in 0..1_000 {
1043            add_duration(&mut total, Duration::from_nanos(900));
1044        }
1045
1046        assert_eq!(duration_micros(total), 900);
1047        assert_eq!(duration_micros(Duration::from_nanos(900)), 0);
1048    }
1049
1050    #[test]
1051    fn restored_sidecar_does_not_claim_its_container_mtime_as_observation_time() {
1052        let (root, analyzed, request) = analyzed_index();
1053        let cache_dir = tempfile::tempdir().expect("cache dir");
1054        let cache = cache_dir.path().join("content.cache");
1055        save_content_cache(&analyzed, &cache).expect("save");
1056        let (mut restored, _) =
1057            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1058
1059        let loaded = load(&mut restored, request, &cache);
1060
1061        assert!(loaded.usable && loaded.hits == 1, "{loaded:?}");
1062        let state =
1063            restored.content().and_then(super::super::ContentIndex::state).expect("tier state");
1064        assert_eq!(state.source, crate::Source::Cached);
1065        assert_eq!(state.observed_at_ns, None, "the sidecar stores no observation instant");
1066    }
1067
1068    /// Restore rebuilds nested directory roll-ups, not only the root.
1069    ///
1070    /// The probe content digest hashes the root roll-up; the `ContentIndex` unit test is
1071    /// the in-memory H115 check. This is the sidecar-boundary half of that claim.
1072    #[test]
1073    fn sidecar_restore_rebuilds_nested_directory_rollups() {
1074        let root = tempfile::tempdir().expect("root");
1075        let cache_dir = tempfile::tempdir().expect("cache dir");
1076        fs::create_dir_all(root.path().join("a/b")).expect("dirs");
1077        fs::write(root.path().join("notes.md"), "one two\n").expect("write");
1078        fs::write(root.path().join("a/keep.rs"), "fn keep() {}\n").expect("write");
1079        fs::write(root.path().join("a/b/nested.rs"), "fn nested() {}\n").expect("write");
1080        let request = request_for(AnalysisSet::NONE.with_lines());
1081        let (mut analyzed, _) =
1082            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1083        super::super::analyze_index(&mut analyzed, request);
1084        let cache = cache_dir.path().join("content.cache");
1085        save_content_cache(&analyzed, &cache).expect("save");
1086
1087        let (mut restored, _) =
1088            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1089        let loaded = load(&mut restored, request, &cache);
1090        assert!(loaded.usable && loaded.hits == 3, "{loaded:?}");
1091        for dir in ["", "a", "a/b"] {
1092            assert_eq!(
1093                restored.content_rollup(Path::new(dir)),
1094                analyzed.content_rollup(Path::new(dir)),
1095                "{dir:?} roll-up"
1096            );
1097        }
1098    }
1099
1100    #[test]
1101    fn operational_failure_in_a_valid_sidecar_is_not_reused() {
1102        let (root, analyzed, request) = analyzed_index();
1103        let store = tempfile::tempdir().expect("cache dir");
1104        let cache = store.path().join("content.cache");
1105        let identity = analyzed.content().and_then(|content| content.identity()).expect("identity");
1106        let mut record = analyzed
1107            .content()
1108            .and_then(|content| content.file(Path::new("notes.md")))
1109            .cloned()
1110            .expect("record");
1111        record.lines = AnalyzerOutcome::unavailable(CoverageReason::IoError);
1112        record.error = Some("injected failure".into());
1113
1114        // Build a checksummed sidecar directly: the ordinary writer correctly excludes
1115        // this record, while the reader must still distrust an image another process can
1116        // create or modify.
1117        let mut image = Vec::new();
1118        image.extend_from_slice(MAGIC);
1119        image.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
1120        image.extend_from_slice(&identity.entries.engine.to_le_bytes());
1121        image.push(crate::snapshot::path_encoding());
1122        put_identity(&mut image, identity).expect("identity");
1123        crate::snapshot::put_os_str(&mut image, analyzed.root_path().as_os_str()).expect("root");
1124        image.extend_from_slice(&1_u64.to_le_bytes());
1125        put_record(&mut image, Path::new("notes.md"), &record).expect("record");
1126        let checksum = crate::snapshot::crc32c(&image);
1127        image.extend_from_slice(&checksum.to_le_bytes());
1128        image.extend_from_slice(TRAILER);
1129        fs::write(&cache, image).expect("write sidecar");
1130
1131        let (mut restored, _) =
1132            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1133        let loaded = load(&mut restored, request, &cache);
1134        assert!(loaded.usable, "the sidecar identity itself remains usable");
1135        assert_eq!((loaded.hits, loaded.stale), (0, 1));
1136        assert!(
1137            restored
1138                .content()
1139                .expect("prepared content tier")
1140                .file(Path::new("notes.md"))
1141                .is_none()
1142        );
1143        assert_eq!(restored.pending_analysis_candidates(request).len(), 1);
1144    }
1145
1146    #[test]
1147    fn unsupported_encoding_outcomes_round_trip_through_the_sidecar() {
1148        let root = tempfile::tempdir().expect("root");
1149        let store = tempfile::tempdir().expect("cache dir");
1150        fs::write(root.path().join("wide.rs"), [0xff, 0xfe, b'f', 0, b'n', 0])
1151            .expect("UTF-16 fixture");
1152        let request = request_for(AnalysisSet::ALL);
1153        let (mut analyzed, _) =
1154            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1155        super::super::analyze_index(&mut analyzed, request);
1156        let expected = analyzed
1157            .content()
1158            .and_then(|content| content.file(Path::new("wide.rs")))
1159            .cloned()
1160            .expect("analyzed record");
1161        let cache = store.path().join("content.cache");
1162        save_content_cache(&analyzed, &cache).expect("save");
1163
1164        let (mut restored, _) =
1165            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1166        let loaded = load(&mut restored, request, &cache);
1167        assert!(loaded.usable && loaded.hits == 1, "{loaded:?}");
1168        let actual = restored
1169            .content()
1170            .and_then(|content| content.file(Path::new("wide.rs")))
1171            .expect("restored record");
1172        assert_eq!(actual, &expected);
1173        assert_eq!(actual.lines.coverage(), CoverageReason::UnsupportedEncoding);
1174        assert_eq!(
1175            actual.code.expect("code outcome").coverage(),
1176            CoverageReason::UnsupportedEncoding
1177        );
1178        assert_eq!(
1179            actual.words.expect("word outcome").coverage(),
1180            CoverageReason::UnsupportedEncoding
1181        );
1182    }
1183
1184    /// A sidecar serves exactly the analyzer set it was written for. A wider one holds
1185    /// metrics the narrower request did not ask for and would report them, and the wider
1186    /// set's label, as its answer; so it is a clean miss, and every file is read again
1187    /// under the narrower set, as a cold run reads it.
1188    #[test]
1189    fn a_wider_sidecar_is_a_clean_miss_for_a_narrower_request() {
1190        let (root, cache_dir, index) = containment_fixture(AnalysisSet::ALL);
1191        let cache = cache_dir.path().join("content.cache");
1192        save_content_cache(&index, &cache).expect("save");
1193
1194        let narrower = request_for(AnalysisSet::NONE.with_code());
1195        let (mut restored, _) =
1196            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1197        let loaded = load(&mut restored, narrower, &cache);
1198
1199        assert_eq!(loaded, ContentCacheLoad::default(), "a wider sidecar must miss");
1200        assert_eq!(
1201            restored.pending_analysis_candidates(narrower).len(),
1202            2,
1203            "a missed request reads every file"
1204        );
1205        assert!(restored.content().is_none(), "a miss leaves no content tier behind");
1206
1207        // The load above missed, so both indexes now analyze cold; this checks only that
1208        // the rejected wider sidecar left no residue. Counts alone would not show that, so
1209        // compare every displayed metric and per-unit outcome in each row and the total
1210        // with a fresh index that has never seen the wider sidecar.
1211        let (mut cold, _) =
1212            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("cold scan");
1213        super::super::analyze_index(&mut cold, narrower);
1214        super::super::analyze_index(&mut restored, narrower);
1215        let values = |index: &Index| {
1216            let query = crate::query::Query {
1217                views: vec![crate::query::ViewSpec::Types],
1218                ..crate::query::Query::default()
1219            };
1220            let report = crate::query::report(
1221                index,
1222                &crate::test_support::read_of(index, query),
1223                std::time::UNIX_EPOCH,
1224            )
1225            .expect("metric report");
1226            let crate::query::Section::Metrics { summary, .. } = &report.sections[0] else {
1227                panic!("expected type metrics");
1228            };
1229            std::iter::once(&summary.total)
1230                .chain(&summary.rows)
1231                .map(|row| {
1232                    (
1233                        row.id.clone(),
1234                        (
1235                            row.analysis,
1236                            row.metrics,
1237                            row.lines_coverage.clone(),
1238                            row.code_coverage.clone(),
1239                            row.words_coverage.clone(),
1240                        ),
1241                    )
1242                })
1243                .collect::<std::collections::BTreeMap<_, _>>()
1244        };
1245        assert_eq!(values(&restored), values(&cold), "wider cache history changes no row value");
1246    }
1247
1248    /// Neither a narrower sidecar nor one of an incomparable set answers a request: each
1249    /// lacks metrics the request needs or holds ones it did not ask for.
1250    #[test]
1251    fn another_analyzer_set_is_a_clean_miss() {
1252        let code = AnalysisSet::NONE.with_code();
1253        let words = AnalysisSet::NONE.with_words();
1254        for (stored, wanted) in [(code, AnalysisSet::ALL), (code, words), (words, code)] {
1255            let (root, cache_dir, index) = containment_fixture(stored);
1256            let cache = cache_dir.path().join("content.cache");
1257            save_content_cache(&index, &cache).expect("save");
1258
1259            let (mut restored, _) =
1260                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1261            assert_eq!(
1262                load(&mut restored, request_for(wanted), &cache),
1263                ContentCacheLoad::default(),
1264                "a {stored:?} sidecar must miss a {wanted:?} request"
1265            );
1266            let (mut same, _) =
1267                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1268            let hit = load(&mut same, request_for(stored), &cache);
1269            assert!(hit.usable && hit.hits == 2, "its own set still restores: {hit:?}");
1270        }
1271    }
1272
1273    /// One sidecar per root holds one analyzer set, so answering another set replaces it.
1274    /// That costs the wider set's next run a re-read, never its correctness.
1275    #[test]
1276    fn a_different_analyzer_set_replaces_the_sidecar() {
1277        let (root, cache_dir, index) = containment_fixture(AnalysisSet::ALL);
1278        let cache = cache_dir.path().join("content.cache");
1279        save_content_cache(&index, &cache).expect("save");
1280
1281        let narrower = request_for(AnalysisSet::NONE.with_code());
1282        let (mut restored, _) =
1283            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1284        load(&mut restored, narrower, &cache);
1285        let analysis = super::super::analyze_index(&mut restored, narrower);
1286        assert_eq!(analysis.applied, 2, "the narrower run reads every file");
1287        save_content_cache(&restored, &cache).expect("resave");
1288
1289        let (mut wide, _) =
1290            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1291        assert_eq!(
1292            load(&mut wide, request_for(AnalysisSet::ALL), &cache),
1293            ContentCacheLoad::default(),
1294            "the wider set was replaced"
1295        );
1296        let (mut narrow, _) =
1297            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1298        let hit = load(&mut narrow, narrower, &cache);
1299        assert!(hit.usable && hit.hits == 2, "by the narrower one: {hit:?}");
1300    }
1301
1302    /// Byte offset of the engine fingerprint: after the magic and the format version, where
1303    /// a snapshot's prologue puts it.
1304    const ENGINE_OFFSET: usize = MAGIC.len() + 4;
1305
1306    /// Byte offset of the path encoding, which follows the engine fingerprint.
1307    const PATH_ENCODING_OFFSET: usize = ENGINE_OFFSET + 8;
1308
1309    /// Recompute a rewritten image's checksum, so the rewrite is the only thing wrong with it.
1310    fn reseal(image: &mut [u8]) {
1311        let payload_len = image.len() - CHECKSUM_BYTES - TRAILER.len();
1312        let checksum = crate::snapshot::crc32c(&image[..payload_len]);
1313        image[payload_len..payload_len + CHECKSUM_BYTES].copy_from_slice(&checksum.to_le_bytes());
1314    }
1315
1316    #[test]
1317    fn corruption_is_a_clean_miss() {
1318        let (root, index, request) = analyzed_index();
1319        let cache = root.path().join("content.cache");
1320        save_content_cache(&index, &cache).expect("save");
1321        let mut bytes = fs::read(&cache).expect("read");
1322        assert_eq!(bytes[PATH_ENCODING_OFFSET], crate::snapshot::path_encoding());
1323        // A header byte flipped without resealing: the checksum no longer matches.
1324        bytes[PATH_ENCODING_OFFSET] ^= 0xff;
1325        fs::write(&cache, bytes).expect("corrupt");
1326        let (mut restored, _) =
1327            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1328        assert_eq!(load(&mut restored, request, &cache), ContentCacheLoad::default());
1329    }
1330
1331    /// A sidecar's records answer only the engine and entry tier they were analyzed under.
1332    /// Another engine's sidecar, one of another format, and one analyzed over another scope
1333    /// are clean misses even though every record's path and fingerprint still match; one
1334    /// analyzed with `.gitignore` observation off answers a request with it on, because no
1335    /// metric depends on observation.
1336    #[test]
1337    fn a_sidecar_from_another_engine_or_scope_is_a_clean_miss() {
1338        let (root, index, request) = analyzed_index();
1339        let cache_dir = tempfile::tempdir().expect("cache dir");
1340        let cache = cache_dir.path().join("content.cache");
1341        save_content_cache(&index, &cache).expect("save");
1342        let saved = fs::read(&cache).expect("read");
1343        let load_into = |config: &ScanConfig| {
1344            let (mut restored, _) =
1345                crate::scan::scan_into_index(root.path(), config).expect("scan");
1346            load(&mut restored, request, &cache)
1347        };
1348        let hit = load_into(&ScanConfig::default());
1349        assert!(hit.usable && hit.hits == 1, "the saved sidecar restores: {hit:?}");
1350
1351        let mut other_engine = saved.clone();
1352        for byte in &mut other_engine[ENGINE_OFFSET..ENGINE_OFFSET + 8] {
1353            *byte = !*byte;
1354        }
1355        reseal(&mut other_engine);
1356        let mut older_format = saved.clone();
1357        older_format[MAGIC.len()..ENGINE_OFFSET].copy_from_slice(&4_u32.to_le_bytes());
1358        reseal(&mut older_format);
1359        for (name, image) in [("another engine", other_engine), ("format 4", older_format)] {
1360            fs::write(&cache, image).expect("rewrite");
1361            assert_eq!(load_into(&ScanConfig::default()), ContentCacheLoad::default(), "{name}");
1362        }
1363
1364        fs::write(&cache, &saved).expect("restore");
1365        for config in [
1366            ScanConfig { max_depth: Some(4), ..ScanConfig::default() },
1367            ScanConfig { exclude_special: true, ..ScanConfig::default() },
1368        ] {
1369            assert_eq!(load_into(&config), ContentCacheLoad::default(), "{:?}", config.scope());
1370        }
1371        let blind = ScanConfig { read_controls: false, ..ScanConfig::default() };
1372        assert_eq!(load_into(&blind), hit, "observation is not part of the content identity");
1373    }
1374
1375    /// The sidecar reads its entry tier through the shared codec, so a sealed image whose
1376    /// entry tier holds a byte no encoder writes is a clean miss rather than an identity
1377    /// that happens to compare unequal.
1378    #[test]
1379    fn a_sidecar_entry_tier_no_encoder_writes_is_a_clean_miss() {
1380        let (root, index, request) = analyzed_index();
1381        let cache_dir = tempfile::tempdir().expect("cache dir");
1382        let cache = cache_dir.path().join("content.cache");
1383        save_content_cache(&index, &cache).expect("save");
1384        let saved = fs::read(&cache).expect("read");
1385        let reload = || {
1386            let (mut restored, _) =
1387                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1388            load(&mut restored, request, &cache)
1389        };
1390        assert!(reload().usable, "the saved sidecar restores");
1391
1392        // The entry tier follows the path encoding; its scope flags follow the depth bound.
1393        let flags_at = PATH_ENCODING_OFFSET + 1 + crate::stored_state::BOUND_BYTES;
1394        let mut unknown_flag = saved;
1395        assert_eq!(unknown_flag[flags_at], 0, "the default scope sets no flag");
1396        unknown_flag[flags_at] |= 1 << 7;
1397        reseal(&mut unknown_flag);
1398        fs::write(&cache, unknown_flag).expect("rewrite");
1399        assert_eq!(reload(), ContentCacheLoad::default());
1400    }
1401
1402    /// Records answer only the analyzer versions and options they were counted under. A
1403    /// sidecar whose analyzers are another version, or whose options differ, is a clean
1404    /// miss even though its analyzer set, entries, and every record's fingerprint match.
1405    #[test]
1406    fn an_analyzer_version_change_invalidates_records() {
1407        let (root, index, request) = analyzed_index();
1408        let cache_dir = tempfile::tempdir().expect("cache dir");
1409        let cache = cache_dir.path().join("content.cache");
1410        save_content_cache(&index, &cache).expect("save");
1411        let saved = fs::read(&cache).expect("read");
1412        let reload = |cache: &Path| {
1413            let (mut restored, _) =
1414                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1415            load(&mut restored, request, cache)
1416        };
1417        let hit = reload(&cache);
1418        assert!(hit.usable && hit.hits == 1, "the saved sidecar restores: {hit:?}");
1419
1420        // After the path encoding: the entry tier, which holds the type rules, the analyzer
1421        // set, the options fingerprint, the analyzer count, then the one analyzer's
1422        // length-prefixed id and its version.
1423        let options_at = PATH_ENCODING_OFFSET + 1 + ENTRY_TIER_BYTES + 1;
1424        let count_at = options_at + 8;
1425        let version_at = count_at + 1 + 4 + super::super::CONTENT_BASIC.0.len();
1426        assert_eq!(saved[count_at], 1, "a lines request runs one analyzer");
1427        assert_eq!(&saved[count_at + 5..version_at], super::super::CONTENT_BASIC.0.as_bytes());
1428        assert_eq!(saved[version_at..version_at + 2], 1_u16.to_le_bytes());
1429
1430        let mut other_version = saved.clone();
1431        other_version[version_at..version_at + 2].copy_from_slice(&2_u16.to_le_bytes());
1432        reseal(&mut other_version);
1433        let mut other_options = saved.clone();
1434        other_options[options_at] ^= 1;
1435        reseal(&mut other_options);
1436        for (name, image) in [("another version", other_version), ("other options", other_options)]
1437        {
1438            fs::write(&cache, image).expect("rewrite");
1439            assert_eq!(reload(&cache), ContentCacheLoad::default(), "{name}");
1440        }
1441        fs::write(&cache, &saved).expect("restore");
1442        assert_eq!(reload(&cache), hit, "the unchanged image still restores");
1443    }
1444
1445    /// A save writes a record only for a file this pass verified. Records restored beside
1446    /// a snapshot over a subtree whose verification was withdrawn, here by a pass that
1447    /// began over `sub` and has not finished, describe retained facts nobody re-checked,
1448    /// so they stay out of the sidecar while records for the verified rest of the tree
1449    /// are written.
1450    #[test]
1451    fn records_under_an_unverified_subtree_are_not_written() {
1452        let root = tempfile::tempdir().expect("root");
1453        let store = tempfile::tempdir().expect("cache dir");
1454        fs::write(root.path().join("top.md"), "one two\n").expect("write");
1455        fs::create_dir(root.path().join("sub")).expect("mkdir");
1456        fs::write(root.path().join("sub").join("inner.md"), "three\n").expect("write");
1457        let config = ScanConfig::default();
1458        let lines = request_for(AnalysisSet::NONE.with_lines());
1459        let (snapshot_path, cache) = (store.path().join("tree.fdu"), store.path().join("first"));
1460
1461        let (mut scanned, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
1462        super::super::analyze_index(&mut scanned, lines);
1463        crate::snapshot::save(&scanned, &snapshot_path).expect("save snapshot");
1464        save_content_cache(&scanned, &cache).expect("save sidecar");
1465
1466        let mut restored = crate::snapshot::load_with_types(&snapshot_path, config.types_shared())
1467            .expect("load")
1468            .expect("a usable snapshot");
1469        assert_eq!(load(&mut restored, lines, &cache).hits, 2);
1470        crate::scan::reconcile(&mut restored, &config, &mut |_| {}).expect("reconcile");
1471        restored.begin_reconcile(Path::new("sub")).expect("withdraw trust over sub");
1472
1473        let content = restored.content().expect("content");
1474        let writable = |path: &str| {
1475            let record = content.file(Path::new(path)).expect("a restored record");
1476            crate::stored_state::content_record_writable(&restored, Path::new(path), record)
1477        };
1478        assert!(writable("top.md"), "a verified file's record is written");
1479        assert!(!writable("sub/inner.md"), "a record under an unverified subtree is not");
1480
1481        let rewritten = store.path().join("second");
1482        save_content_cache(&restored, &rewritten).expect("resave");
1483        let (mut fresh, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
1484        let loaded = load(&mut fresh, lines, &rewritten);
1485        assert!(loaded.usable && loaded.hits == 1, "only the verified record: {loaded:?}");
1486        let fresh_content = fresh.content().expect("content");
1487        assert!(fresh_content.file(Path::new("top.md")).is_some());
1488        assert!(fresh_content.file(Path::new("sub/inner.md")).is_none());
1489    }
1490
1491    /// Re-address the sidecar's one record, leaving the image otherwise valid.
1492    ///
1493    /// The path is swapped in its stored encoding and the checksum recomputed, so the only
1494    /// thing wrong with the result is where the record claims to live.
1495    fn readdress_record(image: &[u8], from: &Path, to: &Path) -> Vec<u8> {
1496        let encode = |path: &Path| {
1497            let mut bytes = Vec::new();
1498            crate::snapshot::put_os_str(&mut bytes, path.as_os_str()).expect("encode");
1499            bytes
1500        };
1501        let (from, to) = (encode(from), encode(to));
1502        let payload = integrity_payload(image).expect("a valid sidecar");
1503        let at = payload.windows(from.len()).position(|window| window == from).expect("the path");
1504        assert_eq!(
1505            payload.windows(from.len()).rposition(|window| window == from),
1506            Some(at),
1507            "the record's path must appear once, or the rewrite is ambiguous"
1508        );
1509        let mut rewritten = [&payload[..at], to.as_slice(), &payload[at + from.len()..]].concat();
1510        let checksum = crate::snapshot::crc32c(&rewritten);
1511        rewritten.extend_from_slice(&checksum.to_le_bytes());
1512        rewritten.extend_from_slice(TRAILER);
1513        rewritten
1514    }
1515
1516    fn two_record_sidecar() -> (tempfile::TempDir, tempfile::TempDir, AnalysisRequest, PathBuf) {
1517        let root = tempfile::tempdir().expect("root");
1518        let cache_dir = tempfile::tempdir().expect("cache dir");
1519        fs::write(root.path().join("a.md"), "one\n").expect("write first");
1520        fs::write(root.path().join("z.md"), "two\n").expect("write second");
1521        let request = request_for(AnalysisSet::NONE.with_lines());
1522        let (mut index, _) =
1523            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1524        super::super::analyze_index(&mut index, request);
1525        let cache = cache_dir.path().join("content.cache");
1526        save_content_cache(&index, &cache).expect("save");
1527        (root, cache_dir, request, cache)
1528    }
1529
1530    fn assert_late_stream_miss_recovers(
1531        root: &tempfile::TempDir,
1532        request: AnalysisRequest,
1533        cache: &Path,
1534    ) {
1535        let (mut restored, _) =
1536            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1537        assert_eq!(load(&mut restored, request, cache), ContentCacheLoad::default());
1538        assert!(restored.content().is_none(), "a late miss exposes no accepted prefix");
1539        assert!(
1540            restored.content_rollup(Path::new("")).is_none(),
1541            "a late miss exposes no roll-up from the accepted prefix"
1542        );
1543
1544        let analyzed = super::super::analyze_index(&mut restored, request);
1545        assert_eq!(analyzed.applied, 2, "both files are reanalyzed after the miss");
1546        assert_eq!(restored.content().expect("reanalyzed content").len(), 2);
1547        assert_eq!(
1548            restored.content_rollup(Path::new("")).expect("rebuilt root roll-up").total.files,
1549            2
1550        );
1551    }
1552
1553    #[test]
1554    fn malformed_second_record_rolls_back_the_valid_prefix() {
1555        let (root, _cache_dir, request, cache) = two_record_sidecar();
1556        let image = fs::read(&cache).expect("read");
1557        let malformed = readdress_record(&image, Path::new("z.md"), Path::new("../outside.md"));
1558        fs::write(&cache, malformed).expect("rewrite");
1559
1560        assert_late_stream_miss_recovers(&root, request, &cache);
1561    }
1562
1563    #[test]
1564    fn checksummed_trailing_bytes_roll_back_all_records() {
1565        let (root, _cache_dir, request, cache) = two_record_sidecar();
1566        let image = fs::read(&cache).expect("read");
1567        let mut payload = integrity_payload(&image).expect("valid sidecar").to_vec();
1568        payload.extend_from_slice(b"trailing");
1569        let checksum = crate::snapshot::crc32c(&payload);
1570        payload.extend_from_slice(&checksum.to_le_bytes());
1571        payload.extend_from_slice(TRAILER);
1572        fs::write(&cache, payload).expect("rewrite");
1573
1574        assert_late_stream_miss_recovers(&root, request, &cache);
1575    }
1576
1577    /// A sidecar is untrusted, and each record names a path under the root it claims.
1578    ///
1579    /// The guard used to ask `is_absolute`, which is the wrong question for "does this stay
1580    /// inside". `..` is not absolute on any platform, and on Windows neither is a rooted
1581    /// path with no drive (`\rooted`) or a drive-relative one (`C:relative`). A record that
1582    /// leaves the root now makes the whole sidecar a clean miss, like any other malformed
1583    /// image, while an ordinary relative record still restores.
1584    #[test]
1585    fn a_record_that_leaves_the_root_is_a_clean_miss() {
1586        // (record path, whether the sidecar restores)
1587        let mut cases =
1588            vec![("notes.md", true), ("../escape.md", false), ("nested/../../escape.md", false)];
1589        #[cfg(unix)]
1590        cases.push(("/absolute.md", false));
1591        #[cfg(windows)]
1592        cases.extend([
1593            (r"C:\absolute.md", false),
1594            (r"\rooted-without-drive.md", false),
1595            ("C:drive-relative.md", false),
1596        ]);
1597
1598        for (record_path, restores) in cases {
1599            // The rule the parser asks, on the bare path: every component must be normal.
1600            assert_eq!(
1601                record_path_stays_inside_root(Path::new(record_path)),
1602                restores,
1603                "{record_path:?}"
1604            );
1605
1606            let (root, index, request) = analyzed_index();
1607            let cache = root.path().join("content.cache");
1608            save_content_cache(&index, &cache).expect("save");
1609            let image = fs::read(&cache).expect("read");
1610            let rewritten = readdress_record(&image, Path::new("notes.md"), Path::new(record_path));
1611            fs::write(&cache, rewritten).expect("re-address");
1612
1613            let (mut restored, _) =
1614                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1615            let loaded = load(&mut restored, request, &cache);
1616            if restores {
1617                assert!(loaded.usable, "{record_path:?} must restore: {loaded:?}");
1618                assert_eq!(loaded.hits, 1, "{record_path:?} must restore: {loaded:?}");
1619            } else {
1620                assert_eq!(
1621                    loaded,
1622                    ContentCacheLoad::default(),
1623                    "{record_path:?} leaves the root, so the sidecar must be a clean miss"
1624                );
1625            }
1626        }
1627    }
1628
1629    #[test]
1630    fn sidecar_name_preserves_the_metadata_snapshot_name() {
1631        assert_eq!(content_cache_path(Path::new("tree.fdu")), PathBuf::from("tree.fdu.content"));
1632    }
1633}