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