1use 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";
25const 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#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
49pub struct ContentCacheLoad {
50 pub usable: bool,
52 pub hits: u64,
54 pub bytes: u64,
56 pub coverage_exclusions: u64,
58 pub stale: u64,
60 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
98pub fn content_cache_path(snapshot_path: &Path) -> PathBuf {
100 crate::cache::CachePaths::from_metadata(snapshot_path).analysis
101}
102
103pub 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 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 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
156pub 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 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 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 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 index.set_content_tier_state(crate::Source::Cached, crate::Freshness::Stale, None);
302 Ok(loaded)
303}
304
305pub(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
332pub(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
361fn 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 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
409fn read_sidecar_header(file: &mut fs::File, header: &mut Vec<u8>) -> io::Result<bool> {
412 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
433fn 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
443fn 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
458fn 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
488fn 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
498fn 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
512struct RecordStream<'a, 'identity> {
514 reader: Reader<'a>,
515 remaining: u64,
516 admission: crate::stored_state::ContentAdmission<'identity>,
517}
518
519fn 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 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
588fn 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 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 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 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 #[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 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 #[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 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 #[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 #[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 const ENGINE_OFFSET: usize = MAGIC.len() + 4;
1309
1310 const PATH_ENCODING_OFFSET: usize = ENGINE_OFFSET + 8;
1312
1313 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 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 #[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 #[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 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 #[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 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 #[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 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 #[test]
1589 fn a_record_that_leaves_the_root_is_a_clean_miss() {
1590 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 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}