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 = 7;
38const CHECKSUM_BYTES: usize = 4;
39const MAX_CACHE_BYTES: u64 = 512 * 1024 * 1024;
40const MAX_RECORDS: u64 = 5_000_000;
41const MAX_PATH_BYTES: usize = 1024 * 1024;
42const MAX_TYPE_BYTES: usize = 256;
43const MAX_ANALYZERS: usize = 16;
44const MAX_ANALYZER_ID_BYTES: usize = 128;
45const MAX_ERROR_BYTES: usize = 512;
46
47#[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 let mut name = snapshot_path.as_os_str().to_os_string();
101 name.push(".content");
102 PathBuf::from(name)
103}
104
105pub fn save_content_cache(index: &Index, path: &Path) -> Result<()> {
113 let Some(content) = index.content() else {
114 return Ok(());
115 };
116 let Some(identity) = content.identity() else {
117 return Ok(());
118 };
119 let wanted = index.content_identity(identity.analysis);
123 let content = content.admit(&wanted).ok_or_else(|| {
124 Error::Snapshot(
125 "content records were produced under another identity than their index".into(),
126 )
127 })?;
128 let identity = content.identity();
129 let records = content
132 .records()
133 .filter(|(path, record)| crate::stored_state::content_record_writable(index, path, record))
134 .collect::<Vec<_>>();
135 let record_count = u64::try_from(records.len())
136 .map_err(|_| Error::Snapshot("content sidecar record count overflow".into()))?;
137 if record_count > MAX_RECORDS {
138 return Err(Error::Snapshot("content sidecar exceeds record limit".into()));
139 }
140
141 let mut buffer = Vec::new();
142 buffer.extend_from_slice(MAGIC);
143 buffer.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
144 buffer.extend_from_slice(&identity.entries.engine.to_le_bytes());
145 buffer.push(crate::snapshot::path_encoding());
146 put_identity(&mut buffer, identity)?;
147 crate::snapshot::put_os_str(&mut buffer, index.root_path().as_os_str())?;
148 buffer.extend_from_slice(&record_count.to_le_bytes());
149 for (relative_path, record) in records {
150 put_record(&mut buffer, relative_path, record)?;
151 }
152 let checksum = crate::snapshot::crc32c(&buffer);
153 buffer.extend_from_slice(&checksum.to_le_bytes());
154 buffer.extend_from_slice(TRAILER);
155 crate::snapshot::write_atomically(path, &buffer)
156}
157
158pub fn load_content_cache(
172 index: &mut Index,
173 wanted: &ContentTierIdentity,
174 path: &Path,
175) -> Result<ContentCacheLoad> {
176 let current = index.content_identity(wanted.analysis);
177 let Some(admission) = current.admit(wanted).filter(|_| wanted.analysis.is_enabled()) else {
178 return Ok(ContentCacheLoad::default());
179 };
180 let wanted = admission.identity();
181 let metadata = match fs::metadata(path) {
182 Ok(metadata) => metadata,
183 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
184 return Ok(ContentCacheLoad::default());
185 }
186 Err(error) => return Err(Error::io(path, error)),
187 };
188 if metadata.len() > MAX_CACHE_BYTES {
189 return Ok(ContentCacheLoad::default());
190 }
191 let read_started = crate::counters::enabled().then(std::time::Instant::now);
192 let image = fs::read(path).map_err(|error| Error::io(path, error))?;
193 crate::counters::add_elapsed(read_started, |counts, elapsed| {
194 counts.content_sidecar_read_us = counts.content_sidecar_read_us.saturating_add(elapsed);
195 });
196 let mut timings = crate::counters::enabled().then(RestoreTimings::default);
197 let parse_started = timings.as_ref().map(|_| Instant::now());
198 let Some(mut stream) = parse_header(&image, index.root_path(), wanted) else {
199 if let (Some(timings), Some(started)) = (&mut timings, parse_started) {
200 timings.add_parse(started);
201 }
202 if let Some(timings) = timings {
203 timings.publish();
204 }
205 return Ok(ContentCacheLoad::default());
206 };
207 if let (Some(timings), Some(started)) = (&mut timings, parse_started) {
208 timings.add_parse(started);
209 }
210 index.prepare_content_analysis(AnalysisRequest {
211 profile: wanted.analysis,
212 ..AnalysisRequest::default()
213 });
214 let candidates_started = crate::counters::enabled().then(std::time::Instant::now);
227 let (mut candidates, visited) = index.restore_analysis_candidates(wanted.analysis);
228 crate::counters::add_elapsed(candidates_started, |counts, elapsed| {
229 counts.content_sidecar_candidates_us =
230 counts.content_sidecar_candidates_us.saturating_add(elapsed);
231 });
232 let mut loaded =
235 ContentCacheLoad { usable: true, candidates: visited, ..ContentCacheLoad::default() };
236 for _ in 0..stream.remaining {
237 let decode_started = timings.as_ref().map(|_| Instant::now());
238 let Some((relative_path, analysis)) = read_record(&mut stream) else {
239 if let (Some(timings), Some(started)) = (&mut timings, decode_started) {
240 timings.add_parse(started);
241 }
242 index.clear_content();
243 if let Some(timings) = timings {
244 timings.publish();
245 }
246 return Ok(ContentCacheLoad::default());
247 };
248 if let (Some(timings), Some(started)) = (&mut timings, decode_started) {
249 timings.add_parse(started);
250 }
251 let apply_started = timings.as_ref().map(|_| Instant::now());
259 match candidates.remove(&relative_path) {
260 Some(candidate)
261 if candidate.attrs.fingerprint() == analysis.value().fingerprint
262 && analysis.value().is_reusable() =>
263 {
264 let coverage_exclusion = analysis_outcomes(analysis.value()).any(|outcome| {
265 !matches!(outcome, CoverageReason::Analyzed | CoverageReason::Binary)
266 });
267 let bytes = analysis.value().bytes;
268 match index.apply_restored_analysis(candidate, analysis) {
269 AnalysisApplyOutcome::Applied => {
270 loaded.hits = loaded.hits.saturating_add(1);
271 loaded.bytes = loaded.bytes.saturating_add(bytes);
272 loaded.coverage_exclusions = loaded
273 .coverage_exclusions
274 .saturating_add(u64::from(coverage_exclusion));
275 }
276 AnalysisApplyOutcome::Stale => {
277 loaded.stale = loaded.stale.saturating_add(1);
278 }
279 }
280 }
281 Some(_) | None => loaded.stale = loaded.stale.saturating_add(1),
282 }
283 if let (Some(timings), Some(started)) = (&mut timings, apply_started) {
284 timings.add_apply(started);
285 }
286 }
287 if !stream.reader.is_empty() {
288 index.clear_content();
289 if let Some(timings) = timings {
290 timings.publish();
291 }
292 return Ok(ContentCacheLoad::default());
293 }
294 let rebuild_started = timings.as_ref().map(|_| Instant::now());
295 index.rebuild_content_rollups();
296 if let (Some(timings), Some(started)) = (&mut timings, rebuild_started) {
297 timings.add_apply(started);
298 }
299 if let Some(timings) = timings {
300 timings.publish();
301 }
302 index.set_content_tier_state(crate::Source::Cached, crate::Freshness::Stale, None);
304 Ok(loaded)
305}
306
307pub(crate) fn content_sidecar_bytes(path: &Path) -> Result<Option<u64>> {
313 let metadata = match fs::symlink_metadata(path) {
314 Ok(metadata) => metadata,
315 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
316 Err(error) => return Err(Error::io(path, error)),
317 };
318 if !metadata.file_type().is_file() {
319 return Ok(None);
320 }
321 let mut file = match fs::File::open(path) {
322 Ok(file) => file,
323 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
324 Err(error) => return Err(Error::io(path, error)),
325 };
326 let mut magic = [0u8; MAGIC.len()];
327 match file.read_exact(&mut magic) {
328 Ok(()) => Ok((&magic == MAGIC).then_some(metadata.len())),
329 Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None),
330 Err(error) => Err(Error::io(path, error)),
331 }
332}
333
334pub(crate) fn identify_sidecar(path: &Path) -> Result<Option<crate::cache::ContentStatus>> {
346 let metadata = match fs::symlink_metadata(path) {
347 Ok(metadata) => metadata,
348 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
349 Err(error) => return Err(Error::io(path, error)),
350 };
351 if !metadata.file_type().is_file() {
352 return Ok(None);
353 }
354 let mut file = match fs::File::open(path) {
355 Ok(file) => file,
356 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
357 Err(error) => return Err(Error::io(path, error)),
358 };
359 let state = identify_sidecar_contents(&mut file).map_err(|error| Error::io(path, error))?;
360 Ok(state.map(|state| crate::cache::ContentStatus { bytes: metadata.len(), state }))
361}
362
363fn identify_sidecar_contents(
366 file: &mut fs::File,
367) -> io::Result<Option<crate::cache::ContentState>> {
368 use crate::cache::{ContentInfo, ContentState, StaleReason};
369
370 let stale = |reason| Ok(Some(ContentState::Stale(reason)));
371 let trailer_intact = sidecar_trailer_intact(file)?;
372 file.seek(SeekFrom::Start(0))?;
373 let mut header = Vec::new();
374 if !read_more(file, &mut header, MAGIC.len())? || header != MAGIC {
375 return Ok(None);
376 }
377 if !read_more(file, &mut header, 4)? {
378 return stale(StaleReason::Unreadable);
379 }
380 let version = u32::from_le_bytes(header[MAGIC.len()..].try_into().expect("four bytes"));
381 match version.cmp(&FORMAT_VERSION) {
382 std::cmp::Ordering::Less => return stale(StaleReason::OlderFormat { version }),
383 std::cmp::Ordering::Greater => return stale(StaleReason::NewerFormat { version }),
384 std::cmp::Ordering::Equal => {}
385 }
386 if !read_more(file, &mut header, 8)? {
387 return stale(StaleReason::Unreadable);
388 }
389 let engine = u64::from_le_bytes(header[MAGIC.len() + 4..].try_into().expect("eight bytes"));
390 if engine != crate::snapshot::engine_fingerprint() {
391 return stale(StaleReason::OtherEngine);
392 }
393 if !trailer_intact || !read_sidecar_header(file, &mut header)? {
396 return stale(StaleReason::Unreadable);
397 }
398 let parsed = (|| {
399 let mut reader = Reader::new(header.get(MAGIC.len() + 4 + 8..)?);
400 if reader.u8()? != crate::snapshot::path_encoding() {
401 return None;
402 }
403 let identity = read_identity(&mut reader, engine)?;
404 reader.os_string()?;
405 let records = reader.u64()?;
406 (records <= MAX_RECORDS && reader.is_empty()).then_some(ContentInfo { identity, records })
407 })();
408 Ok(Some(parsed.map_or(ContentState::Stale(StaleReason::Unreadable), ContentState::Current)))
409}
410
411fn read_sidecar_header(file: &mut fs::File, header: &mut Vec<u8>) -> io::Result<bool> {
414 if !read_more(file, header, 1 + ENTRY_TIER_BYTES + 1 + 8 + 1)? {
417 return Ok(false);
418 }
419 let analyzers = usize::from(*header.last().expect("the analyzer count"));
420 if analyzers > MAX_ANALYZERS {
421 return Ok(false);
422 }
423 for _ in 0..analyzers {
424 let Some(length) = read_length(file, header, MAX_ANALYZER_ID_BYTES)? else {
425 return Ok(false);
426 };
427 if !read_more(file, header, length + 2)? {
428 return Ok(false);
429 }
430 }
431 let Some(root) = read_length(file, header, MAX_PATH_BYTES)? else { return Ok(false) };
432 Ok(read_more(file, header, root)? && read_more(file, header, 8)?)
433}
434
435fn read_length(file: &mut fs::File, header: &mut Vec<u8>, max: usize) -> io::Result<Option<usize>> {
438 if !read_more(file, header, 4)? {
439 return Ok(None);
440 }
441 let bytes = header[header.len() - 4..].try_into().expect("four bytes");
442 Ok(usize::try_from(u32::from_le_bytes(bytes)).ok().filter(|length| *length <= max))
443}
444
445fn read_more(file: &mut fs::File, buffer: &mut Vec<u8>, count: usize) -> io::Result<bool> {
448 let start = buffer.len();
449 buffer.resize(start + count, 0);
450 match file.read_exact(&mut buffer[start..]) {
451 Ok(()) => Ok(true),
452 Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => {
453 buffer.truncate(start);
454 Ok(false)
455 }
456 Err(error) => Err(error),
457 }
458}
459
460fn sidecar_trailer_intact(file: &mut fs::File) -> io::Result<bool> {
462 let footer = u64::try_from(CHECKSUM_BYTES + TRAILER.len()).expect("a small footer");
463 if file.metadata()?.len() < footer {
464 return Ok(false);
465 }
466 file.seek(SeekFrom::End(-i64::try_from(TRAILER.len()).expect("a small trailer")))?;
467 let mut trailer = [0u8; TRAILER.len()];
468 match file.read_exact(&mut trailer) {
469 Ok(()) => Ok(&trailer == TRAILER),
470 Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(false),
471 Err(error) => Err(error),
472 }
473}
474
475fn put_record(buffer: &mut Vec<u8>, path: &Path, record: &FileAnalysis) -> Result<()> {
476 crate::snapshot::put_os_str(buffer, path.as_os_str())?;
477 put_fingerprint(buffer, record.fingerprint);
478 buffer.extend_from_slice(&record.bytes.to_le_bytes());
479 put_bounded_bytes(buffer, record.detection.file_type.as_str().as_bytes(), MAX_TYPE_BYTES)?;
480 buffer.push(family_code(record.detection.family));
481 buffer.push(source_code(record.detection.source));
482 buffer.push(confidence_code(record.detection.confidence));
483 buffer.push(flags_code(record.detection.flags));
484 put_outcome(buffer, record.lines, put_basic_metrics);
485 put_optional_outcome(buffer, record.code, put_code_metrics);
486 put_optional_outcome(buffer, record.words, put_word_metrics);
487 put_bounded_bytes(buffer, record.error.as_deref().unwrap_or("").as_bytes(), MAX_ERROR_BYTES)
488}
489
490fn put_identity(buffer: &mut Vec<u8>, identity: &ContentTierIdentity) -> Result<()> {
494 buffer.extend_from_slice(&identity.entries.encode());
495 put_profile(buffer, identity.analysis);
496 buffer.extend_from_slice(&identity.provenance.options_fingerprint.0.to_le_bytes());
497 put_analyzers(buffer, &identity.provenance.analyzers)
498}
499
500fn read_identity(reader: &mut Reader<'_>, engine: u64) -> Option<ContentTierIdentity> {
502 let entries =
503 EntryTierIdentity::decode(engine, reader.take(ENTRY_TIER_BYTES)?.try_into().ok()?)?;
504 Some(ContentTierIdentity {
505 entries,
506 analysis: read_profile(reader.u8()?)?,
507 provenance: AnalyzerProvenance {
508 options_fingerprint: super::OptionsFingerprint(reader.u64()?),
509 analyzers: read_analyzers(reader)?,
510 },
511 })
512}
513
514struct RecordStream<'a, 'identity> {
516 reader: Reader<'a>,
517 remaining: u64,
518 admission: crate::stored_state::ContentAdmission<'identity>,
519}
520
521fn parse_header<'a, 'identity>(
523 image: &'a [u8],
524 root: &Path,
525 wanted: &'identity ContentTierIdentity,
526) -> Option<RecordStream<'a, 'identity>> {
527 let payload = integrity_payload(image)?;
528 let mut reader = Reader::new(payload.get(MAGIC.len()..)?);
529 if reader.u32()? != FORMAT_VERSION {
530 return None;
531 }
532 let engine = reader.u64()?;
533 if reader.u8()? != crate::snapshot::path_encoding() {
534 return None;
535 }
536 let identity = read_identity(&mut reader, engine)?;
540 let admission = wanted.admit(&identity)?;
541 if reader.os_string()?.as_os_str() != root.as_os_str() {
542 return None;
543 }
544 let count = reader.u64()?;
545 if count > MAX_RECORDS {
546 return None;
547 }
548 Some(RecordStream { reader, remaining: count, admission })
549}
550
551fn read_record<'identity>(
552 stream: &mut RecordStream<'_, 'identity>,
553) -> Option<(PathBuf, crate::stored_state::AdmittedRecord<'identity>)> {
554 let relative_path = PathBuf::from(stream.reader.os_string()?);
555 if !record_path_stays_inside_root(&relative_path) {
556 return None;
557 }
558 let fingerprint = read_fingerprint(&mut stream.reader)?;
559 let bytes = stream.reader.u64()?;
560 let file_type = String::from_utf8(stream.reader.bytes(MAX_TYPE_BYTES)?).ok()?;
561 if file_type.is_empty() {
562 return None;
563 }
564 let detection = ContentDetection {
565 file_type: FileTypeId::from_cache(file_type),
566 family: read_family(stream.reader.u8()?)?,
567 source: read_source(stream.reader.u8()?)?,
568 confidence: read_confidence(stream.reader.u8()?)?,
569 flags: read_flags(stream.reader.u8()?)?,
570 };
571 let lines = read_outcome(&mut stream.reader, read_basic_metrics)?;
572 let code = read_optional_outcome(&mut stream.reader, read_code_metrics)?;
573 let words = read_optional_outcome(&mut stream.reader, read_word_metrics)?;
574 let error = String::from_utf8(stream.reader.bytes(MAX_ERROR_BYTES)?).ok()?;
575 stream.remaining = stream.remaining.saturating_sub(1);
576 Some((
577 relative_path,
578 stream.admission.record(FileAnalysis {
579 fingerprint,
580 bytes,
581 detection,
582 lines,
583 code,
584 words,
585 error: (!error.is_empty()).then_some(error),
586 })?,
587 ))
588}
589
590fn record_path_stays_inside_root(path: &Path) -> bool {
602 path.components().all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
603}
604
605fn integrity_payload(image: &[u8]) -> Option<&[u8]> {
606 let footer = CHECKSUM_BYTES.checked_add(TRAILER.len())?;
607 if image.len() < MAGIC.len() + footer || image.get(..MAGIC.len())? != MAGIC {
608 return None;
609 }
610 let payload_len = image.len().checked_sub(footer)?;
611 if image.get(payload_len + CHECKSUM_BYTES..)? != TRAILER {
612 return None;
613 }
614 let expected_checksum =
615 u32::from_le_bytes(image.get(payload_len..payload_len + CHECKSUM_BYTES)?.try_into().ok()?);
616 let payload = image.get(..payload_len)?;
617 (crate::snapshot::crc32c(payload) == expected_checksum).then_some(payload)
618}
619
620fn put_fingerprint(buffer: &mut Vec<u8>, value: Fingerprint) {
621 buffer.extend_from_slice(&value.size.to_le_bytes());
622 buffer.extend_from_slice(&value.mtime_ns.to_le_bytes());
623 buffer.extend_from_slice(&value.ctime_ns.to_le_bytes());
624 buffer.extend_from_slice(&value.inode.to_le_bytes());
625 buffer.extend_from_slice(&value.dev.to_le_bytes());
626}
627
628fn read_fingerprint(reader: &mut Reader<'_>) -> Option<Fingerprint> {
629 Some(Fingerprint {
630 size: reader.u64()?,
631 mtime_ns: reader.i64()?,
632 ctime_ns: reader.i64()?,
633 inode: reader.u64()?,
634 dev: reader.u64()?,
635 })
636}
637
638fn put_basic_metrics(buffer: &mut Vec<u8>, value: BasicMetrics) {
639 for metric in [value.physical_lines, value.blank_lines, value.nonblank_lines, value.raw_words] {
640 buffer.extend_from_slice(&metric.to_le_bytes());
641 }
642}
643
644fn read_basic_metrics(reader: &mut Reader<'_>) -> Option<BasicMetrics> {
645 Some(BasicMetrics {
646 physical_lines: reader.u64()?,
647 blank_lines: reader.u64()?,
648 nonblank_lines: reader.u64()?,
649 raw_words: reader.u64()?,
650 })
651}
652
653fn put_code_metrics(buffer: &mut Vec<u8>, value: CodeMetrics) {
654 for metric in [value.code_lines, value.comment_lines, value.code_blank_lines] {
655 buffer.extend_from_slice(&metric.to_le_bytes());
656 }
657}
658
659fn read_code_metrics(reader: &mut Reader<'_>) -> Option<CodeMetrics> {
660 Some(CodeMetrics {
661 code_lines: reader.u64()?,
662 comment_lines: reader.u64()?,
663 code_blank_lines: reader.u64()?,
664 })
665}
666
667fn put_word_metrics(buffer: &mut Vec<u8>, value: WordMetrics) {
668 for metric in [
669 value.paragraphs,
670 value.visible_words,
671 value.logical_word_stats.wide_chars,
672 value.logical_word_stats.nonwide_tokens,
673 value.logical_word_stats.nonwide_chars,
674 value.visible_logical_word_stats.wide_chars,
675 value.visible_logical_word_stats.nonwide_tokens,
676 value.visible_logical_word_stats.nonwide_chars,
677 ] {
678 buffer.extend_from_slice(&metric.to_le_bytes());
679 }
680}
681
682fn read_word_metrics(reader: &mut Reader<'_>) -> Option<WordMetrics> {
683 Some(WordMetrics {
684 paragraphs: reader.u64()?,
685 visible_words: reader.u64()?,
686 logical_word_stats: LogicalWordStats {
687 wide_chars: reader.u64()?,
688 nonwide_tokens: reader.u64()?,
689 nonwide_chars: reader.u64()?,
690 },
691 visible_logical_word_stats: LogicalWordStats {
692 wide_chars: reader.u64()?,
693 nonwide_tokens: reader.u64()?,
694 nonwide_chars: reader.u64()?,
695 },
696 })
697}
698
699fn put_outcome<T: Copy>(
700 buffer: &mut Vec<u8>,
701 outcome: AnalyzerOutcome<T>,
702 put_value: fn(&mut Vec<u8>, T),
703) {
704 buffer.push(coverage_code(outcome.coverage()));
705 if let Some(value) = outcome.value() {
706 put_value(buffer, value);
707 }
708}
709
710fn read_outcome<T>(
711 reader: &mut Reader<'_>,
712 read_value: fn(&mut Reader<'_>) -> Option<T>,
713) -> Option<AnalyzerOutcome<T>> {
714 let coverage = read_coverage(reader.u8()?)?;
715 let value = if coverage == CoverageReason::Analyzed { Some(read_value(reader)?) } else { None };
716 AnalyzerOutcome::from_parts(coverage, value)
717}
718
719fn put_optional_outcome<T: Copy>(
720 buffer: &mut Vec<u8>,
721 outcome: Option<AnalyzerOutcome<T>>,
722 put_value: fn(&mut Vec<u8>, T),
723) {
724 buffer.push(u8::from(outcome.is_some()));
725 if let Some(outcome) = outcome {
726 put_outcome(buffer, outcome, put_value);
727 }
728}
729
730#[expect(
731 clippy::option_option,
732 reason = "Outer None rejects malformed bytes; inner None is an absent unit."
733)]
734fn read_optional_outcome<T>(
735 reader: &mut Reader<'_>,
736 read_value: fn(&mut Reader<'_>) -> Option<T>,
737) -> Option<Option<AnalyzerOutcome<T>>> {
738 match reader.u8()? {
739 0 => Some(None),
740 1 => Some(Some(read_outcome(reader, read_value)?)),
741 _ => None,
742 }
743}
744
745fn analysis_outcomes(analysis: &FileAnalysis) -> impl Iterator<Item = CoverageReason> + '_ {
746 std::iter::once(analysis.lines.coverage())
747 .chain(analysis.code.map(|outcome| outcome.coverage()))
748 .chain(analysis.words.map(|outcome| outcome.coverage()))
749}
750
751fn put_analyzers(buffer: &mut Vec<u8>, analyzers: &[(AnalyzerId, AnalyzerVersion)]) -> Result<()> {
752 let count = u8::try_from(analyzers.len())
753 .map_err(|_| Error::Snapshot("too many content analyzers".into()))?;
754 buffer.push(count);
755 for (id, version) in analyzers {
756 put_bounded_bytes(buffer, id.0.as_bytes(), MAX_ANALYZER_ID_BYTES)?;
757 buffer.extend_from_slice(&version.0.to_le_bytes());
758 }
759 Ok(())
760}
761
762fn read_analyzers(reader: &mut Reader<'_>) -> Option<Vec<(AnalyzerId, AnalyzerVersion)>> {
763 let count = usize::from(reader.u8()?);
764 if count > MAX_ANALYZERS {
765 return None;
766 }
767 let mut analyzers = Vec::with_capacity(count);
768 for _ in 0..count {
769 let id = String::from_utf8(reader.bytes(MAX_ANALYZER_ID_BYTES)?).ok()?;
770 let known = match id.as_str() {
771 "content-basic-v1" => super::CONTENT_BASIC,
772 "code-sloc-v1" => super::CODE_SLOC,
773 "text-logical-v1" => super::TEXT_LOGICAL,
774 "markdown-prose-v1" => super::MARKDOWN_PROSE,
775 _ => return None,
776 };
777 analyzers.push((known, AnalyzerVersion(reader.u16()?)));
778 }
779 Some(analyzers)
780}
781
782fn put_bounded_bytes(buffer: &mut Vec<u8>, bytes: &[u8], max: usize) -> Result<()> {
783 if bytes.len() > max {
784 return Err(Error::Snapshot("content sidecar string exceeds limit".into()));
785 }
786 let length = u32::try_from(bytes.len())
787 .map_err(|_| Error::Snapshot("content sidecar string length overflow".into()))?;
788 buffer.extend_from_slice(&length.to_le_bytes());
789 buffer.extend_from_slice(bytes);
790 Ok(())
791}
792
793fn put_profile(buffer: &mut Vec<u8>, profile: AnalysisSet) {
794 buffer.push(profile.bits());
795}
796
797fn read_profile(code: u8) -> Option<AnalysisSet> {
798 AnalysisSet::from_bits(code)
799}
800
801fn family_code(value: ContentFamily) -> u8 {
802 match value {
803 ContentFamily::Code => 0,
804 ContentFamily::Prose => 1,
805 ContentFamily::Markup => 2,
806 ContentFamily::Data => 3,
807 ContentFamily::Binary => 4,
808 ContentFamily::Unknown => 5,
809 }
810}
811
812fn read_family(code: u8) -> Option<ContentFamily> {
813 match code {
814 0 => Some(ContentFamily::Code),
815 1 => Some(ContentFamily::Prose),
816 2 => Some(ContentFamily::Markup),
817 3 => Some(ContentFamily::Data),
818 4 => Some(ContentFamily::Binary),
819 5 => Some(ContentFamily::Unknown),
820 _ => None,
821 }
822}
823
824fn source_code(value: DetectionSource) -> u8 {
825 match value {
826 DetectionSource::ExactFilename => 0,
827 DetectionSource::CompoundExtension => 1,
828 DetectionSource::Extension => 2,
829 DetectionSource::Shebang => 3,
830 DetectionSource::ContentProbe => 4,
831 DetectionSource::Unknown => 5,
832 DetectionSource::Modeline => 6,
833 DetectionSource::AmbiguousContent => 7,
834 DetectionSource::FormatSignature => 8,
835 }
836}
837
838fn read_source(code: u8) -> Option<DetectionSource> {
839 match code {
840 0 => Some(DetectionSource::ExactFilename),
841 1 => Some(DetectionSource::CompoundExtension),
842 2 => Some(DetectionSource::Extension),
843 3 => Some(DetectionSource::Shebang),
844 4 => Some(DetectionSource::ContentProbe),
845 5 => Some(DetectionSource::Unknown),
846 6 => Some(DetectionSource::Modeline),
847 7 => Some(DetectionSource::AmbiguousContent),
848 8 => Some(DetectionSource::FormatSignature),
849 _ => None,
850 }
851}
852
853fn flags_code(value: ClassificationFlags) -> u8 {
854 u8::from(value.generated)
855 | (u8::from(value.vendored) << 1)
856 | (u8::from(value.documentation) << 2)
857}
858
859fn read_flags(code: u8) -> Option<ClassificationFlags> {
860 (code & !0b111 == 0).then_some(ClassificationFlags {
861 generated: code & 0b001 != 0,
862 vendored: code & 0b010 != 0,
863 documentation: code & 0b100 != 0,
864 })
865}
866
867fn confidence_code(value: DetectionConfidence) -> u8 {
868 match value {
869 DetectionConfidence::Certain => 0,
870 DetectionConfidence::High => 1,
871 DetectionConfidence::Heuristic => 2,
872 }
873}
874
875fn read_confidence(code: u8) -> Option<DetectionConfidence> {
876 match code {
877 0 => Some(DetectionConfidence::Certain),
878 1 => Some(DetectionConfidence::High),
879 2 => Some(DetectionConfidence::Heuristic),
880 _ => None,
881 }
882}
883
884fn coverage_code(value: CoverageReason) -> u8 {
885 match value {
886 CoverageReason::Analyzed => 0,
887 CoverageReason::Binary => 1,
888 CoverageReason::InvalidUtf8 => 2,
889 CoverageReason::UnsupportedEncoding => 3,
890 CoverageReason::Unsupported => 4,
891 CoverageReason::IoError => 5,
892 CoverageReason::ChangedDuringRead => 6,
893 }
894}
895
896fn read_coverage(code: u8) -> Option<CoverageReason> {
897 match code {
898 0 => Some(CoverageReason::Analyzed),
899 1 => Some(CoverageReason::Binary),
900 2 => Some(CoverageReason::InvalidUtf8),
901 3 => Some(CoverageReason::UnsupportedEncoding),
902 4 => Some(CoverageReason::Unsupported),
903 5 => Some(CoverageReason::IoError),
904 6 => Some(CoverageReason::ChangedDuringRead),
905 _ => None,
906 }
907}
908
909struct Reader<'a> {
910 remaining: &'a [u8],
911}
912
913impl<'a> Reader<'a> {
914 fn new(remaining: &'a [u8]) -> Self {
915 Self { remaining }
916 }
917
918 fn take(&mut self, count: usize) -> Option<&'a [u8]> {
919 let (value, rest) = self.remaining.split_at_checked(count)?;
920 self.remaining = rest;
921 Some(value)
922 }
923
924 fn u8(&mut self) -> Option<u8> {
925 self.take(1).map(|value| value[0])
926 }
927
928 fn u16(&mut self) -> Option<u16> {
929 Some(u16::from_le_bytes(self.take(2)?.try_into().ok()?))
930 }
931
932 fn u32(&mut self) -> Option<u32> {
933 Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
934 }
935
936 fn u64(&mut self) -> Option<u64> {
937 Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
938 }
939
940 fn i64(&mut self) -> Option<i64> {
941 Some(i64::from_le_bytes(self.take(8)?.try_into().ok()?))
942 }
943
944 fn bytes(&mut self, max: usize) -> Option<Vec<u8>> {
945 let length = usize::try_from(self.u32()?).ok()?;
946 (length <= max).then(|| self.take(length).map(<[u8]>::to_vec)).flatten()
947 }
948
949 fn os_string(&mut self) -> Option<OsString> {
950 let bytes = self.bytes(MAX_PATH_BYTES)?;
951 #[cfg(unix)]
952 {
953 Some(decode_os_string(&bytes))
954 }
955 #[cfg(not(unix))]
956 {
957 decode_os_string(&bytes)
958 }
959 }
960
961 fn is_empty(&self) -> bool {
962 self.remaining.is_empty()
963 }
964}
965
966#[cfg(unix)]
967fn decode_os_string(bytes: &[u8]) -> OsString {
968 use std::os::unix::ffi::OsStringExt;
969 OsString::from_vec(bytes.to_vec())
970}
971
972#[cfg(windows)]
973fn decode_os_string(bytes: &[u8]) -> Option<OsString> {
974 use std::os::windows::ffi::OsStringExt;
975 if bytes.len() % 2 != 0 {
979 return None;
980 }
981 let units = bytes
982 .chunks_exact(2)
983 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
984 .collect::<Vec<_>>();
985 Some(OsString::from_wide(&units))
986}
987
988#[cfg(not(any(unix, windows)))]
989fn decode_os_string(bytes: &[u8]) -> Option<OsString> {
990 String::from_utf8(bytes.to_vec()).ok().map(OsString::from)
991}
992
993#[cfg(test)]
994mod tests {
995 use super::*;
996 use std::fs;
997
998 use crate::scan::ScanConfig;
999
1000 fn analyzed_index() -> (tempfile::TempDir, Index, AnalysisRequest) {
1001 let root = tempfile::tempdir().expect("root");
1002 fs::write(root.path().join("notes.md"), "<!-- @generated -->\none two\n").expect("write");
1003 let (mut index, _) =
1004 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1005 let request = AnalysisRequest {
1006 profile: AnalysisSet::NONE.with_lines(),
1007 ..AnalysisRequest::default()
1008 };
1009 super::super::analyze_index(&mut index, request);
1010 (root, index, request)
1011 }
1012
1013 fn containment_fixture(profile: AnalysisSet) -> (tempfile::TempDir, tempfile::TempDir, Index) {
1019 let root = tempfile::tempdir().expect("root");
1020 let cache_dir = tempfile::tempdir().expect("cache dir");
1021 fs::write(root.path().join("notes.md"), "<!-- @generated -->\none two\n").expect("write");
1022 fs::write(root.path().join("main.rs"), "fn main() {\n // hi\n}\n").expect("write");
1023 let (mut index, _) =
1024 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1025 super::super::analyze_index(&mut index, request_for(profile));
1026 (root, cache_dir, index)
1027 }
1028
1029 fn request_for(profile: AnalysisSet) -> AnalysisRequest {
1030 AnalysisRequest { profile, ..AnalysisRequest::default() }
1031 }
1032
1033 fn load(index: &mut Index, request: AnalysisRequest, cache: &Path) -> ContentCacheLoad {
1035 let wanted = index.content_identity(request.profile);
1036 load_content_cache(index, &wanted, cache).expect("load")
1037 }
1038
1039 #[test]
1040 fn fractional_record_durations_are_converted_after_accumulation() {
1041 let mut total = Duration::ZERO;
1042 for _ in 0..1_000 {
1043 add_duration(&mut total, Duration::from_nanos(900));
1044 }
1045
1046 assert_eq!(duration_micros(total), 900);
1047 assert_eq!(duration_micros(Duration::from_nanos(900)), 0);
1048 }
1049
1050 #[test]
1051 fn restored_sidecar_does_not_claim_its_container_mtime_as_observation_time() {
1052 let (root, analyzed, request) = analyzed_index();
1053 let cache_dir = tempfile::tempdir().expect("cache dir");
1054 let cache = cache_dir.path().join("content.cache");
1055 save_content_cache(&analyzed, &cache).expect("save");
1056 let (mut restored, _) =
1057 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1058
1059 let loaded = load(&mut restored, request, &cache);
1060
1061 assert!(loaded.usable && loaded.hits == 1, "{loaded:?}");
1062 let state =
1063 restored.content().and_then(super::super::ContentIndex::state).expect("tier state");
1064 assert_eq!(state.source, crate::Source::Cached);
1065 assert_eq!(state.observed_at_ns, None, "the sidecar stores no observation instant");
1066 }
1067
1068 #[test]
1073 fn sidecar_restore_rebuilds_nested_directory_rollups() {
1074 let root = tempfile::tempdir().expect("root");
1075 let cache_dir = tempfile::tempdir().expect("cache dir");
1076 fs::create_dir_all(root.path().join("a/b")).expect("dirs");
1077 fs::write(root.path().join("notes.md"), "one two\n").expect("write");
1078 fs::write(root.path().join("a/keep.rs"), "fn keep() {}\n").expect("write");
1079 fs::write(root.path().join("a/b/nested.rs"), "fn nested() {}\n").expect("write");
1080 let request = request_for(AnalysisSet::NONE.with_lines());
1081 let (mut analyzed, _) =
1082 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1083 super::super::analyze_index(&mut analyzed, request);
1084 let cache = cache_dir.path().join("content.cache");
1085 save_content_cache(&analyzed, &cache).expect("save");
1086
1087 let (mut restored, _) =
1088 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1089 let loaded = load(&mut restored, request, &cache);
1090 assert!(loaded.usable && loaded.hits == 3, "{loaded:?}");
1091 for dir in ["", "a", "a/b"] {
1092 assert_eq!(
1093 restored.content_rollup(Path::new(dir)),
1094 analyzed.content_rollup(Path::new(dir)),
1095 "{dir:?} roll-up"
1096 );
1097 }
1098 }
1099
1100 #[test]
1101 fn operational_failure_in_a_valid_sidecar_is_not_reused() {
1102 let (root, analyzed, request) = analyzed_index();
1103 let store = tempfile::tempdir().expect("cache dir");
1104 let cache = store.path().join("content.cache");
1105 let identity = analyzed.content().and_then(|content| content.identity()).expect("identity");
1106 let mut record = analyzed
1107 .content()
1108 .and_then(|content| content.file(Path::new("notes.md")))
1109 .cloned()
1110 .expect("record");
1111 record.lines = AnalyzerOutcome::unavailable(CoverageReason::IoError);
1112 record.error = Some("injected failure".into());
1113
1114 let mut image = Vec::new();
1118 image.extend_from_slice(MAGIC);
1119 image.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
1120 image.extend_from_slice(&identity.entries.engine.to_le_bytes());
1121 image.push(crate::snapshot::path_encoding());
1122 put_identity(&mut image, identity).expect("identity");
1123 crate::snapshot::put_os_str(&mut image, analyzed.root_path().as_os_str()).expect("root");
1124 image.extend_from_slice(&1_u64.to_le_bytes());
1125 put_record(&mut image, Path::new("notes.md"), &record).expect("record");
1126 let checksum = crate::snapshot::crc32c(&image);
1127 image.extend_from_slice(&checksum.to_le_bytes());
1128 image.extend_from_slice(TRAILER);
1129 fs::write(&cache, image).expect("write sidecar");
1130
1131 let (mut restored, _) =
1132 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1133 let loaded = load(&mut restored, request, &cache);
1134 assert!(loaded.usable, "the sidecar identity itself remains usable");
1135 assert_eq!((loaded.hits, loaded.stale), (0, 1));
1136 assert!(
1137 restored
1138 .content()
1139 .expect("prepared content tier")
1140 .file(Path::new("notes.md"))
1141 .is_none()
1142 );
1143 assert_eq!(restored.pending_analysis_candidates(request).len(), 1);
1144 }
1145
1146 #[test]
1147 fn unsupported_encoding_outcomes_round_trip_through_the_sidecar() {
1148 let root = tempfile::tempdir().expect("root");
1149 let store = tempfile::tempdir().expect("cache dir");
1150 fs::write(root.path().join("wide.rs"), [0xff, 0xfe, b'f', 0, b'n', 0])
1151 .expect("UTF-16 fixture");
1152 let request = request_for(AnalysisSet::ALL);
1153 let (mut analyzed, _) =
1154 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1155 super::super::analyze_index(&mut analyzed, request);
1156 let expected = analyzed
1157 .content()
1158 .and_then(|content| content.file(Path::new("wide.rs")))
1159 .cloned()
1160 .expect("analyzed record");
1161 let cache = store.path().join("content.cache");
1162 save_content_cache(&analyzed, &cache).expect("save");
1163
1164 let (mut restored, _) =
1165 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1166 let loaded = load(&mut restored, request, &cache);
1167 assert!(loaded.usable && loaded.hits == 1, "{loaded:?}");
1168 let actual = restored
1169 .content()
1170 .and_then(|content| content.file(Path::new("wide.rs")))
1171 .expect("restored record");
1172 assert_eq!(actual, &expected);
1173 assert_eq!(actual.lines.coverage(), CoverageReason::UnsupportedEncoding);
1174 assert_eq!(
1175 actual.code.expect("code outcome").coverage(),
1176 CoverageReason::UnsupportedEncoding
1177 );
1178 assert_eq!(
1179 actual.words.expect("word outcome").coverage(),
1180 CoverageReason::UnsupportedEncoding
1181 );
1182 }
1183
1184 #[test]
1189 fn a_wider_sidecar_is_a_clean_miss_for_a_narrower_request() {
1190 let (root, cache_dir, index) = containment_fixture(AnalysisSet::ALL);
1191 let cache = cache_dir.path().join("content.cache");
1192 save_content_cache(&index, &cache).expect("save");
1193
1194 let narrower = request_for(AnalysisSet::NONE.with_code());
1195 let (mut restored, _) =
1196 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1197 let loaded = load(&mut restored, narrower, &cache);
1198
1199 assert_eq!(loaded, ContentCacheLoad::default(), "a wider sidecar must miss");
1200 assert_eq!(
1201 restored.pending_analysis_candidates(narrower).len(),
1202 2,
1203 "a missed request reads every file"
1204 );
1205 assert!(restored.content().is_none(), "a miss leaves no content tier behind");
1206
1207 let (mut cold, _) =
1212 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("cold scan");
1213 super::super::analyze_index(&mut cold, narrower);
1214 super::super::analyze_index(&mut restored, narrower);
1215 let values = |index: &Index| {
1216 let query = crate::query::Query {
1217 views: vec![crate::query::ViewSpec::Types],
1218 ..crate::query::Query::default()
1219 };
1220 let report = crate::query::report(
1221 index,
1222 &crate::test_support::read_of(index, query),
1223 std::time::UNIX_EPOCH,
1224 )
1225 .expect("metric report");
1226 let crate::query::Section::Metrics { summary, .. } = &report.sections[0] else {
1227 panic!("expected type metrics");
1228 };
1229 std::iter::once(&summary.total)
1230 .chain(&summary.rows)
1231 .map(|row| {
1232 (
1233 row.id.clone(),
1234 (
1235 row.analysis,
1236 row.metrics,
1237 row.lines_coverage.clone(),
1238 row.code_coverage.clone(),
1239 row.words_coverage.clone(),
1240 ),
1241 )
1242 })
1243 .collect::<std::collections::BTreeMap<_, _>>()
1244 };
1245 assert_eq!(values(&restored), values(&cold), "wider cache history changes no row value");
1246 }
1247
1248 #[test]
1251 fn another_analyzer_set_is_a_clean_miss() {
1252 let code = AnalysisSet::NONE.with_code();
1253 let words = AnalysisSet::NONE.with_words();
1254 for (stored, wanted) in [(code, AnalysisSet::ALL), (code, words), (words, code)] {
1255 let (root, cache_dir, index) = containment_fixture(stored);
1256 let cache = cache_dir.path().join("content.cache");
1257 save_content_cache(&index, &cache).expect("save");
1258
1259 let (mut restored, _) =
1260 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1261 assert_eq!(
1262 load(&mut restored, request_for(wanted), &cache),
1263 ContentCacheLoad::default(),
1264 "a {stored:?} sidecar must miss a {wanted:?} request"
1265 );
1266 let (mut same, _) =
1267 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1268 let hit = load(&mut same, request_for(stored), &cache);
1269 assert!(hit.usable && hit.hits == 2, "its own set still restores: {hit:?}");
1270 }
1271 }
1272
1273 #[test]
1276 fn a_different_analyzer_set_replaces_the_sidecar() {
1277 let (root, cache_dir, index) = containment_fixture(AnalysisSet::ALL);
1278 let cache = cache_dir.path().join("content.cache");
1279 save_content_cache(&index, &cache).expect("save");
1280
1281 let narrower = request_for(AnalysisSet::NONE.with_code());
1282 let (mut restored, _) =
1283 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1284 load(&mut restored, narrower, &cache);
1285 let analysis = super::super::analyze_index(&mut restored, narrower);
1286 assert_eq!(analysis.applied, 2, "the narrower run reads every file");
1287 save_content_cache(&restored, &cache).expect("resave");
1288
1289 let (mut wide, _) =
1290 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1291 assert_eq!(
1292 load(&mut wide, request_for(AnalysisSet::ALL), &cache),
1293 ContentCacheLoad::default(),
1294 "the wider set was replaced"
1295 );
1296 let (mut narrow, _) =
1297 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1298 let hit = load(&mut narrow, narrower, &cache);
1299 assert!(hit.usable && hit.hits == 2, "by the narrower one: {hit:?}");
1300 }
1301
1302 const ENGINE_OFFSET: usize = MAGIC.len() + 4;
1305
1306 const PATH_ENCODING_OFFSET: usize = ENGINE_OFFSET + 8;
1308
1309 fn reseal(image: &mut [u8]) {
1311 let payload_len = image.len() - CHECKSUM_BYTES - TRAILER.len();
1312 let checksum = crate::snapshot::crc32c(&image[..payload_len]);
1313 image[payload_len..payload_len + CHECKSUM_BYTES].copy_from_slice(&checksum.to_le_bytes());
1314 }
1315
1316 #[test]
1317 fn corruption_is_a_clean_miss() {
1318 let (root, index, request) = analyzed_index();
1319 let cache = root.path().join("content.cache");
1320 save_content_cache(&index, &cache).expect("save");
1321 let mut bytes = fs::read(&cache).expect("read");
1322 assert_eq!(bytes[PATH_ENCODING_OFFSET], crate::snapshot::path_encoding());
1323 bytes[PATH_ENCODING_OFFSET] ^= 0xff;
1325 fs::write(&cache, bytes).expect("corrupt");
1326 let (mut restored, _) =
1327 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1328 assert_eq!(load(&mut restored, request, &cache), ContentCacheLoad::default());
1329 }
1330
1331 #[test]
1337 fn a_sidecar_from_another_engine_or_scope_is_a_clean_miss() {
1338 let (root, index, request) = analyzed_index();
1339 let cache_dir = tempfile::tempdir().expect("cache dir");
1340 let cache = cache_dir.path().join("content.cache");
1341 save_content_cache(&index, &cache).expect("save");
1342 let saved = fs::read(&cache).expect("read");
1343 let load_into = |config: &ScanConfig| {
1344 let (mut restored, _) =
1345 crate::scan::scan_into_index(root.path(), config).expect("scan");
1346 load(&mut restored, request, &cache)
1347 };
1348 let hit = load_into(&ScanConfig::default());
1349 assert!(hit.usable && hit.hits == 1, "the saved sidecar restores: {hit:?}");
1350
1351 let mut other_engine = saved.clone();
1352 for byte in &mut other_engine[ENGINE_OFFSET..ENGINE_OFFSET + 8] {
1353 *byte = !*byte;
1354 }
1355 reseal(&mut other_engine);
1356 let mut older_format = saved.clone();
1357 older_format[MAGIC.len()..ENGINE_OFFSET].copy_from_slice(&4_u32.to_le_bytes());
1358 reseal(&mut older_format);
1359 for (name, image) in [("another engine", other_engine), ("format 4", older_format)] {
1360 fs::write(&cache, image).expect("rewrite");
1361 assert_eq!(load_into(&ScanConfig::default()), ContentCacheLoad::default(), "{name}");
1362 }
1363
1364 fs::write(&cache, &saved).expect("restore");
1365 for config in [
1366 ScanConfig { max_depth: Some(4), ..ScanConfig::default() },
1367 ScanConfig { exclude_special: true, ..ScanConfig::default() },
1368 ] {
1369 assert_eq!(load_into(&config), ContentCacheLoad::default(), "{:?}", config.scope());
1370 }
1371 let blind = ScanConfig { read_controls: false, ..ScanConfig::default() };
1372 assert_eq!(load_into(&blind), hit, "observation is not part of the content identity");
1373 }
1374
1375 #[test]
1379 fn a_sidecar_entry_tier_no_encoder_writes_is_a_clean_miss() {
1380 let (root, index, request) = analyzed_index();
1381 let cache_dir = tempfile::tempdir().expect("cache dir");
1382 let cache = cache_dir.path().join("content.cache");
1383 save_content_cache(&index, &cache).expect("save");
1384 let saved = fs::read(&cache).expect("read");
1385 let reload = || {
1386 let (mut restored, _) =
1387 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1388 load(&mut restored, request, &cache)
1389 };
1390 assert!(reload().usable, "the saved sidecar restores");
1391
1392 let flags_at = PATH_ENCODING_OFFSET + 1 + crate::stored_state::BOUND_BYTES;
1394 let mut unknown_flag = saved;
1395 assert_eq!(unknown_flag[flags_at], 0, "the default scope sets no flag");
1396 unknown_flag[flags_at] |= 1 << 7;
1397 reseal(&mut unknown_flag);
1398 fs::write(&cache, unknown_flag).expect("rewrite");
1399 assert_eq!(reload(), ContentCacheLoad::default());
1400 }
1401
1402 #[test]
1406 fn an_analyzer_version_change_invalidates_records() {
1407 let (root, index, request) = analyzed_index();
1408 let cache_dir = tempfile::tempdir().expect("cache dir");
1409 let cache = cache_dir.path().join("content.cache");
1410 save_content_cache(&index, &cache).expect("save");
1411 let saved = fs::read(&cache).expect("read");
1412 let reload = |cache: &Path| {
1413 let (mut restored, _) =
1414 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1415 load(&mut restored, request, cache)
1416 };
1417 let hit = reload(&cache);
1418 assert!(hit.usable && hit.hits == 1, "the saved sidecar restores: {hit:?}");
1419
1420 let options_at = PATH_ENCODING_OFFSET + 1 + ENTRY_TIER_BYTES + 1;
1424 let count_at = options_at + 8;
1425 let version_at = count_at + 1 + 4 + super::super::CONTENT_BASIC.0.len();
1426 assert_eq!(saved[count_at], 1, "a lines request runs one analyzer");
1427 assert_eq!(&saved[count_at + 5..version_at], super::super::CONTENT_BASIC.0.as_bytes());
1428 assert_eq!(saved[version_at..version_at + 2], 1_u16.to_le_bytes());
1429
1430 let mut other_version = saved.clone();
1431 other_version[version_at..version_at + 2].copy_from_slice(&2_u16.to_le_bytes());
1432 reseal(&mut other_version);
1433 let mut other_options = saved.clone();
1434 other_options[options_at] ^= 1;
1435 reseal(&mut other_options);
1436 for (name, image) in [("another version", other_version), ("other options", other_options)]
1437 {
1438 fs::write(&cache, image).expect("rewrite");
1439 assert_eq!(reload(&cache), ContentCacheLoad::default(), "{name}");
1440 }
1441 fs::write(&cache, &saved).expect("restore");
1442 assert_eq!(reload(&cache), hit, "the unchanged image still restores");
1443 }
1444
1445 #[test]
1451 fn records_under_an_unverified_subtree_are_not_written() {
1452 let root = tempfile::tempdir().expect("root");
1453 let store = tempfile::tempdir().expect("cache dir");
1454 fs::write(root.path().join("top.md"), "one two\n").expect("write");
1455 fs::create_dir(root.path().join("sub")).expect("mkdir");
1456 fs::write(root.path().join("sub").join("inner.md"), "three\n").expect("write");
1457 let config = ScanConfig::default();
1458 let lines = request_for(AnalysisSet::NONE.with_lines());
1459 let (snapshot_path, cache) = (store.path().join("tree.fdu"), store.path().join("first"));
1460
1461 let (mut scanned, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
1462 super::super::analyze_index(&mut scanned, lines);
1463 crate::snapshot::save(&scanned, &snapshot_path).expect("save snapshot");
1464 save_content_cache(&scanned, &cache).expect("save sidecar");
1465
1466 let mut restored = crate::snapshot::load_with_types(&snapshot_path, config.types_shared())
1467 .expect("load")
1468 .expect("a usable snapshot");
1469 assert_eq!(load(&mut restored, lines, &cache).hits, 2);
1470 crate::scan::reconcile(&mut restored, &config, &mut |_| {}).expect("reconcile");
1471 restored.begin_reconcile(Path::new("sub")).expect("withdraw trust over sub");
1472
1473 let content = restored.content().expect("content");
1474 let writable = |path: &str| {
1475 let record = content.file(Path::new(path)).expect("a restored record");
1476 crate::stored_state::content_record_writable(&restored, Path::new(path), record)
1477 };
1478 assert!(writable("top.md"), "a verified file's record is written");
1479 assert!(!writable("sub/inner.md"), "a record under an unverified subtree is not");
1480
1481 let rewritten = store.path().join("second");
1482 save_content_cache(&restored, &rewritten).expect("resave");
1483 let (mut fresh, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
1484 let loaded = load(&mut fresh, lines, &rewritten);
1485 assert!(loaded.usable && loaded.hits == 1, "only the verified record: {loaded:?}");
1486 let fresh_content = fresh.content().expect("content");
1487 assert!(fresh_content.file(Path::new("top.md")).is_some());
1488 assert!(fresh_content.file(Path::new("sub/inner.md")).is_none());
1489 }
1490
1491 fn readdress_record(image: &[u8], from: &Path, to: &Path) -> Vec<u8> {
1496 let encode = |path: &Path| {
1497 let mut bytes = Vec::new();
1498 crate::snapshot::put_os_str(&mut bytes, path.as_os_str()).expect("encode");
1499 bytes
1500 };
1501 let (from, to) = (encode(from), encode(to));
1502 let payload = integrity_payload(image).expect("a valid sidecar");
1503 let at = payload.windows(from.len()).position(|window| window == from).expect("the path");
1504 assert_eq!(
1505 payload.windows(from.len()).rposition(|window| window == from),
1506 Some(at),
1507 "the record's path must appear once, or the rewrite is ambiguous"
1508 );
1509 let mut rewritten = [&payload[..at], to.as_slice(), &payload[at + from.len()..]].concat();
1510 let checksum = crate::snapshot::crc32c(&rewritten);
1511 rewritten.extend_from_slice(&checksum.to_le_bytes());
1512 rewritten.extend_from_slice(TRAILER);
1513 rewritten
1514 }
1515
1516 fn two_record_sidecar() -> (tempfile::TempDir, tempfile::TempDir, AnalysisRequest, PathBuf) {
1517 let root = tempfile::tempdir().expect("root");
1518 let cache_dir = tempfile::tempdir().expect("cache dir");
1519 fs::write(root.path().join("a.md"), "one\n").expect("write first");
1520 fs::write(root.path().join("z.md"), "two\n").expect("write second");
1521 let request = request_for(AnalysisSet::NONE.with_lines());
1522 let (mut index, _) =
1523 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1524 super::super::analyze_index(&mut index, request);
1525 let cache = cache_dir.path().join("content.cache");
1526 save_content_cache(&index, &cache).expect("save");
1527 (root, cache_dir, request, cache)
1528 }
1529
1530 fn assert_late_stream_miss_recovers(
1531 root: &tempfile::TempDir,
1532 request: AnalysisRequest,
1533 cache: &Path,
1534 ) {
1535 let (mut restored, _) =
1536 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1537 assert_eq!(load(&mut restored, request, cache), ContentCacheLoad::default());
1538 assert!(restored.content().is_none(), "a late miss exposes no accepted prefix");
1539 assert!(
1540 restored.content_rollup(Path::new("")).is_none(),
1541 "a late miss exposes no roll-up from the accepted prefix"
1542 );
1543
1544 let analyzed = super::super::analyze_index(&mut restored, request);
1545 assert_eq!(analyzed.applied, 2, "both files are reanalyzed after the miss");
1546 assert_eq!(restored.content().expect("reanalyzed content").len(), 2);
1547 assert_eq!(
1548 restored.content_rollup(Path::new("")).expect("rebuilt root roll-up").total.files,
1549 2
1550 );
1551 }
1552
1553 #[test]
1554 fn malformed_second_record_rolls_back_the_valid_prefix() {
1555 let (root, _cache_dir, request, cache) = two_record_sidecar();
1556 let image = fs::read(&cache).expect("read");
1557 let malformed = readdress_record(&image, Path::new("z.md"), Path::new("../outside.md"));
1558 fs::write(&cache, malformed).expect("rewrite");
1559
1560 assert_late_stream_miss_recovers(&root, request, &cache);
1561 }
1562
1563 #[test]
1564 fn checksummed_trailing_bytes_roll_back_all_records() {
1565 let (root, _cache_dir, request, cache) = two_record_sidecar();
1566 let image = fs::read(&cache).expect("read");
1567 let mut payload = integrity_payload(&image).expect("valid sidecar").to_vec();
1568 payload.extend_from_slice(b"trailing");
1569 let checksum = crate::snapshot::crc32c(&payload);
1570 payload.extend_from_slice(&checksum.to_le_bytes());
1571 payload.extend_from_slice(TRAILER);
1572 fs::write(&cache, payload).expect("rewrite");
1573
1574 assert_late_stream_miss_recovers(&root, request, &cache);
1575 }
1576
1577 #[test]
1585 fn a_record_that_leaves_the_root_is_a_clean_miss() {
1586 let mut cases =
1588 vec![("notes.md", true), ("../escape.md", false), ("nested/../../escape.md", false)];
1589 #[cfg(unix)]
1590 cases.push(("/absolute.md", false));
1591 #[cfg(windows)]
1592 cases.extend([
1593 (r"C:\absolute.md", false),
1594 (r"\rooted-without-drive.md", false),
1595 ("C:drive-relative.md", false),
1596 ]);
1597
1598 for (record_path, restores) in cases {
1599 assert_eq!(
1601 record_path_stays_inside_root(Path::new(record_path)),
1602 restores,
1603 "{record_path:?}"
1604 );
1605
1606 let (root, index, request) = analyzed_index();
1607 let cache = root.path().join("content.cache");
1608 save_content_cache(&index, &cache).expect("save");
1609 let image = fs::read(&cache).expect("read");
1610 let rewritten = readdress_record(&image, Path::new("notes.md"), Path::new(record_path));
1611 fs::write(&cache, rewritten).expect("re-address");
1612
1613 let (mut restored, _) =
1614 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1615 let loaded = load(&mut restored, request, &cache);
1616 if restores {
1617 assert!(loaded.usable, "{record_path:?} must restore: {loaded:?}");
1618 assert_eq!(loaded.hits, 1, "{record_path:?} must restore: {loaded:?}");
1619 } else {
1620 assert_eq!(
1621 loaded,
1622 ContentCacheLoad::default(),
1623 "{record_path:?} leaves the root, so the sidecar must be a clean miss"
1624 );
1625 }
1626 }
1627 }
1628
1629 #[test]
1630 fn sidecar_name_preserves_the_metadata_snapshot_name() {
1631 assert_eq!(content_cache_path(Path::new("tree.fdu")), PathBuf::from("tree.fdu.content"));
1632 }
1633}