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 matches!(coverage, CoverageReason::Analyzed | CoverageReason::TextOnly) {
714        Some(read_value(reader)?)
715    } else {
716        None
717    };
718    AnalyzerOutcome::from_parts(coverage, value)
719}
720
721fn put_optional_outcome<T: Copy>(
722    buffer: &mut Vec<u8>,
723    outcome: Option<AnalyzerOutcome<T>>,
724    put_value: fn(&mut Vec<u8>, T),
725) {
726    buffer.push(u8::from(outcome.is_some()));
727    if let Some(outcome) = outcome {
728        put_outcome(buffer, outcome, put_value);
729    }
730}
731
732#[expect(
733    clippy::option_option,
734    reason = "Outer None rejects malformed bytes; inner None is an absent unit."
735)]
736fn read_optional_outcome<T>(
737    reader: &mut Reader<'_>,
738    read_value: fn(&mut Reader<'_>) -> Option<T>,
739) -> Option<Option<AnalyzerOutcome<T>>> {
740    match reader.u8()? {
741        0 => Some(None),
742        1 => Some(Some(read_outcome(reader, read_value)?)),
743        _ => None,
744    }
745}
746
747fn analysis_outcomes(analysis: &FileAnalysis) -> impl Iterator<Item = CoverageReason> + '_ {
748    std::iter::once(analysis.lines.coverage())
749        .chain(analysis.code.map(|outcome| outcome.coverage()))
750        .chain(analysis.words.map(|outcome| outcome.coverage()))
751}
752
753fn put_analyzers(buffer: &mut Vec<u8>, analyzers: &[(AnalyzerId, AnalyzerVersion)]) -> Result<()> {
754    let count = u8::try_from(analyzers.len())
755        .map_err(|_| Error::Snapshot("too many content analyzers".into()))?;
756    buffer.push(count);
757    for (id, version) in analyzers {
758        put_bounded_bytes(buffer, id.0.as_bytes(), MAX_ANALYZER_ID_BYTES)?;
759        buffer.extend_from_slice(&version.0.to_le_bytes());
760    }
761    Ok(())
762}
763
764fn read_analyzers(reader: &mut Reader<'_>) -> Option<Vec<(AnalyzerId, AnalyzerVersion)>> {
765    let count = usize::from(reader.u8()?);
766    if count > MAX_ANALYZERS {
767        return None;
768    }
769    let mut analyzers = Vec::with_capacity(count);
770    for _ in 0..count {
771        let id = String::from_utf8(reader.bytes(MAX_ANALYZER_ID_BYTES)?).ok()?;
772        let known = match id.as_str() {
773            "content-basic-v1" => super::CONTENT_BASIC,
774            "code-sloc-v1" => super::CODE_SLOC,
775            "text-logical-v1" => super::TEXT_LOGICAL,
776            "markdown-prose-v1" => super::MARKDOWN_PROSE,
777            _ => return None,
778        };
779        analyzers.push((known, AnalyzerVersion(reader.u16()?)));
780    }
781    Some(analyzers)
782}
783
784fn put_bounded_bytes(buffer: &mut Vec<u8>, bytes: &[u8], max: usize) -> Result<()> {
785    if bytes.len() > max {
786        return Err(Error::Snapshot("content sidecar string exceeds limit".into()));
787    }
788    let length = u32::try_from(bytes.len())
789        .map_err(|_| Error::Snapshot("content sidecar string length overflow".into()))?;
790    buffer.extend_from_slice(&length.to_le_bytes());
791    buffer.extend_from_slice(bytes);
792    Ok(())
793}
794
795fn put_profile(buffer: &mut Vec<u8>, profile: AnalysisSet) {
796    buffer.push(profile.bits());
797}
798
799fn read_profile(code: u8) -> Option<AnalysisSet> {
800    AnalysisSet::from_bits(code)
801}
802
803fn family_code(value: ContentFamily) -> u8 {
804    match value {
805        ContentFamily::Code => 0,
806        ContentFamily::Prose => 1,
807        ContentFamily::Markup => 2,
808        ContentFamily::Data => 3,
809        ContentFamily::Binary => 4,
810        ContentFamily::Unknown => 5,
811    }
812}
813
814fn read_family(code: u8) -> Option<ContentFamily> {
815    match code {
816        0 => Some(ContentFamily::Code),
817        1 => Some(ContentFamily::Prose),
818        2 => Some(ContentFamily::Markup),
819        3 => Some(ContentFamily::Data),
820        4 => Some(ContentFamily::Binary),
821        5 => Some(ContentFamily::Unknown),
822        _ => None,
823    }
824}
825
826fn source_code(value: DetectionSource) -> u8 {
827    match value {
828        DetectionSource::ExactFilename => 0,
829        DetectionSource::CompoundExtension => 1,
830        DetectionSource::Extension => 2,
831        DetectionSource::Shebang => 3,
832        DetectionSource::ContentProbe => 4,
833        DetectionSource::Unknown => 5,
834        DetectionSource::Modeline => 6,
835        DetectionSource::AmbiguousContent => 7,
836        DetectionSource::FormatSignature => 8,
837    }
838}
839
840fn read_source(code: u8) -> Option<DetectionSource> {
841    match code {
842        0 => Some(DetectionSource::ExactFilename),
843        1 => Some(DetectionSource::CompoundExtension),
844        2 => Some(DetectionSource::Extension),
845        3 => Some(DetectionSource::Shebang),
846        4 => Some(DetectionSource::ContentProbe),
847        5 => Some(DetectionSource::Unknown),
848        6 => Some(DetectionSource::Modeline),
849        7 => Some(DetectionSource::AmbiguousContent),
850        8 => Some(DetectionSource::FormatSignature),
851        _ => None,
852    }
853}
854
855fn flags_code(value: ClassificationFlags) -> u8 {
856    u8::from(value.generated)
857        | (u8::from(value.vendored) << 1)
858        | (u8::from(value.documentation) << 2)
859}
860
861fn read_flags(code: u8) -> Option<ClassificationFlags> {
862    (code & !0b111 == 0).then_some(ClassificationFlags {
863        generated: code & 0b001 != 0,
864        vendored: code & 0b010 != 0,
865        documentation: code & 0b100 != 0,
866    })
867}
868
869fn confidence_code(value: DetectionConfidence) -> u8 {
870    match value {
871        DetectionConfidence::Certain => 0,
872        DetectionConfidence::High => 1,
873        DetectionConfidence::Heuristic => 2,
874    }
875}
876
877fn read_confidence(code: u8) -> Option<DetectionConfidence> {
878    match code {
879        0 => Some(DetectionConfidence::Certain),
880        1 => Some(DetectionConfidence::High),
881        2 => Some(DetectionConfidence::Heuristic),
882        _ => None,
883    }
884}
885
886fn coverage_code(value: CoverageReason) -> u8 {
887    match value {
888        CoverageReason::Analyzed => 0,
889        CoverageReason::Binary => 1,
890        CoverageReason::InvalidUtf8 => 2,
891        CoverageReason::UnsupportedEncoding => 3,
892        CoverageReason::Unsupported => 4,
893        CoverageReason::IoError => 5,
894        CoverageReason::ChangedDuringRead => 6,
895        CoverageReason::TextOnly => 7,
896    }
897}
898
899fn read_coverage(code: u8) -> Option<CoverageReason> {
900    match code {
901        0 => Some(CoverageReason::Analyzed),
902        1 => Some(CoverageReason::Binary),
903        2 => Some(CoverageReason::InvalidUtf8),
904        3 => Some(CoverageReason::UnsupportedEncoding),
905        4 => Some(CoverageReason::Unsupported),
906        5 => Some(CoverageReason::IoError),
907        6 => Some(CoverageReason::ChangedDuringRead),
908        7 => Some(CoverageReason::TextOnly),
909        _ => None,
910    }
911}
912
913struct Reader<'a> {
914    remaining: &'a [u8],
915}
916
917impl<'a> Reader<'a> {
918    fn new(remaining: &'a [u8]) -> Self {
919        Self { remaining }
920    }
921
922    fn take(&mut self, count: usize) -> Option<&'a [u8]> {
923        let (value, rest) = self.remaining.split_at_checked(count)?;
924        self.remaining = rest;
925        Some(value)
926    }
927
928    fn u8(&mut self) -> Option<u8> {
929        self.take(1).map(|value| value[0])
930    }
931
932    fn u16(&mut self) -> Option<u16> {
933        Some(u16::from_le_bytes(self.take(2)?.try_into().ok()?))
934    }
935
936    fn u32(&mut self) -> Option<u32> {
937        Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
938    }
939
940    fn u64(&mut self) -> Option<u64> {
941        Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
942    }
943
944    fn i64(&mut self) -> Option<i64> {
945        Some(i64::from_le_bytes(self.take(8)?.try_into().ok()?))
946    }
947
948    fn bytes(&mut self, max: usize) -> Option<Vec<u8>> {
949        let length = usize::try_from(self.u32()?).ok()?;
950        (length <= max).then(|| self.take(length).map(<[u8]>::to_vec)).flatten()
951    }
952
953    fn os_string(&mut self) -> Option<OsString> {
954        let bytes = self.bytes(MAX_PATH_BYTES)?;
955        #[cfg(unix)]
956        {
957            Some(decode_os_string(&bytes))
958        }
959        #[cfg(not(unix))]
960        {
961            decode_os_string(&bytes)
962        }
963    }
964
965    fn is_empty(&self) -> bool {
966        self.remaining.is_empty()
967    }
968}
969
970#[cfg(unix)]
971fn decode_os_string(bytes: &[u8]) -> OsString {
972    use std::os::unix::ffi::OsStringExt;
973    OsString::from_vec(bytes.to_vec())
974}
975
976#[cfg(windows)]
977fn decode_os_string(bytes: &[u8]) -> Option<OsString> {
978    use std::os::windows::ffi::OsStringExt;
979    // `usize::is_multiple_of` is stable since 1.87 and this crate's MSRV is 1.85, so a
980    // Windows user on the declared minimum could not build it. Nothing caught that
981    // because the MSRV job runs on ubuntu, where this function does not exist.
982    if bytes.len() % 2 != 0 {
983        return None;
984    }
985    let units = bytes
986        .chunks_exact(2)
987        .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
988        .collect::<Vec<_>>();
989    Some(OsString::from_wide(&units))
990}
991
992#[cfg(not(any(unix, windows)))]
993fn decode_os_string(bytes: &[u8]) -> Option<OsString> {
994    String::from_utf8(bytes.to_vec()).ok().map(OsString::from)
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000    use std::fs;
1001
1002    use crate::scan::ScanConfig;
1003
1004    fn analyzed_index() -> (tempfile::TempDir, Index, AnalysisRequest) {
1005        let root = tempfile::tempdir().expect("root");
1006        fs::write(root.path().join("notes.md"), "<!-- @generated -->\none two\n").expect("write");
1007        let (mut index, _) =
1008            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1009        let request = AnalysisRequest {
1010            profile: AnalysisSet::NONE.with_lines(),
1011            ..AnalysisRequest::default()
1012        };
1013        super::super::analyze_index(&mut index, request);
1014        (root, index, request)
1015    }
1016
1017    /// A prose file and a code file, so a `code`-only request is genuinely narrower than
1018    /// `all` rather than accidentally equivalent on this fixture.
1019    ///
1020    /// The sidecar lives outside the scanned tree: writing it inside would make the cache
1021    /// file itself an unanalyzed candidate and quietly defeat any "opened no file" claim.
1022    fn containment_fixture(profile: AnalysisSet) -> (tempfile::TempDir, tempfile::TempDir, Index) {
1023        let root = tempfile::tempdir().expect("root");
1024        let cache_dir = tempfile::tempdir().expect("cache dir");
1025        fs::write(root.path().join("notes.md"), "<!-- @generated -->\none two\n").expect("write");
1026        fs::write(root.path().join("main.rs"), "fn main() {\n    // hi\n}\n").expect("write");
1027        let (mut index, _) =
1028            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1029        super::super::analyze_index(&mut index, request_for(profile));
1030        (root, cache_dir, index)
1031    }
1032
1033    fn request_for(profile: AnalysisSet) -> AnalysisRequest {
1034        AnalysisRequest { profile, ..AnalysisRequest::default() }
1035    }
1036
1037    /// Load the sidecar at `cache` for `request`'s identity in `index`.
1038    fn load(index: &mut Index, request: AnalysisRequest, cache: &Path) -> ContentCacheLoad {
1039        let wanted = index.content_identity(request.profile);
1040        load_content_cache(index, &wanted, cache).expect("load")
1041    }
1042
1043    #[test]
1044    fn fractional_record_durations_are_converted_after_accumulation() {
1045        let mut total = Duration::ZERO;
1046        for _ in 0..1_000 {
1047            add_duration(&mut total, Duration::from_nanos(900));
1048        }
1049
1050        assert_eq!(duration_micros(total), 900);
1051        assert_eq!(duration_micros(Duration::from_nanos(900)), 0);
1052    }
1053
1054    #[test]
1055    fn restored_sidecar_does_not_claim_its_container_mtime_as_observation_time() {
1056        let (root, analyzed, request) = analyzed_index();
1057        let cache_dir = tempfile::tempdir().expect("cache dir");
1058        let cache = cache_dir.path().join("content.cache");
1059        save_content_cache(&analyzed, &cache).expect("save");
1060        let (mut restored, _) =
1061            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1062
1063        let loaded = load(&mut restored, request, &cache);
1064
1065        assert!(loaded.usable && loaded.hits == 1, "{loaded:?}");
1066        let state =
1067            restored.content().and_then(super::super::ContentIndex::state).expect("tier state");
1068        assert_eq!(state.source, crate::Source::Cached);
1069        assert_eq!(state.observed_at_ns, None, "the sidecar stores no observation instant");
1070    }
1071
1072    /// Restore rebuilds nested directory roll-ups, not only the root.
1073    ///
1074    /// The probe content digest hashes the root roll-up; the `ContentIndex` unit test is
1075    /// the in-memory H115 check. This is the sidecar-boundary half of that claim.
1076    #[test]
1077    fn sidecar_restore_rebuilds_nested_directory_rollups() {
1078        let root = tempfile::tempdir().expect("root");
1079        let cache_dir = tempfile::tempdir().expect("cache dir");
1080        fs::create_dir_all(root.path().join("a/b")).expect("dirs");
1081        fs::write(root.path().join("notes.md"), "one two\n").expect("write");
1082        fs::write(root.path().join("a/keep.rs"), "fn keep() {}\n").expect("write");
1083        fs::write(root.path().join("a/b/nested.rs"), "fn nested() {}\n").expect("write");
1084        let request = request_for(AnalysisSet::NONE.with_lines());
1085        let (mut analyzed, _) =
1086            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1087        super::super::analyze_index(&mut analyzed, request);
1088        let cache = cache_dir.path().join("content.cache");
1089        save_content_cache(&analyzed, &cache).expect("save");
1090
1091        let (mut restored, _) =
1092            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1093        let loaded = load(&mut restored, request, &cache);
1094        assert!(loaded.usable && loaded.hits == 3, "{loaded:?}");
1095        for dir in ["", "a", "a/b"] {
1096            assert_eq!(
1097                restored.content_rollup(Path::new(dir)),
1098                analyzed.content_rollup(Path::new(dir)),
1099                "{dir:?} roll-up"
1100            );
1101        }
1102    }
1103
1104    #[test]
1105    fn operational_failure_in_a_valid_sidecar_is_not_reused() {
1106        let (root, analyzed, request) = analyzed_index();
1107        let store = tempfile::tempdir().expect("cache dir");
1108        let cache = store.path().join("content.cache");
1109        let identity = analyzed.content().and_then(|content| content.identity()).expect("identity");
1110        let mut record = analyzed
1111            .content()
1112            .and_then(|content| content.file(Path::new("notes.md")))
1113            .cloned()
1114            .expect("record");
1115        record.lines = AnalyzerOutcome::unavailable(CoverageReason::IoError);
1116        record.error = Some("injected failure".into());
1117
1118        // Build a checksummed sidecar directly: the ordinary writer correctly excludes
1119        // this record, while the reader must still distrust an image another process can
1120        // create or modify.
1121        let mut image = Vec::new();
1122        image.extend_from_slice(MAGIC);
1123        image.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
1124        image.extend_from_slice(&identity.entries.engine.to_le_bytes());
1125        image.push(crate::snapshot::path_encoding());
1126        put_identity(&mut image, identity).expect("identity");
1127        crate::snapshot::put_os_str(&mut image, analyzed.root_path().as_os_str()).expect("root");
1128        image.extend_from_slice(&1_u64.to_le_bytes());
1129        put_record(&mut image, Path::new("notes.md"), &record).expect("record");
1130        let checksum = crate::snapshot::crc32c(&image);
1131        image.extend_from_slice(&checksum.to_le_bytes());
1132        image.extend_from_slice(TRAILER);
1133        fs::write(&cache, image).expect("write sidecar");
1134
1135        let (mut restored, _) =
1136            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1137        let loaded = load(&mut restored, request, &cache);
1138        assert!(loaded.usable, "the sidecar identity itself remains usable");
1139        assert_eq!((loaded.hits, loaded.stale), (0, 1));
1140        assert!(
1141            restored
1142                .content()
1143                .expect("prepared content tier")
1144                .file(Path::new("notes.md"))
1145                .is_none()
1146        );
1147        assert_eq!(restored.pending_analysis_candidates(request).len(), 1);
1148    }
1149
1150    #[test]
1151    fn unsupported_encoding_outcomes_round_trip_through_the_sidecar() {
1152        let root = tempfile::tempdir().expect("root");
1153        let store = tempfile::tempdir().expect("cache dir");
1154        fs::write(root.path().join("wide.rs"), [0xff, 0xfe, b'f', 0, b'n', 0])
1155            .expect("UTF-16 fixture");
1156        let request = request_for(AnalysisSet::ALL);
1157        let (mut analyzed, _) =
1158            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1159        super::super::analyze_index(&mut analyzed, request);
1160        let expected = analyzed
1161            .content()
1162            .and_then(|content| content.file(Path::new("wide.rs")))
1163            .cloned()
1164            .expect("analyzed record");
1165        let cache = store.path().join("content.cache");
1166        save_content_cache(&analyzed, &cache).expect("save");
1167
1168        let (mut restored, _) =
1169            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1170        let loaded = load(&mut restored, request, &cache);
1171        assert!(loaded.usable && loaded.hits == 1, "{loaded:?}");
1172        let actual = restored
1173            .content()
1174            .and_then(|content| content.file(Path::new("wide.rs")))
1175            .expect("restored record");
1176        assert_eq!(actual, &expected);
1177        assert_eq!(actual.lines.coverage(), CoverageReason::UnsupportedEncoding);
1178        assert_eq!(
1179            actual.code.expect("code outcome").coverage(),
1180            CoverageReason::UnsupportedEncoding
1181        );
1182        assert_eq!(
1183            actual.words.expect("word outcome").coverage(),
1184            CoverageReason::UnsupportedEncoding
1185        );
1186    }
1187
1188    /// A sidecar serves exactly the analyzer set it was written for. A wider one holds
1189    /// metrics the narrower request did not ask for and would report them, and the wider
1190    /// set's label, as its answer; so it is a clean miss, and every file is read again
1191    /// under the narrower set, as a cold run reads it.
1192    #[test]
1193    fn a_wider_sidecar_is_a_clean_miss_for_a_narrower_request() {
1194        let (root, cache_dir, index) = containment_fixture(AnalysisSet::ALL);
1195        let cache = cache_dir.path().join("content.cache");
1196        save_content_cache(&index, &cache).expect("save");
1197
1198        let narrower = request_for(AnalysisSet::NONE.with_code());
1199        let (mut restored, _) =
1200            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1201        let loaded = load(&mut restored, narrower, &cache);
1202
1203        assert_eq!(loaded, ContentCacheLoad::default(), "a wider sidecar must miss");
1204        assert_eq!(
1205            restored.pending_analysis_candidates(narrower).len(),
1206            2,
1207            "a missed request reads every file"
1208        );
1209        assert!(restored.content().is_none(), "a miss leaves no content tier behind");
1210
1211        // The load above missed, so both indexes now analyze cold; this checks only that
1212        // the rejected wider sidecar left no residue. Counts alone would not show that, so
1213        // compare every displayed metric and per-unit outcome in each row and the total
1214        // with a fresh index that has never seen the wider sidecar.
1215        let (mut cold, _) =
1216            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("cold scan");
1217        super::super::analyze_index(&mut cold, narrower);
1218        super::super::analyze_index(&mut restored, narrower);
1219        let values = |index: &Index| {
1220            let query = crate::query::Query {
1221                views: vec![crate::query::ViewSpec::Types],
1222                ..crate::query::Query::default()
1223            };
1224            let report = crate::query::report(
1225                index,
1226                &crate::test_support::read_of(index, query),
1227                std::time::UNIX_EPOCH,
1228            )
1229            .expect("metric report");
1230            let crate::query::Section::Metrics { summary, .. } = &report.sections[0] else {
1231                panic!("expected type metrics");
1232            };
1233            std::iter::once(&summary.total)
1234                .chain(&summary.rows)
1235                .map(|row| {
1236                    (
1237                        row.id.clone(),
1238                        (
1239                            row.analysis,
1240                            row.metrics,
1241                            row.lines_coverage.clone(),
1242                            row.code_coverage.clone(),
1243                            row.words_coverage.clone(),
1244                        ),
1245                    )
1246                })
1247                .collect::<std::collections::BTreeMap<_, _>>()
1248        };
1249        assert_eq!(values(&restored), values(&cold), "wider cache history changes no row value");
1250    }
1251
1252    /// Neither a narrower sidecar nor one of an incomparable set answers a request: each
1253    /// lacks metrics the request needs or holds ones it did not ask for.
1254    #[test]
1255    fn another_analyzer_set_is_a_clean_miss() {
1256        let code = AnalysisSet::NONE.with_code();
1257        let words = AnalysisSet::NONE.with_words();
1258        for (stored, wanted) in [(code, AnalysisSet::ALL), (code, words), (words, code)] {
1259            let (root, cache_dir, index) = containment_fixture(stored);
1260            let cache = cache_dir.path().join("content.cache");
1261            save_content_cache(&index, &cache).expect("save");
1262
1263            let (mut restored, _) =
1264                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1265            assert_eq!(
1266                load(&mut restored, request_for(wanted), &cache),
1267                ContentCacheLoad::default(),
1268                "a {stored:?} sidecar must miss a {wanted:?} request"
1269            );
1270            let (mut same, _) =
1271                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1272            let hit = load(&mut same, request_for(stored), &cache);
1273            assert!(hit.usable && hit.hits == 2, "its own set still restores: {hit:?}");
1274        }
1275    }
1276
1277    /// One sidecar per root holds one analyzer set, so answering another set replaces it.
1278    /// That costs the wider set's next run a re-read, never its correctness.
1279    #[test]
1280    fn a_different_analyzer_set_replaces_the_sidecar() {
1281        let (root, cache_dir, index) = containment_fixture(AnalysisSet::ALL);
1282        let cache = cache_dir.path().join("content.cache");
1283        save_content_cache(&index, &cache).expect("save");
1284
1285        let narrower = request_for(AnalysisSet::NONE.with_code());
1286        let (mut restored, _) =
1287            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1288        load(&mut restored, narrower, &cache);
1289        let analysis = super::super::analyze_index(&mut restored, narrower);
1290        assert_eq!(analysis.applied, 2, "the narrower run reads every file");
1291        save_content_cache(&restored, &cache).expect("resave");
1292
1293        let (mut wide, _) =
1294            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1295        assert_eq!(
1296            load(&mut wide, request_for(AnalysisSet::ALL), &cache),
1297            ContentCacheLoad::default(),
1298            "the wider set was replaced"
1299        );
1300        let (mut narrow, _) =
1301            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1302        let hit = load(&mut narrow, narrower, &cache);
1303        assert!(hit.usable && hit.hits == 2, "by the narrower one: {hit:?}");
1304    }
1305
1306    /// Byte offset of the engine fingerprint: after the magic and the format version, where
1307    /// a snapshot's prologue puts it.
1308    const ENGINE_OFFSET: usize = MAGIC.len() + 4;
1309
1310    /// Byte offset of the path encoding, which follows the engine fingerprint.
1311    const PATH_ENCODING_OFFSET: usize = ENGINE_OFFSET + 8;
1312
1313    /// Recompute a rewritten image's checksum, so the rewrite is the only thing wrong with it.
1314    fn reseal(image: &mut [u8]) {
1315        let payload_len = image.len() - CHECKSUM_BYTES - TRAILER.len();
1316        let checksum = crate::snapshot::crc32c(&image[..payload_len]);
1317        image[payload_len..payload_len + CHECKSUM_BYTES].copy_from_slice(&checksum.to_le_bytes());
1318    }
1319
1320    #[test]
1321    fn corruption_is_a_clean_miss() {
1322        let (root, index, request) = analyzed_index();
1323        let cache = root.path().join("content.cache");
1324        save_content_cache(&index, &cache).expect("save");
1325        let mut bytes = fs::read(&cache).expect("read");
1326        assert_eq!(bytes[PATH_ENCODING_OFFSET], crate::snapshot::path_encoding());
1327        // A header byte flipped without resealing: the checksum no longer matches.
1328        bytes[PATH_ENCODING_OFFSET] ^= 0xff;
1329        fs::write(&cache, bytes).expect("corrupt");
1330        let (mut restored, _) =
1331            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1332        assert_eq!(load(&mut restored, request, &cache), ContentCacheLoad::default());
1333    }
1334
1335    /// A sidecar's records answer only the engine and entry tier they were analyzed under.
1336    /// Another engine's sidecar, one of another format, and one analyzed over another scope
1337    /// are clean misses even though every record's path and fingerprint still match; one
1338    /// analyzed with `.gitignore` observation off answers a request with it on, because no
1339    /// metric depends on observation.
1340    #[test]
1341    fn a_sidecar_from_another_engine_or_scope_is_a_clean_miss() {
1342        let (root, index, request) = analyzed_index();
1343        let cache_dir = tempfile::tempdir().expect("cache dir");
1344        let cache = cache_dir.path().join("content.cache");
1345        save_content_cache(&index, &cache).expect("save");
1346        let saved = fs::read(&cache).expect("read");
1347        let load_into = |config: &ScanConfig| {
1348            let (mut restored, _) =
1349                crate::scan::scan_into_index(root.path(), config).expect("scan");
1350            load(&mut restored, request, &cache)
1351        };
1352        let hit = load_into(&ScanConfig::default());
1353        assert!(hit.usable && hit.hits == 1, "the saved sidecar restores: {hit:?}");
1354
1355        let mut other_engine = saved.clone();
1356        for byte in &mut other_engine[ENGINE_OFFSET..ENGINE_OFFSET + 8] {
1357            *byte = !*byte;
1358        }
1359        reseal(&mut other_engine);
1360        let mut older_format = saved.clone();
1361        older_format[MAGIC.len()..ENGINE_OFFSET].copy_from_slice(&4_u32.to_le_bytes());
1362        reseal(&mut older_format);
1363        for (name, image) in [("another engine", other_engine), ("format 4", older_format)] {
1364            fs::write(&cache, image).expect("rewrite");
1365            assert_eq!(load_into(&ScanConfig::default()), ContentCacheLoad::default(), "{name}");
1366        }
1367
1368        fs::write(&cache, &saved).expect("restore");
1369        for config in [
1370            ScanConfig { max_depth: Some(4), ..ScanConfig::default() },
1371            ScanConfig { exclude_special: true, ..ScanConfig::default() },
1372        ] {
1373            assert_eq!(load_into(&config), ContentCacheLoad::default(), "{:?}", config.scope());
1374        }
1375        let blind = ScanConfig { read_controls: false, ..ScanConfig::default() };
1376        assert_eq!(load_into(&blind), hit, "observation is not part of the content identity");
1377    }
1378
1379    /// The sidecar reads its entry tier through the shared codec, so a sealed image whose
1380    /// entry tier holds a byte no encoder writes is a clean miss rather than an identity
1381    /// that happens to compare unequal.
1382    #[test]
1383    fn a_sidecar_entry_tier_no_encoder_writes_is_a_clean_miss() {
1384        let (root, index, request) = analyzed_index();
1385        let cache_dir = tempfile::tempdir().expect("cache dir");
1386        let cache = cache_dir.path().join("content.cache");
1387        save_content_cache(&index, &cache).expect("save");
1388        let saved = fs::read(&cache).expect("read");
1389        let reload = || {
1390            let (mut restored, _) =
1391                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1392            load(&mut restored, request, &cache)
1393        };
1394        assert!(reload().usable, "the saved sidecar restores");
1395
1396        // The entry tier follows the path encoding; its scope flags follow the depth bound.
1397        let flags_at = PATH_ENCODING_OFFSET + 1 + crate::stored_state::BOUND_BYTES;
1398        let mut unknown_flag = saved;
1399        assert_eq!(unknown_flag[flags_at], 0, "the default scope sets no flag");
1400        unknown_flag[flags_at] |= 1 << 7;
1401        reseal(&mut unknown_flag);
1402        fs::write(&cache, unknown_flag).expect("rewrite");
1403        assert_eq!(reload(), ContentCacheLoad::default());
1404    }
1405
1406    /// Records answer only the analyzer versions and options they were counted under. A
1407    /// sidecar whose analyzers are another version, or whose options differ, is a clean
1408    /// miss even though its analyzer set, entries, and every record's fingerprint match.
1409    #[test]
1410    fn an_analyzer_version_change_invalidates_records() {
1411        let (root, index, request) = analyzed_index();
1412        let cache_dir = tempfile::tempdir().expect("cache dir");
1413        let cache = cache_dir.path().join("content.cache");
1414        save_content_cache(&index, &cache).expect("save");
1415        let saved = fs::read(&cache).expect("read");
1416        let reload = |cache: &Path| {
1417            let (mut restored, _) =
1418                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1419            load(&mut restored, request, cache)
1420        };
1421        let hit = reload(&cache);
1422        assert!(hit.usable && hit.hits == 1, "the saved sidecar restores: {hit:?}");
1423
1424        // After the path encoding: the entry tier, which holds the type rules, the analyzer
1425        // set, the options fingerprint, the analyzer count, then the one analyzer's
1426        // length-prefixed id and its version.
1427        let options_at = PATH_ENCODING_OFFSET + 1 + ENTRY_TIER_BYTES + 1;
1428        let count_at = options_at + 8;
1429        let version_at = count_at + 1 + 4 + super::super::CONTENT_BASIC.0.len();
1430        assert_eq!(saved[count_at], 1, "a lines request runs one analyzer");
1431        assert_eq!(&saved[count_at + 5..version_at], super::super::CONTENT_BASIC.0.as_bytes());
1432        assert_eq!(saved[version_at..version_at + 2], 1_u16.to_le_bytes());
1433
1434        let mut other_version = saved.clone();
1435        other_version[version_at..version_at + 2].copy_from_slice(&2_u16.to_le_bytes());
1436        reseal(&mut other_version);
1437        let mut other_options = saved.clone();
1438        other_options[options_at] ^= 1;
1439        reseal(&mut other_options);
1440        for (name, image) in [("another version", other_version), ("other options", other_options)]
1441        {
1442            fs::write(&cache, image).expect("rewrite");
1443            assert_eq!(reload(&cache), ContentCacheLoad::default(), "{name}");
1444        }
1445        fs::write(&cache, &saved).expect("restore");
1446        assert_eq!(reload(&cache), hit, "the unchanged image still restores");
1447    }
1448
1449    /// A save writes a record only for a file this pass verified. Records restored beside
1450    /// a snapshot over a subtree whose verification was withdrawn, here by a pass that
1451    /// began over `sub` and has not finished, describe retained facts nobody re-checked,
1452    /// so they stay out of the sidecar while records for the verified rest of the tree
1453    /// are written.
1454    #[test]
1455    fn records_under_an_unverified_subtree_are_not_written() {
1456        let root = tempfile::tempdir().expect("root");
1457        let store = tempfile::tempdir().expect("cache dir");
1458        fs::write(root.path().join("top.md"), "one two\n").expect("write");
1459        fs::create_dir(root.path().join("sub")).expect("mkdir");
1460        fs::write(root.path().join("sub").join("inner.md"), "three\n").expect("write");
1461        let config = ScanConfig::default();
1462        let lines = request_for(AnalysisSet::NONE.with_lines());
1463        let (snapshot_path, cache) = (store.path().join("tree.fdu"), store.path().join("first"));
1464
1465        let (mut scanned, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
1466        super::super::analyze_index(&mut scanned, lines);
1467        crate::snapshot::save(&scanned, &snapshot_path).expect("save snapshot");
1468        save_content_cache(&scanned, &cache).expect("save sidecar");
1469
1470        let mut restored = crate::snapshot::load_with_types(&snapshot_path, config.types_shared())
1471            .expect("load")
1472            .expect("a usable snapshot");
1473        assert_eq!(load(&mut restored, lines, &cache).hits, 2);
1474        crate::scan::reconcile(&mut restored, &config, &mut |_| {}).expect("reconcile");
1475        restored.begin_reconcile(Path::new("sub")).expect("withdraw trust over sub");
1476
1477        let content = restored.content().expect("content");
1478        let writable = |path: &str| {
1479            let record = content.file(Path::new(path)).expect("a restored record");
1480            crate::stored_state::content_record_writable(&restored, Path::new(path), record)
1481        };
1482        assert!(writable("top.md"), "a verified file's record is written");
1483        assert!(!writable("sub/inner.md"), "a record under an unverified subtree is not");
1484
1485        let rewritten = store.path().join("second");
1486        save_content_cache(&restored, &rewritten).expect("resave");
1487        let (mut fresh, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
1488        let loaded = load(&mut fresh, lines, &rewritten);
1489        assert!(loaded.usable && loaded.hits == 1, "only the verified record: {loaded:?}");
1490        let fresh_content = fresh.content().expect("content");
1491        assert!(fresh_content.file(Path::new("top.md")).is_some());
1492        assert!(fresh_content.file(Path::new("sub/inner.md")).is_none());
1493    }
1494
1495    /// Re-address the sidecar's one record, leaving the image otherwise valid.
1496    ///
1497    /// The path is swapped in its stored encoding and the checksum recomputed, so the only
1498    /// thing wrong with the result is where the record claims to live.
1499    fn readdress_record(image: &[u8], from: &Path, to: &Path) -> Vec<u8> {
1500        let encode = |path: &Path| {
1501            let mut bytes = Vec::new();
1502            crate::snapshot::put_os_str(&mut bytes, path.as_os_str()).expect("encode");
1503            bytes
1504        };
1505        let (from, to) = (encode(from), encode(to));
1506        let payload = integrity_payload(image).expect("a valid sidecar");
1507        let at = payload.windows(from.len()).position(|window| window == from).expect("the path");
1508        assert_eq!(
1509            payload.windows(from.len()).rposition(|window| window == from),
1510            Some(at),
1511            "the record's path must appear once, or the rewrite is ambiguous"
1512        );
1513        let mut rewritten = [&payload[..at], to.as_slice(), &payload[at + from.len()..]].concat();
1514        let checksum = crate::snapshot::crc32c(&rewritten);
1515        rewritten.extend_from_slice(&checksum.to_le_bytes());
1516        rewritten.extend_from_slice(TRAILER);
1517        rewritten
1518    }
1519
1520    fn two_record_sidecar() -> (tempfile::TempDir, tempfile::TempDir, AnalysisRequest, PathBuf) {
1521        let root = tempfile::tempdir().expect("root");
1522        let cache_dir = tempfile::tempdir().expect("cache dir");
1523        fs::write(root.path().join("a.md"), "one\n").expect("write first");
1524        fs::write(root.path().join("z.md"), "two\n").expect("write second");
1525        let request = request_for(AnalysisSet::NONE.with_lines());
1526        let (mut index, _) =
1527            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1528        super::super::analyze_index(&mut index, request);
1529        let cache = cache_dir.path().join("content.cache");
1530        save_content_cache(&index, &cache).expect("save");
1531        (root, cache_dir, request, cache)
1532    }
1533
1534    fn assert_late_stream_miss_recovers(
1535        root: &tempfile::TempDir,
1536        request: AnalysisRequest,
1537        cache: &Path,
1538    ) {
1539        let (mut restored, _) =
1540            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1541        assert_eq!(load(&mut restored, request, cache), ContentCacheLoad::default());
1542        assert!(restored.content().is_none(), "a late miss exposes no accepted prefix");
1543        assert!(
1544            restored.content_rollup(Path::new("")).is_none(),
1545            "a late miss exposes no roll-up from the accepted prefix"
1546        );
1547
1548        let analyzed = super::super::analyze_index(&mut restored, request);
1549        assert_eq!(analyzed.applied, 2, "both files are reanalyzed after the miss");
1550        assert_eq!(restored.content().expect("reanalyzed content").len(), 2);
1551        assert_eq!(
1552            restored.content_rollup(Path::new("")).expect("rebuilt root roll-up").total.files,
1553            2
1554        );
1555    }
1556
1557    #[test]
1558    fn malformed_second_record_rolls_back_the_valid_prefix() {
1559        let (root, _cache_dir, request, cache) = two_record_sidecar();
1560        let image = fs::read(&cache).expect("read");
1561        let malformed = readdress_record(&image, Path::new("z.md"), Path::new("../outside.md"));
1562        fs::write(&cache, malformed).expect("rewrite");
1563
1564        assert_late_stream_miss_recovers(&root, request, &cache);
1565    }
1566
1567    #[test]
1568    fn checksummed_trailing_bytes_roll_back_all_records() {
1569        let (root, _cache_dir, request, cache) = two_record_sidecar();
1570        let image = fs::read(&cache).expect("read");
1571        let mut payload = integrity_payload(&image).expect("valid sidecar").to_vec();
1572        payload.extend_from_slice(b"trailing");
1573        let checksum = crate::snapshot::crc32c(&payload);
1574        payload.extend_from_slice(&checksum.to_le_bytes());
1575        payload.extend_from_slice(TRAILER);
1576        fs::write(&cache, payload).expect("rewrite");
1577
1578        assert_late_stream_miss_recovers(&root, request, &cache);
1579    }
1580
1581    /// A sidecar is untrusted, and each record names a path under the root it claims.
1582    ///
1583    /// The guard used to ask `is_absolute`, which is the wrong question for "does this stay
1584    /// inside". `..` is not absolute on any platform, and on Windows neither is a rooted
1585    /// path with no drive (`\rooted`) or a drive-relative one (`C:relative`). A record that
1586    /// leaves the root now makes the whole sidecar a clean miss, like any other malformed
1587    /// image, while an ordinary relative record still restores.
1588    #[test]
1589    fn a_record_that_leaves_the_root_is_a_clean_miss() {
1590        // (record path, whether the sidecar restores)
1591        let mut cases =
1592            vec![("notes.md", true), ("../escape.md", false), ("nested/../../escape.md", false)];
1593        #[cfg(unix)]
1594        cases.push(("/absolute.md", false));
1595        #[cfg(windows)]
1596        cases.extend([
1597            (r"C:\absolute.md", false),
1598            (r"\rooted-without-drive.md", false),
1599            ("C:drive-relative.md", false),
1600        ]);
1601
1602        for (record_path, restores) in cases {
1603            // The rule the parser asks, on the bare path: every component must be normal.
1604            assert_eq!(
1605                record_path_stays_inside_root(Path::new(record_path)),
1606                restores,
1607                "{record_path:?}"
1608            );
1609
1610            let (root, index, request) = analyzed_index();
1611            let cache = root.path().join("content.cache");
1612            save_content_cache(&index, &cache).expect("save");
1613            let image = fs::read(&cache).expect("read");
1614            let rewritten = readdress_record(&image, Path::new("notes.md"), Path::new(record_path));
1615            fs::write(&cache, rewritten).expect("re-address");
1616
1617            let (mut restored, _) =
1618                crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1619            let loaded = load(&mut restored, request, &cache);
1620            if restores {
1621                assert!(loaded.usable, "{record_path:?} must restore: {loaded:?}");
1622                assert_eq!(loaded.hits, 1, "{record_path:?} must restore: {loaded:?}");
1623            } else {
1624                assert_eq!(
1625                    loaded,
1626                    ContentCacheLoad::default(),
1627                    "{record_path:?} leaves the root, so the sidecar must be a clean miss"
1628                );
1629            }
1630        }
1631    }
1632
1633    #[test]
1634    fn sidecar_name_pairs_with_the_metadata_snapshot_name() {
1635        assert_eq!(
1636            content_cache_path(Path::new("0123456789abcdef.metadata.bin")),
1637            PathBuf::from("0123456789abcdef.analysis.bin")
1638        );
1639    }
1640}