1use std::{
8 fs::{self, File},
9 io::{self, Read},
10 path::{Path, PathBuf},
11};
12
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use supercov_contracts::EVIDENCE_ARCHIVE_SCHEMA_VERSION;
16
17use crate::query_index::{QUERY_INDEX_SCHEMA_VERSION, QueryIndexIdentity};
18use crate::{
19 coverage_index::{CoverageIndex, CoverageIndexError, coverage_index_sections},
20 coverage_report::{
21 ArchiveReportRequest, CoverageReport, ExitCodeInput, ReportError, analyze_coverage_archive,
22 },
23 evidence_archive::read_archive_schema_version,
24 query_index::{QueryIndex, QueryIndexError, write_query_index},
25};
26
27const MAX_RUN_METADATA_BYTES: u64 = 1024 * 1024;
28pub const RUST_ANALYSIS_ABI_VERSION: u32 = 1;
29pub const RUST_QUERY_PRODUCER_ABI_VERSION: u32 = 4;
30pub const RUST_QUERY_INDEX_FILE: &str = "query-index.v1.bin";
31pub const STATEMENT_SPANS_FILE: &str = "statement-spans.json";
33pub const ASSESSMENT_INPUT_FILE: &str = "assessment-input.json.gz";
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38pub struct RunFingerprint {
39 pub algorithm: String,
40 pub source: String,
41 pub tests: String,
42 pub dependencies: String,
43 pub configuration: String,
44 pub instrumenter: String,
45 pub execution: String,
46 pub combined: String,
47 pub source_files: usize,
48 pub test_files: usize,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct GitIntegrity {
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub revision: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub branch: Option<String>,
59 pub dirty: bool,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase", deny_unknown_fields)]
64pub struct RunIntegrity {
65 pub schema_version: u32,
66 pub instrumenter_version: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub git: Option<GitIntegrity>,
69 pub fingerprint: RunFingerprint,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub stale: Option<bool>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub stale_reasons: Option<Vec<String>>,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78pub struct RunTimings {
79 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
80 pub initialization_ms: f64,
81 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
82 pub workspace_preparation_ms: f64,
83 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
84 pub adapter_setup_ms: f64,
85 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
86 pub instrumented_build_ms: f64,
87 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
88 pub test_command_ms: f64,
89 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
90 pub evidence_publication_ms: f64,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95pub struct InstrumentedBuildCache {
96 pub key: String,
97 pub reused: bool,
98}
99
100#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase", deny_unknown_fields)]
102pub struct RawEvidenceMetadata {
103 pub schema_version: u32,
104 pub format: String,
105 pub file: String,
106 pub files: usize,
107 pub uncompressed_bytes: u64,
108 pub compressed_bytes: u64,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase", deny_unknown_fields)]
113pub struct RunMetadata {
114 pub id: String,
115 pub started_at: String,
116 #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
117 pub duration_ms: f64,
118 pub command: Vec<String>,
119 pub test_exit_code: Option<i32>,
120 pub integrity: RunIntegrity,
121 pub raw_evidence: RawEvidenceMetadata,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub isolated_build: Option<bool>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub instrumented_build_cache: Option<InstrumentedBuildCache>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub timings: Option<RunTimings>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub merged: Option<bool>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub parents: Option<Vec<String>>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub source_roots: Option<Vec<String>>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub copy: Option<usize>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub merged_across: Option<Vec<String>>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub recorded_by: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub companions: Option<Vec<CompanionRun>>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase", deny_unknown_fields)]
165pub struct CompanionRun {
166 pub id: String,
167 pub language: String,
169 pub role: String,
172}
173
174#[cfg(test)]
175pub(crate) fn create_analyzable_test_run(root: &Path, id: &str) -> PathBuf {
176 create_analyzable_run(root, id, None)
177}
178
179#[cfg(test)]
184pub(crate) fn create_run_wide_test_run(root: &Path, id: &str) -> PathBuf {
185 create_analyzable_run(root, id, Some(crate::coverage_report::ATTRIBUTION_RUN_WIDE))
186}
187
188#[cfg(test)]
192pub(crate) fn create_partial_test_run(root: &Path, id: &str) -> PathBuf {
193 create_analyzable_run(root, id, Some(crate::coverage_report::ATTRIBUTION_PARTIAL))
194}
195
196#[cfg(test)]
197fn create_analyzable_run(root: &Path, id: &str, attribution: Option<&str>) -> PathBuf {
198 use crate::{
199 coverage_analysis::{McdcVector, PointKind},
200 coverage_report::{
201 BranchAlternativeMeta, BranchMeta, CoverageManifest, DecisionMeta, DecisionSnapshot,
202 PointMeta, RawTestResult, RuntimeSnapshot, TestProvenance,
203 },
204 evidence_archive::{EvidenceArchiveEntry, write_archive},
205 };
206
207 let directory = root.join(".supercov/runs").join(id);
208 fs::create_dir_all(&directory).unwrap();
209 let decision = DecisionMeta {
210 id: "decision".into(),
211 file: "src/app.js".into(),
212 line: 1,
213 column: 0,
214 source: "left && right".into(),
215 conditions: vec!["left".into(), "right".into()],
216 kind: "if".into(),
217 };
218 let manifest = CoverageManifest {
219 unmeasured: Vec::new(),
220 decisions: vec![decision.clone()],
221 points: vec![PointMeta {
222 id: "statement".into(),
223 kind: PointKind::Statement,
224 file: "src/app.js".into(),
225 line: 1,
226 column: 0,
227 source: "work();".into(),
228 label: None,
229 }],
230 branches: vec![BranchMeta {
231 id: "branch".into(),
232 kind: "if".into(),
233 file: "src/app.js".into(),
234 line: 1,
235 column: 0,
236 source: "if (left && right)".into(),
237 alternatives: vec![
238 BranchAlternativeMeta {
239 id: "branch:true".into(),
240 label: "true".into(),
241 },
242 BranchAlternativeMeta {
243 id: "branch:false".into(),
244 label: "false".into(),
245 },
246 ],
247 }],
248 limitations: vec![],
249 scope: None,
250 assertion_sites: Vec::new(),
251 };
252 let result = RawTestResult {
253 test_id: Some("test".into()),
254 scope: None,
255 test: "test".into(),
256 test_file: Some("tests/app.test.js".into()),
257 title: None,
258 retry: Some(0),
259 status: Some("passed".into()),
260 expected_status: None,
261 flaky: false,
262 provenance: TestProvenance {
263 runner: "node:test".into(),
264 kind: "unit".into(),
265 project: None,
266 source: "test-fixture".into(),
267 },
268 role: "test".into(),
269 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
270 phases: vec![],
271 runtime: vec![RuntimeSnapshot {
272 decisions: vec![DecisionSnapshot {
273 meta: decision,
274 vectors: vec![
275 McdcVector {
276 values: vec![Some(false), Some(false)],
277 outcome: false,
278 },
279 McdcVector {
280 values: vec![Some(false), Some(true)],
281 outcome: false,
282 },
283 McdcVector {
284 values: vec![Some(true), Some(false)],
285 outcome: false,
286 },
287 McdcVector {
288 values: vec![Some(true), Some(true)],
289 outcome: true,
290 },
291 ],
292 }],
293 hits: vec![
294 "statement".into(),
295 "branch:true".into(),
296 "branch:false".into(),
297 ],
298 events: vec![],
299 logicals: vec![],
300 phase_id: None,
301 }],
302 browser: vec![],
303 server: vec![],
304 };
305 let results = if attribution == Some(crate::coverage_report::ATTRIBUTION_PARTIAL) {
306 let mut partial = result.clone();
308 partial.attribution = crate::coverage_report::ATTRIBUTION_PARTIAL.into();
309 vec![partial]
310 } else if attribution == Some(crate::coverage_report::ATTRIBUTION_RUN_WIDE) {
311 let mut unclaimed = result.clone();
312 unclaimed.test = "run".into();
313 unclaimed.test_id = Some("run".into());
314 unclaimed.test_file = None;
315 unclaimed.role = "background".into();
316 let mut parallel = result.clone();
317 parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
318 parallel.runtime = vec![RuntimeSnapshot {
320 decisions: vec![],
321 hits: vec![],
322 events: vec![],
323 logicals: vec![],
324 phase_id: None,
325 }];
326 vec![unclaimed, parallel]
327 } else {
328 vec![result]
329 };
330 let mut entries = vec![
331 EvidenceArchiveEntry {
332 path: "coverage-model.json".into(),
333 contents: serde_json::to_vec(&serde_json::json!({
334 "schemaVersion": 1,
335 "language": "javascript",
336 "variant": "fixture-v1",
337 "name": "Fixture model",
338 "completenessMeaning": "Every fixture obligation was observed.",
339 "measured": ["fixture obligations"],
340 "notMeasured": []
341 }))
342 .unwrap(),
343 },
344 EvidenceArchiveEntry {
345 path: "frontend.json".into(),
346 contents: serde_json::to_vec(&serde_json::json!({
347 "protocolVersion": 2,
348 "frontendId": "javascript",
349 "frontendVersion": "fixture-v1",
350 "language": "javascript",
351 "structuralSource": "owned-probes",
352 "runners": [{
353 "runner": "node:test",
354 "executionModel": "serial-in-process",
355 "attribution": {
356 "run": "exact",
357 "worker": "unavailable",
358 "test": "exact",
359 "retry": "exact",
360 "phase": "exact",
361 "action": "exact",
362 "assertion": "exact"
363 },
364 "limitations": [{
365 "id": "fixture-worker-unavailable",
366 "scopes": ["worker"],
367 "reason": "The fixture intentionally has no worker identity"
368 }]
369 }],
370 "structuralLimitations": []
371 }))
372 .unwrap(),
373 },
374 EvidenceArchiveEntry {
375 path: "manifest.json".into(),
376 contents: serde_json::to_vec(&manifest).unwrap(),
377 },
378 ];
379 for (at, result) in results.iter().enumerate() {
380 entries.push(EvidenceArchiveEntry {
381 path: format!(
385 "worker{}/mcdc.json",
386 if at == 0 {
387 String::new()
388 } else {
389 format!("-{at}")
390 }
391 ),
392 contents: serde_json::to_vec(result).unwrap(),
393 });
394 }
395 let archive = write_archive(entries, &directory.join("evidence.raw.gz")).unwrap();
396 let digest = |character: char| std::iter::repeat_n(character, 64).collect::<String>();
397 let metadata = RunMetadata {
398 id: id.into(),
399 started_at: id.into(),
400 duration_ms: 1.0,
401 command: vec!["node".into(), "--test".into()],
402 test_exit_code: Some(0),
403 integrity: RunIntegrity {
404 schema_version: 2,
405 instrumenter_version: "test".into(),
406 git: None,
407 fingerprint: RunFingerprint {
408 algorithm: "sha256".into(),
409 source: digest('a'),
410 tests: digest('b'),
411 dependencies: digest('c'),
412 configuration: digest('d'),
413 instrumenter: digest('e'),
414 execution: digest('f'),
415 combined: digest('0'),
416 source_files: 1,
417 test_files: 1,
418 },
419 stale: None,
420 stale_reasons: None,
421 },
422 raw_evidence: RawEvidenceMetadata {
423 schema_version: archive.schema_version,
424 format: archive.format.into(),
425 file: archive.file.into(),
426 files: archive.files,
427 uncompressed_bytes: archive.uncompressed_bytes,
428 compressed_bytes: archive.compressed_bytes,
429 },
430 isolated_build: Some(true),
431 instrumented_build_cache: None,
432 timings: None,
433 merged: None,
434 parents: None,
435 source_roots: None,
436 copy: None,
437 merged_across: None,
438 recorded_by: None,
439 companions: None,
440 };
441 fs::write(
442 directory.join("run.json"),
443 serde_json::to_vec_pretty(&metadata).unwrap(),
444 )
445 .unwrap();
446 directory
447}
448
449#[derive(Debug, Clone, PartialEq)]
450pub struct StoredRun {
451 pub id: String,
452 pub directory: PathBuf,
453 pub evidence_path: PathBuf,
454 pub metadata_path: PathBuf,
455 pub query_index_path: PathBuf,
456 pub metadata: RunMetadata,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
460#[serde(rename_all = "camelCase")]
461pub struct RejectedRun {
462 pub entry: String,
463 pub reason: String,
464}
465
466#[derive(Debug, Clone, PartialEq)]
467pub struct RunInventory {
468 pub runs: Vec<StoredRun>,
469 pub rejected: Vec<RejectedRun>,
470}
471
472#[derive(Debug)]
473pub enum RunStoreError {
474 Io(io::Error),
475 UnsafeStore(PathBuf),
476 NoRuns,
477 RunNotFound(String),
478 InvalidRun(&'static str),
479}
480
481impl From<io::Error> for RunStoreError {
482 fn from(value: io::Error) -> Self {
483 Self::Io(value)
484 }
485}
486
487impl std::fmt::Display for RunStoreError {
488 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489 match self {
490 Self::Io(error) => write!(formatter, "{error}"),
491 Self::UnsafeStore(path) => {
492 write!(formatter, "unsafe run-store path: {}", path.display())
493 }
494 Self::NoRuns => write!(formatter, "no local coverage runs"),
495 Self::RunNotFound(selector) => write!(formatter, "coverage run not found: {selector}"),
496 Self::InvalidRun(reason) => write!(formatter, "invalid coverage run: {reason}"),
497 }
498 }
499}
500
501impl std::error::Error for RunStoreError {}
502
503fn regular_file(path: &Path) -> Result<fs::Metadata, RunStoreError> {
504 let metadata = fs::symlink_metadata(path)?;
505 if !metadata.file_type().is_file() {
506 return Err(RunStoreError::UnsafeStore(path.to_owned()));
507 }
508 Ok(metadata)
509}
510
511pub(crate) fn valid_run_id(value: &str) -> bool {
512 !value.is_empty()
513 && value != "."
514 && value != ".."
515 && !value
516 .chars()
517 .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
518}
519
520fn valid_hex_digest(value: &str, length: usize) -> bool {
521 value.len() == length
522 && value
523 .bytes()
524 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
525}
526
527fn valid_sha256(value: &str) -> bool {
528 valid_hex_digest(value, 64)
529}
530
531fn validate_integrity(integrity: &RunIntegrity) -> Result<(), RunStoreError> {
532 let fingerprint = &integrity.fingerprint;
533 if fingerprint.algorithm != "sha256"
534 || ![
535 &fingerprint.source,
536 &fingerprint.tests,
537 &fingerprint.dependencies,
538 &fingerprint.configuration,
539 &fingerprint.instrumenter,
540 &fingerprint.execution,
541 &fingerprint.combined,
542 ]
543 .into_iter()
544 .all(|digest| valid_sha256(digest))
545 {
546 return Err(RunStoreError::InvalidRun("integrity fingerprint"));
547 }
548 if let Some(git) = &integrity.git
549 && git.revision.as_ref().is_some_and(|revision| {
550 !valid_hex_digest(revision, 40) && !valid_hex_digest(revision, 64)
551 })
552 {
553 return Err(RunStoreError::InvalidRun("git revision"));
554 }
555 Ok(())
556}
557
558fn read_metadata(path: &Path) -> Result<RunMetadata, RunStoreError> {
559 let metadata = regular_file(path)?;
560 if metadata.len() > MAX_RUN_METADATA_BYTES {
561 return Err(RunStoreError::InvalidRun("run metadata is too large"));
562 }
563 let capacity =
564 usize::try_from(metadata.len()).map_err(|_| RunStoreError::InvalidRun("metadata size"))?;
565 let mut bytes = Vec::with_capacity(capacity);
566 File::open(path)?
567 .take(MAX_RUN_METADATA_BYTES + 1)
568 .read_to_end(&mut bytes)?;
569 let metadata: RunMetadata = serde_json::from_slice(&bytes)
570 .map_err(|_| RunStoreError::InvalidRun("run metadata JSON"))?;
571 validate_integrity(&metadata.integrity)?;
572 Ok(metadata)
573}
574
575fn load_run(directory: &Path, entry: &str) -> Result<StoredRun, RunStoreError> {
576 if !valid_run_id(entry) {
577 return Err(RunStoreError::InvalidRun("run directory name"));
578 }
579 let directory_metadata = fs::symlink_metadata(directory)?;
580 if !directory_metadata.file_type().is_dir() {
581 return Err(RunStoreError::UnsafeStore(directory.to_owned()));
582 }
583 let metadata_path = directory.join("run.json");
584 let metadata = read_metadata(&metadata_path)?;
585 if metadata.id != entry {
586 return Err(RunStoreError::InvalidRun(
587 "metadata ID differs from directory",
588 ));
589 }
590 if metadata.raw_evidence.schema_version != EVIDENCE_ARCHIVE_SCHEMA_VERSION
591 || metadata.raw_evidence.format != "framed+gzip"
592 || metadata.raw_evidence.file != "evidence.raw.gz"
593 || metadata.raw_evidence.files == 0
594 {
595 return Err(RunStoreError::InvalidRun("raw evidence metadata"));
596 }
597 let evidence_path = directory.join("evidence.raw.gz");
598 let evidence_metadata = regular_file(&evidence_path)?;
599 if evidence_metadata.len() != metadata.raw_evidence.compressed_bytes {
600 return Err(RunStoreError::InvalidRun("raw evidence length"));
601 }
602 if read_archive_schema_version(&evidence_path)
603 .map_err(|_| RunStoreError::InvalidRun("raw evidence archive"))?
604 != metadata.raw_evidence.schema_version
605 {
606 return Err(RunStoreError::InvalidRun("raw evidence schema mismatch"));
607 }
608 Ok(StoredRun {
609 id: entry.into(),
610 directory: directory.to_owned(),
611 evidence_path,
612 metadata_path,
613 query_index_path: directory.join(RUST_QUERY_INDEX_FILE),
614 metadata,
615 })
616}
617
618pub fn discover_runs(project_root: &Path) -> Result<RunInventory, RunStoreError> {
619 let store = project_root.join(".supercov").join("runs");
620 let store_metadata = match fs::symlink_metadata(&store) {
621 Ok(metadata) => metadata,
622 Err(error) if error.kind() == io::ErrorKind::NotFound => {
623 return Ok(RunInventory {
624 runs: Vec::new(),
625 rejected: Vec::new(),
626 });
627 }
628 Err(error) => return Err(error.into()),
629 };
630 if !store_metadata.file_type().is_dir() {
631 return Err(RunStoreError::UnsafeStore(store));
632 }
633 let mut runs = Vec::new();
634 let mut rejected = Vec::new();
635 for entry in fs::read_dir(&store)? {
636 let entry = match entry {
637 Ok(entry) => entry,
638 Err(error) => {
639 rejected.push(RejectedRun {
640 entry: "<unreadable>".into(),
641 reason: error.to_string(),
642 });
643 continue;
644 }
645 };
646 let name = entry.file_name().to_string_lossy().into_owned();
647 match load_run(&entry.path(), &name) {
648 Ok(run) => runs.push(run),
649 Err(error) => rejected.push(RejectedRun {
650 entry: name,
651 reason: error.to_string(),
652 }),
653 }
654 }
655 runs.sort_by(|left, right| {
656 right
657 .metadata
658 .started_at
659 .cmp(&left.metadata.started_at)
660 .then_with(|| right.id.cmp(&left.id))
661 });
662 rejected.sort_by(|left, right| left.entry.cmp(&right.entry));
663 Ok(RunInventory { runs, rejected })
664}
665
666pub fn select_run<'a>(
667 inventory: &'a RunInventory,
668 selector: Option<&str>,
669) -> Result<&'a StoredRun, RunStoreError> {
670 if inventory.runs.is_empty() {
671 return Err(RunStoreError::NoRuns);
672 }
673 if selector.is_none() || selector == Some("latest") {
674 return Ok(&inventory.runs[0]);
675 }
676 let selector = selector.expect("checked selector");
677 inventory
678 .runs
679 .iter()
680 .find(|run| run.id == selector)
681 .or_else(|| {
682 inventory
683 .runs
684 .iter()
685 .find(|run| run.id.starts_with(selector))
686 })
687 .ok_or_else(|| RunStoreError::RunNotFound(selector.into()))
688}
689
690#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct IntegrityComparison {
692 pub stale: bool,
693 pub reasons: Vec<String>,
694}
695
696pub fn compare_run_integrity(
697 stored: Option<&RunIntegrity>,
698 current: &RunIntegrity,
699) -> IntegrityComparison {
700 let Some(stored) = stored else {
701 return IntegrityComparison {
702 stale: true,
703 reasons: vec!["run predates integrity fingerprints".into()],
704 };
705 };
706 if std::env::var_os("SUPERCOV_DEBUG_INTEGRITY").is_some() {
707 eprintln!("[integrity] stored : {stored:?}");
708 eprintln!("[integrity] current: {current:?}");
709 }
710 let mut reasons = Vec::new();
711 if stored.schema_version != current.schema_version {
712 reasons.push("coverage schema changed".into());
713 }
714 if stored.instrumenter_version != current.instrumenter_version {
720 reasons.push("instrumenter contract changed".into());
721 }
722 if stored.fingerprint.source != current.fingerprint.source {
723 reasons.push("instrumented source changed".into());
724 }
725 if stored.fingerprint.tests != current.fingerprint.tests {
726 reasons.push("test files changed".into());
727 }
728 if stored.fingerprint.dependencies != current.fingerprint.dependencies {
729 reasons.push("dependencies or lockfile changed".into());
730 }
731 if stored.fingerprint.configuration != current.fingerprint.configuration {
732 reasons.push("test/build configuration changed".into());
733 }
734 if reasons.is_empty() && stored.fingerprint.execution != current.fingerprint.execution {
735 reasons.push("execution environment changed".into());
736 }
737 IntegrityComparison {
738 stale: !reasons.is_empty(),
739 reasons,
740 }
741}
742
743fn file_sha256(path: &Path) -> Result<([u8; 32], u64), RunStoreError> {
744 let metadata = regular_file(path)?;
745 let mut file = File::open(path)?;
746 let mut hash = Sha256::new();
747 let mut buffer = [0_u8; 128 * 1024];
748 loop {
749 let read = file.read(&mut buffer)?;
750 if read == 0 {
751 break;
752 }
753 hash.update(&buffer[..read]);
754 }
755 Ok((hash.finalize().into(), metadata.len()))
756}
757
758fn domain_hash(domain: &str, version: u32) -> [u8; 32] {
759 let mut hash = Sha256::new();
760 hash.update(domain.as_bytes());
761 hash.update([0]);
762 hash.update(version.to_le_bytes());
763 hash.update([0]);
764 hash.update(env!("SUPERCOV_ENGINE_SOURCE_SHA256").as_bytes());
765 hash.finalize().into()
766}
767
768pub fn query_index_identity(run: &StoredRun) -> Result<QueryIndexIdentity, RunStoreError> {
769 let (evidence_sha256, evidence_bytes) = file_sha256(&run.evidence_path)?;
770 Ok(QueryIndexIdentity {
771 evidence_sha256,
772 evidence_bytes,
773 analysis_sha256: domain_hash("supercov-analysis", RUST_ANALYSIS_ABI_VERSION),
774 producer_sha256: domain_hash(
775 "supercov-query-producer",
776 RUST_QUERY_PRODUCER_ABI_VERSION ^ QUERY_INDEX_SCHEMA_VERSION,
777 ),
778 archive_schema_version: run.metadata.raw_evidence.schema_version,
779 })
780}
781
782#[derive(Debug)]
783pub enum RunIndexError {
784 RunStore(RunStoreError),
785 QueryIndex(QueryIndexError),
786 CoverageIndex(CoverageIndexError),
787 Report(ReportError),
788 Metadata(serde_json::Error),
789 EvidenceChanged,
790}
791
792impl std::fmt::Display for RunIndexError {
793 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794 match self {
795 Self::RunStore(error) => write!(formatter, "{error}"),
796 Self::QueryIndex(error) => write!(formatter, "{error}"),
797 Self::CoverageIndex(error) => write!(formatter, "{error}"),
798 Self::Report(ReportError::NoEvidence(_)) => {
799 write!(formatter, "no coverage evidence was published")
800 }
801 Self::Report(error) => write!(formatter, "coverage analysis failed: {error:?}"),
802 Self::Metadata(error) => write!(formatter, "run integrity is invalid: {error}"),
803 Self::EvidenceChanged => write!(formatter, "evidence changed while indexing"),
804 }
805 }
806}
807
808impl std::error::Error for RunIndexError {}
809
810impl From<RunStoreError> for RunIndexError {
811 fn from(value: RunStoreError) -> Self {
812 Self::RunStore(value)
813 }
814}
815
816impl From<QueryIndexError> for RunIndexError {
817 fn from(value: QueryIndexError) -> Self {
818 Self::QueryIndex(value)
819 }
820}
821
822impl From<CoverageIndexError> for RunIndexError {
823 fn from(value: CoverageIndexError) -> Self {
824 Self::CoverageIndex(value)
825 }
826}
827
828impl From<ReportError> for RunIndexError {
829 fn from(value: ReportError) -> Self {
830 Self::Report(value)
831 }
832}
833
834impl From<serde_json::Error> for RunIndexError {
835 fn from(value: serde_json::Error) -> Self {
836 Self::Metadata(value)
837 }
838}
839
840fn open_validated_query_index(
841 path: &Path,
842 identity: &QueryIndexIdentity,
843) -> Result<QueryIndex, RunIndexError> {
844 let index = QueryIndex::open(path, identity)?;
845 index.verify_all()?;
846 CoverageIndex::new(&index)?;
847 Ok(index)
848}
849
850fn open_written_query_index(
859 path: &Path,
860 identity: &QueryIndexIdentity,
861) -> Result<QueryIndex, RunIndexError> {
862 let index = QueryIndex::open(path, identity)?;
863 CoverageIndex::new(&index)?;
864 Ok(index)
865}
866
867pub fn open_existing_query_index(run: &StoredRun) -> Result<Option<QueryIndex>, RunIndexError> {
869 match fs::symlink_metadata(&run.query_index_path) {
870 Ok(_) => {
871 open_validated_query_index(&run.query_index_path, &query_index_identity(run)?).map(Some)
872 }
873 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
874 Err(error) => Err(RunStoreError::Io(error).into()),
875 }
876}
877
878pub fn analyze_stored_run(run: &StoredRun) -> Result<CoverageReport, RunIndexError> {
880 Ok(analyze_coverage_archive(&ArchiveReportRequest {
881 archive_path: run.evidence_path.clone(),
882 run_id: run.id.clone(),
883 generated_at: run.metadata.started_at.clone(),
884 integrity: Some(serde_json::to_value(&run.metadata.integrity)?),
885 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
886 })?)
887}
888
889pub fn statement_spans(report: &CoverageReport) -> Vec<serde_json::Value> {
898 report
899 .view
900 .points
901 .iter()
902 .filter(|p| p.meta.kind == crate::coverage_analysis::PointKind::Statement)
903 .filter_map(|p| {
904 let extra = p.meta.source.matches('\n').count();
905 (extra > 0).then(|| {
906 serde_json::json!({
907 "file": p.meta.file, "line": p.meta.line,
908 "end": p.meta.line + extra, "covered": p.covered,
909 })
910 })
911 })
912 .collect()
913}
914
915pub fn read_statement_spans(run: &StoredRun) -> Option<Vec<serde_json::Value>> {
917 let bytes = fs::read(run.directory.join(STATEMENT_SPANS_FILE)).ok()?;
918 serde_json::from_slice(&bytes).ok()
919}
920
921pub(crate) fn write_statement_spans(run: &StoredRun, report: &CoverageReport) -> io::Result<()> {
922 let bytes = serde_json::to_vec(&statement_spans(report)).map_err(io::Error::other)?;
923 fs::write(run.directory.join(STATEMENT_SPANS_FILE), bytes)
924}
925
926pub fn read_assessment_input(
928 run: &StoredRun,
929) -> Option<crate::assertion_coverage::AssessmentInput> {
930 let file = fs::File::open(run.directory.join(ASSESSMENT_INPUT_FILE)).ok()?;
931 serde_json::from_reader(flate2::read::GzDecoder::new(io::BufReader::new(file))).ok()
932}
933
934pub(crate) fn write_assessment_input(run: &StoredRun, report: &CoverageReport) -> io::Result<()> {
935 use std::io::Write;
936 let bytes = serde_json::to_vec(&crate::assertion_coverage::assessment_input(report))
937 .map_err(io::Error::other)?;
938 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
939 encoder.write_all(&bytes)?;
940 fs::write(run.directory.join(ASSESSMENT_INPUT_FILE), encoder.finish()?)
941}
942
943pub(crate) fn write_query_index_from(
944 run: &StoredRun,
945 report: &CoverageReport,
946) -> Result<(), RunIndexError> {
947 let identity = query_index_identity(run)?;
948 let sections = coverage_index_sections(report)?;
949 write_query_index(§ions, &identity, &run.query_index_path)?;
950 Ok(())
951}
952
953pub fn open_or_rebuild_query_index(run: &StoredRun) -> Result<QueryIndex, RunIndexError> {
960 let identity = query_index_identity(run)?;
961 if let Ok(index) = open_validated_query_index(&run.query_index_path, &identity) {
962 return Ok(index);
963 }
964
965 let report = analyze_stored_run(run)?;
966 let sections = coverage_index_sections(&report)?;
967 if query_index_identity(run)? != identity {
968 return Err(RunIndexError::EvidenceChanged);
969 }
970 write_query_index(§ions, &identity, &run.query_index_path)?;
971 let index = open_written_query_index(&run.query_index_path, &identity)?;
972 if query_index_identity(run)? != identity {
973 return Err(RunIndexError::EvidenceChanged);
974 }
975 Ok(index)
976}
977
978#[cfg(test)]
979mod tests {
980 use std::{
981 fs,
982 time::{SystemTime, UNIX_EPOCH},
983 };
984
985 use crate::evidence_archive::{EvidenceArchiveEntry, read_archive, write_archive};
986
987 use super::*;
988
989 fn temporary_directory(label: &str) -> PathBuf {
990 let nonce = SystemTime::now()
991 .duration_since(UNIX_EPOCH)
992 .unwrap()
993 .as_nanos();
994 let path = std::env::temp_dir().join(format!(
995 "supercov-run-store-{label}-{}-{nonce}",
996 std::process::id()
997 ));
998 fs::create_dir_all(&path).unwrap();
999 path
1000 }
1001
1002 fn digest(character: char) -> String {
1003 std::iter::repeat_n(character, 64).collect()
1004 }
1005
1006 fn integrity() -> RunIntegrity {
1007 RunIntegrity {
1008 schema_version: 2,
1009 instrumenter_version: "2.0.0".into(),
1010 git: Some(GitIntegrity {
1011 revision: Some(std::iter::repeat_n('a', 40).collect()),
1012 branch: None,
1013 dirty: false,
1014 }),
1015 fingerprint: RunFingerprint {
1016 algorithm: "sha256".into(),
1017 source: digest('a'),
1018 tests: digest('b'),
1019 dependencies: digest('c'),
1020 configuration: digest('d'),
1021 instrumenter: digest('e'),
1022 execution: digest('f'),
1023 combined: digest('0'),
1024 source_files: 1,
1025 test_files: 1,
1026 },
1027 stale: None,
1028 stale_reasons: None,
1029 }
1030 }
1031
1032 fn create_run(root: &Path, id: &str) -> PathBuf {
1033 let directory = root.join(".supercov/runs").join(id);
1034 fs::create_dir_all(&directory).unwrap();
1035 let archive = write_archive(
1036 vec![
1037 EvidenceArchiveEntry {
1038 path: "coverage-model.json".into(),
1039 contents: br#"{"schemaVersion":1,"language":"fixture","variant":"fixture-v1","name":"Fixture model","completenessMeaning":"Fixture archive identity only.","measured":["fixture"],"notMeasured":[]}"#.to_vec(),
1040 },
1041 EvidenceArchiveEntry {
1042 path: "frontend.json".into(),
1043 contents: br#"{"protocolVersion":2,"frontendId":"fixture","frontendVersion":"fixture-v1","language":"fixture","structuralSource":"owned-probes","runners":[{"runner":"fixture","executionModel":"serial-in-process","attribution":{"run":"exact","worker":"unavailable","test":"unavailable","retry":"unavailable","phase":"unavailable","action":"unavailable","assertion":"unavailable"},"limitations":[{"id":"fixture-identities-unavailable","scopes":["worker","test","retry","phase","action","assertion"],"reason":"This store-only fixture has no execution evidence"}]}],"structuralLimitations":[]}"#.to_vec(),
1044 },
1045 EvidenceArchiveEntry {
1046 path: "manifest.json".into(),
1047 contents: b"{}".to_vec(),
1048 },
1049 ],
1050 &directory.join("evidence.raw.gz"),
1051 )
1052 .unwrap();
1053 let metadata = RunMetadata {
1054 id: id.into(),
1055 started_at: id.into(),
1056 duration_ms: 1.0,
1057 command: vec!["npm".into(), "test".into()],
1058 test_exit_code: Some(0),
1059 integrity: integrity(),
1060 raw_evidence: RawEvidenceMetadata {
1061 schema_version: archive.schema_version,
1062 format: archive.format.into(),
1063 file: archive.file.into(),
1064 files: archive.files,
1065 uncompressed_bytes: archive.uncompressed_bytes,
1066 compressed_bytes: archive.compressed_bytes,
1067 },
1068 isolated_build: Some(true),
1069 instrumented_build_cache: None,
1070 timings: None,
1071 merged: None,
1072 parents: None,
1073 source_roots: None,
1074 copy: None,
1075 merged_across: None,
1076 recorded_by: None,
1077 companions: None,
1078 };
1079 fs::write(
1080 directory.join("run.json"),
1081 serde_json::to_vec_pretty(&metadata).unwrap(),
1082 )
1083 .unwrap();
1084 directory
1085 }
1086
1087 fn create_indexable_run(root: &Path) -> StoredRun {
1088 create_analyzable_test_run(root, "test-run");
1089 discover_runs(root).unwrap().runs.remove(0)
1090 }
1091
1092 fn create_indexable_python_run(root: &Path) -> StoredRun {
1093 let directory = create_analyzable_test_run(root, "python-run");
1094 let evidence_path = directory.join("evidence.raw.gz");
1095 let mut entries = read_archive(&evidence_path).unwrap();
1096 for entry in &mut entries {
1097 if entry.path.ends_with("/mcdc.json") {
1098 let mut result: serde_json::Value =
1099 serde_json::from_slice(&entry.contents).unwrap();
1100 result["provenance"]["runner"] = "pytest".into();
1101 result["provenance"]["kind"] = "unit".into();
1102 result["testFile"] = "tests/test_app.py".into();
1103 entry.contents = serde_json::to_vec(&result).unwrap();
1104 }
1105 }
1106 entries
1107 .iter_mut()
1108 .find(|entry| entry.path == "frontend.json")
1109 .unwrap()
1110 .contents = serde_json::to_vec(&serde_json::json!({
1111 "protocolVersion": 2,
1112 "frontendId": "python",
1113 "frontendVersion": "python-owned-v1",
1114 "language": "python",
1115 "structuralSource": "owned-probes",
1116 "runners": [{
1117 "runner": "pytest",
1118 "executionModel": "serial-in-process",
1119 "attribution": {
1120 "run": "exact",
1121 "worker": "unavailable",
1122 "test": "exact",
1123 "retry": "exact",
1124 "phase": "exact",
1125 "action": "exact",
1126 "assertion": "exact"
1127 },
1128 "limitations": [{
1129 "id": "test-fixture-worker-unavailable",
1130 "scopes": ["worker"],
1131 "reason": "The persisted-run fixture intentionally has no worker identity"
1132 }]
1133 }],
1134 "structuralLimitations": []
1135 }))
1136 .unwrap();
1137 entries
1138 .iter_mut()
1139 .find(|entry| entry.path == "coverage-model.json")
1140 .unwrap()
1141 .contents = serde_json::to_vec(&serde_json::json!({
1142 "schemaVersion": 1,
1143 "language": "python",
1144 "variant": "all",
1145 "name": "python-owned-control-flow",
1146 "completenessMeaning": "Every declared owned-probe obligation was observed.",
1147 "measured": ["owned statements", "owned decisions"],
1148 "notMeasured": []
1149 }))
1150 .unwrap();
1151 let archive = write_archive(entries, &evidence_path).unwrap();
1152 let metadata_path = directory.join("run.json");
1153 let mut metadata: RunMetadata =
1154 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1155 metadata.raw_evidence = RawEvidenceMetadata {
1156 schema_version: archive.schema_version,
1157 format: archive.format.into(),
1158 file: archive.file.into(),
1159 files: archive.files,
1160 uncompressed_bytes: archive.uncompressed_bytes,
1161 compressed_bytes: archive.compressed_bytes,
1162 };
1163 fs::write(metadata_path, serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
1164 discover_runs(root).unwrap().runs.remove(0)
1165 }
1166
1167 #[test]
1168 fn discovers_valid_runs_in_reverse_order_and_selects_exact_prefix_or_latest() {
1169 let root = temporary_directory("discovery");
1170 create_run(&root, "2026-08-24T00-00-00-000Z");
1171 create_run(&root, "2026-08-25T00-00-00-000Z");
1172 create_run(&root, "zz-custom-old-run");
1173 let custom_metadata = root.join(".supercov/runs/zz-custom-old-run/run.json");
1174 let mut custom: serde_json::Value =
1175 serde_json::from_slice(&fs::read(&custom_metadata).unwrap()).unwrap();
1176 custom["startedAt"] = "2026-08-23T00:00:00.000Z".into();
1177 fs::write(
1178 &custom_metadata,
1179 serde_json::to_vec_pretty(&custom).unwrap(),
1180 )
1181 .unwrap();
1182 let inventory = discover_runs(&root).unwrap();
1183 assert!(inventory.rejected.is_empty());
1184 assert_eq!(
1185 inventory
1186 .runs
1187 .iter()
1188 .map(|run| run.id.as_str())
1189 .collect::<Vec<_>>(),
1190 [
1191 "2026-08-25T00-00-00-000Z",
1192 "2026-08-24T00-00-00-000Z",
1193 "zz-custom-old-run"
1194 ]
1195 );
1196 assert_eq!(
1197 select_run(&inventory, None).unwrap().id,
1198 inventory.runs[0].id
1199 );
1200 assert_eq!(
1201 select_run(&inventory, Some("2026-08-24")).unwrap().id,
1202 "2026-08-24T00-00-00-000Z"
1203 );
1204 assert!(matches!(
1205 select_run(&inventory, Some("missing")),
1206 Err(RunStoreError::RunNotFound(_))
1207 ));
1208 fs::remove_dir_all(root).unwrap();
1209 }
1210
1211 #[test]
1212 fn reports_damaged_entries_without_hiding_valid_runs() {
1213 let root = temporary_directory("rejected");
1214 create_run(&root, "valid");
1215 let mismatched = create_run(&root, "mismatched");
1216 let mut metadata: serde_json::Value =
1217 serde_json::from_slice(&fs::read(mismatched.join("run.json")).unwrap()).unwrap();
1218 metadata["id"] = "different".into();
1219 fs::write(
1220 mismatched.join("run.json"),
1221 serde_json::to_vec(&metadata).unwrap(),
1222 )
1223 .unwrap();
1224 let corrupt = create_run(&root, "wrong-length");
1225 fs::write(corrupt.join("evidence.raw.gz"), b"truncated").unwrap();
1226 let schema_mismatch = create_run(&root, "schema-mismatch");
1227 let metadata_path = schema_mismatch.join("run.json");
1228 let mut metadata: serde_json::Value =
1229 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1230 metadata["rawEvidence"]["schemaVersion"] = 2.into();
1231 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1232
1233 let inventory = discover_runs(&root).unwrap();
1234 assert_eq!(inventory.runs.len(), 1);
1235 assert_eq!(inventory.runs[0].id, "valid");
1236 assert_eq!(inventory.rejected.len(), 3);
1237 assert!(inventory.rejected[0].reason.contains("metadata ID"));
1238 assert!(
1239 inventory
1240 .rejected
1241 .iter()
1242 .any(|run| run.reason.contains("raw evidence metadata"))
1243 );
1244 assert!(
1245 inventory
1246 .rejected
1247 .iter()
1248 .any(|run| run.reason.contains("raw evidence length"))
1249 );
1250 fs::remove_dir_all(root).unwrap();
1251 }
1252
1253 #[cfg(unix)]
1254 #[test]
1255 fn refuses_linked_run_directories_and_files() {
1256 use std::os::unix::fs::symlink;
1257
1258 let root = temporary_directory("links");
1259 let target = create_run(&root, "target");
1260 symlink(&target, root.join(".supercov/runs/linked-run")).unwrap();
1261 let linked_metadata = create_run(&root, "linked-metadata");
1262 fs::remove_file(linked_metadata.join("run.json")).unwrap();
1263 symlink(target.join("run.json"), linked_metadata.join("run.json")).unwrap();
1264 let linked_evidence = create_run(&root, "linked-evidence");
1265 fs::remove_file(linked_evidence.join("evidence.raw.gz")).unwrap();
1266 symlink(
1267 target.join("evidence.raw.gz"),
1268 linked_evidence.join("evidence.raw.gz"),
1269 )
1270 .unwrap();
1271
1272 let inventory = discover_runs(&root).unwrap();
1273 assert_eq!(inventory.runs.len(), 1);
1274 assert_eq!(inventory.runs[0].id, "target");
1275 assert_eq!(inventory.rejected.len(), 3);
1276 assert!(
1277 inventory
1278 .rejected
1279 .iter()
1280 .all(|rejected| rejected.reason.contains("unsafe run-store path"))
1281 );
1282 fs::remove_dir_all(root).unwrap();
1283 }
1284
1285 #[test]
1286 fn compares_integrity_in_stable_contract_order_and_binds_identity_to_evidence() {
1287 let root = temporary_directory("identity");
1288 create_run(&root, "run");
1289 let inventory = discover_runs(&root).unwrap();
1290 let run = &inventory.runs[0];
1291 let first = query_index_identity(run).unwrap();
1292 fs::write(&run.evidence_path, b"different bytes").unwrap();
1293 let second = query_index_identity(run).unwrap();
1294 assert_ne!(first.evidence_sha256, second.evidence_sha256);
1295 assert_ne!(first.evidence_bytes, second.evidence_bytes);
1296 assert_eq!(first.analysis_sha256, second.analysis_sha256);
1297 assert_eq!(env!("SUPERCOV_ENGINE_SOURCE_SHA256").len(), 64);
1298
1299 let mut current = integrity();
1300 current.schema_version += 1;
1301 current.instrumenter_version = "javascript-v2".into();
1302 current.fingerprint.source = digest('2');
1303 current.fingerprint.tests = digest('3');
1304 current.fingerprint.dependencies = digest('4');
1305 current.fingerprint.configuration = digest('5');
1306 assert_eq!(
1307 compare_run_integrity(Some(&integrity()), ¤t).reasons,
1308 [
1309 "coverage schema changed",
1310 "instrumenter contract changed",
1311 "instrumented source changed",
1312 "test files changed",
1313 "dependencies or lockfile changed",
1314 "test/build configuration changed",
1315 ]
1316 );
1317
1318 let mut rebuilt = integrity();
1323 rebuilt.fingerprint.instrumenter = digest('1');
1324 assert!(!compare_run_integrity(Some(&integrity()), &rebuilt).stale);
1325 fs::remove_dir_all(root).unwrap();
1326 }
1327
1328 #[test]
1329 fn lazily_builds_reuses_and_repairs_a_fully_authenticated_typed_index() {
1330 let root = temporary_directory("lazy-index");
1331 let run = create_indexable_run(&root);
1332 assert!(!run.query_index_path.exists());
1333
1334 {
1335 let index = open_or_rebuild_query_index(&run).unwrap();
1336 index.verify_all().unwrap();
1337 CoverageIndex::new(&index).unwrap();
1338 }
1339 let canonical = fs::read(&run.query_index_path).unwrap();
1340 assert!(canonical.len() > crate::query_index::QUERY_INDEX_HEADER_SIZE);
1341 {
1342 let index = open_or_rebuild_query_index(&run).unwrap();
1343 index.verify_all().unwrap();
1344 }
1345 assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1346
1347 let mut corrupt = canonical.clone();
1348 let offset = crate::query_index::QUERY_INDEX_HEADER_SIZE + 8;
1349 corrupt[offset] ^= 0xff;
1350 fs::write(&run.query_index_path, corrupt).unwrap();
1351 {
1352 let index = open_or_rebuild_query_index(&run).unwrap();
1353 index.verify_all().unwrap();
1354 }
1355 assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1356 fs::remove_dir_all(root).unwrap();
1357 }
1358
1359 #[test]
1360 fn an_index_opened_without_the_full_check_still_refuses_a_corrupt_page() {
1361 let root = temporary_directory("written-index");
1366 let run = create_indexable_python_run(&root);
1367 let identity = query_index_identity(&run).unwrap();
1368 drop(open_or_rebuild_query_index(&run).unwrap());
1369 let lines = QueryIndex::open(&run.query_index_path, &identity)
1370 .unwrap()
1371 .descriptor(crate::coverage_index::SECTION_LINES)
1372 .unwrap();
1373 assert!(lines.length > 0, "the fixture has line records to corrupt");
1374
1375 let mut bytes = fs::read(&run.query_index_path).unwrap();
1376 bytes[lines.offset as usize] ^= 0xff;
1377 fs::write(&run.query_index_path, bytes).unwrap();
1378
1379 let index = open_written_query_index(&run.query_index_path, &identity)
1380 .expect("opening does not hash every page");
1381 assert!(
1382 matches!(
1383 index.bytes(crate::coverage_index::SECTION_LINES, 0, 1),
1384 Err(crate::query_index::QueryIndexError::CorruptPage { .. })
1385 ),
1386 "the corrupt page is refused when it is read"
1387 );
1388 assert!(
1389 index.verify_all().is_err(),
1390 "and a full check still finds it"
1391 );
1392 fs::remove_dir_all(root).unwrap();
1393 }
1394
1395 #[test]
1396 fn indexes_the_declared_language_model() {
1397 let root = temporary_directory("v3-index");
1398 let run = create_indexable_python_run(&root);
1399 assert_eq!(
1400 run.metadata.raw_evidence.schema_version,
1401 EVIDENCE_ARCHIVE_SCHEMA_VERSION
1402 );
1403 assert_eq!(
1404 query_index_identity(&run).unwrap().archive_schema_version,
1405 EVIDENCE_ARCHIVE_SCHEMA_VERSION
1406 );
1407 let index = open_or_rebuild_query_index(&run).unwrap();
1408 index.verify_all().unwrap();
1409 let coverage_index = CoverageIndex::new(&index).unwrap();
1410 assert_eq!(
1411 coverage_index.model().unwrap().name,
1412 "python-owned-control-flow"
1413 );
1414 let summary = coverage_index
1415 .summary(crate::coverage_index::CoverageViewId::All)
1416 .unwrap();
1417 assert_eq!(summary.lines.percentage, 100.0);
1418 fs::remove_dir_all(root).unwrap();
1419 }
1420
1421 #[cfg(unix)]
1422 #[test]
1423 fn atomically_replaces_a_linked_disposable_index_without_touching_its_target() {
1424 use std::os::unix::fs::symlink;
1425
1426 let root = temporary_directory("linked-index");
1427 let run = create_indexable_run(&root);
1428 let outside = root.join("outside");
1429 fs::write(&outside, b"user data").unwrap();
1430 symlink(&outside, &run.query_index_path).unwrap();
1431
1432 let index = open_or_rebuild_query_index(&run).unwrap();
1433 index.verify_all().unwrap();
1434 assert_eq!(fs::read(outside).unwrap(), b"user data");
1435 assert!(
1436 fs::symlink_metadata(&run.query_index_path)
1437 .unwrap()
1438 .file_type()
1439 .is_file()
1440 );
1441 fs::remove_dir_all(root).unwrap();
1442 }
1443}