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 tags: Vec::new(),
268 },
269 role: "test".into(),
270 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
271 phases: vec![],
272 runtime: vec![RuntimeSnapshot {
273 decisions: vec![DecisionSnapshot {
274 meta: decision,
275 vectors: vec![
276 McdcVector {
277 values: vec![Some(false), Some(false)],
278 outcome: false,
279 },
280 McdcVector {
281 values: vec![Some(false), Some(true)],
282 outcome: false,
283 },
284 McdcVector {
285 values: vec![Some(true), Some(false)],
286 outcome: false,
287 },
288 McdcVector {
289 values: vec![Some(true), Some(true)],
290 outcome: true,
291 },
292 ],
293 }],
294 hits: vec![
295 "statement".into(),
296 "branch:true".into(),
297 "branch:false".into(),
298 ],
299 events: vec![],
300 logicals: vec![],
301 phase_id: None,
302 }],
303 browser: vec![],
304 server: vec![],
305 };
306 let results = if attribution == Some(crate::coverage_report::ATTRIBUTION_PARTIAL) {
307 let mut partial = result.clone();
309 partial.attribution = crate::coverage_report::ATTRIBUTION_PARTIAL.into();
310 vec![partial]
311 } else if attribution == Some(crate::coverage_report::ATTRIBUTION_RUN_WIDE) {
312 let mut unclaimed = result.clone();
313 unclaimed.test = "run".into();
314 unclaimed.test_id = Some("run".into());
315 unclaimed.test_file = None;
316 unclaimed.role = "background".into();
317 let mut parallel = result.clone();
318 parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
319 parallel.runtime = vec![RuntimeSnapshot {
321 decisions: vec![],
322 hits: vec![],
323 events: vec![],
324 logicals: vec![],
325 phase_id: None,
326 }];
327 vec![unclaimed, parallel]
328 } else {
329 vec![result]
330 };
331 let mut entries = vec![
332 EvidenceArchiveEntry {
333 path: "coverage-model.json".into(),
334 contents: serde_json::to_vec(&serde_json::json!({
335 "schemaVersion": 1,
336 "language": "javascript",
337 "variant": "fixture-v1",
338 "name": "Fixture model",
339 "completenessMeaning": "Every fixture obligation was observed.",
340 "measured": ["fixture obligations"],
341 "notMeasured": []
342 }))
343 .unwrap(),
344 },
345 EvidenceArchiveEntry {
346 path: "frontend.json".into(),
347 contents: serde_json::to_vec(&serde_json::json!({
348 "protocolVersion": 2,
349 "frontendId": "javascript",
350 "frontendVersion": "fixture-v1",
351 "language": "javascript",
352 "structuralSource": "owned-probes",
353 "runners": [{
354 "runner": "node:test",
355 "executionModel": "serial-in-process",
356 "attribution": {
357 "run": "exact",
358 "worker": "unavailable",
359 "test": "exact",
360 "retry": "exact",
361 "phase": "exact",
362 "action": "exact",
363 "assertion": "exact"
364 },
365 "limitations": [{
366 "id": "fixture-worker-unavailable",
367 "scopes": ["worker"],
368 "reason": "The fixture intentionally has no worker identity"
369 }]
370 }],
371 "structuralLimitations": []
372 }))
373 .unwrap(),
374 },
375 EvidenceArchiveEntry {
376 path: "manifest.json".into(),
377 contents: serde_json::to_vec(&manifest).unwrap(),
378 },
379 ];
380 for (at, result) in results.iter().enumerate() {
381 entries.push(EvidenceArchiveEntry {
382 path: format!(
386 "worker{}/mcdc.json",
387 if at == 0 {
388 String::new()
389 } else {
390 format!("-{at}")
391 }
392 ),
393 contents: serde_json::to_vec(result).unwrap(),
394 });
395 }
396 let archive = write_archive(entries, &directory.join("evidence.raw.gz")).unwrap();
397 let digest = |character: char| std::iter::repeat_n(character, 64).collect::<String>();
398 let metadata = RunMetadata {
399 id: id.into(),
400 started_at: id.into(),
401 duration_ms: 1.0,
402 command: vec!["node".into(), "--test".into()],
403 test_exit_code: Some(0),
404 integrity: RunIntegrity {
405 schema_version: 2,
406 instrumenter_version: "test".into(),
407 git: None,
408 fingerprint: RunFingerprint {
409 algorithm: "sha256".into(),
410 source: digest('a'),
411 tests: digest('b'),
412 dependencies: digest('c'),
413 configuration: digest('d'),
414 instrumenter: digest('e'),
415 execution: digest('f'),
416 combined: digest('0'),
417 source_files: 1,
418 test_files: 1,
419 },
420 stale: None,
421 stale_reasons: None,
422 },
423 raw_evidence: RawEvidenceMetadata {
424 schema_version: archive.schema_version,
425 format: archive.format.into(),
426 file: archive.file.into(),
427 files: archive.files,
428 uncompressed_bytes: archive.uncompressed_bytes,
429 compressed_bytes: archive.compressed_bytes,
430 },
431 isolated_build: Some(true),
432 instrumented_build_cache: None,
433 timings: None,
434 merged: None,
435 parents: None,
436 source_roots: None,
437 copy: None,
438 merged_across: None,
439 recorded_by: None,
440 companions: None,
441 };
442 fs::write(
443 directory.join("run.json"),
444 serde_json::to_vec_pretty(&metadata).unwrap(),
445 )
446 .unwrap();
447 directory
448}
449
450#[derive(Debug, Clone, PartialEq)]
451pub struct StoredRun {
452 pub id: String,
453 pub directory: PathBuf,
454 pub evidence_path: PathBuf,
455 pub metadata_path: PathBuf,
456 pub query_index_path: PathBuf,
457 pub metadata: RunMetadata,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
461#[serde(rename_all = "camelCase")]
462pub struct RejectedRun {
463 pub entry: String,
464 pub reason: String,
465}
466
467#[derive(Debug, Clone, PartialEq)]
468pub struct RunInventory {
469 pub runs: Vec<StoredRun>,
470 pub rejected: Vec<RejectedRun>,
471}
472
473#[derive(Debug)]
474pub enum RunStoreError {
475 Io(io::Error),
476 UnsafeStore(PathBuf),
477 NoRuns,
478 RunNotFound(String),
479 InvalidRun(&'static str),
480}
481
482impl From<io::Error> for RunStoreError {
483 fn from(value: io::Error) -> Self {
484 Self::Io(value)
485 }
486}
487
488impl std::fmt::Display for RunStoreError {
489 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
490 match self {
491 Self::Io(error) => write!(formatter, "{error}"),
492 Self::UnsafeStore(path) => {
493 write!(formatter, "unsafe run-store path: {}", path.display())
494 }
495 Self::NoRuns => write!(formatter, "no local coverage runs"),
496 Self::RunNotFound(selector) => write!(formatter, "coverage run not found: {selector}"),
497 Self::InvalidRun(reason) => write!(formatter, "invalid coverage run: {reason}"),
498 }
499 }
500}
501
502impl std::error::Error for RunStoreError {}
503
504fn regular_file(path: &Path) -> Result<fs::Metadata, RunStoreError> {
505 let metadata = fs::symlink_metadata(path)?;
506 if !metadata.file_type().is_file() {
507 return Err(RunStoreError::UnsafeStore(path.to_owned()));
508 }
509 Ok(metadata)
510}
511
512pub(crate) fn valid_run_id(value: &str) -> bool {
513 !value.is_empty()
514 && value != "."
515 && value != ".."
516 && !value
517 .chars()
518 .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
519}
520
521fn valid_hex_digest(value: &str, length: usize) -> bool {
522 value.len() == length
523 && value
524 .bytes()
525 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
526}
527
528fn valid_sha256(value: &str) -> bool {
529 valid_hex_digest(value, 64)
530}
531
532fn validate_integrity(integrity: &RunIntegrity) -> Result<(), RunStoreError> {
533 let fingerprint = &integrity.fingerprint;
534 if fingerprint.algorithm != "sha256"
535 || ![
536 &fingerprint.source,
537 &fingerprint.tests,
538 &fingerprint.dependencies,
539 &fingerprint.configuration,
540 &fingerprint.instrumenter,
541 &fingerprint.execution,
542 &fingerprint.combined,
543 ]
544 .into_iter()
545 .all(|digest| valid_sha256(digest))
546 {
547 return Err(RunStoreError::InvalidRun("integrity fingerprint"));
548 }
549 if let Some(git) = &integrity.git
550 && git.revision.as_ref().is_some_and(|revision| {
551 !valid_hex_digest(revision, 40) && !valid_hex_digest(revision, 64)
552 })
553 {
554 return Err(RunStoreError::InvalidRun("git revision"));
555 }
556 Ok(())
557}
558
559fn read_metadata(path: &Path) -> Result<RunMetadata, RunStoreError> {
560 let metadata = regular_file(path)?;
561 if metadata.len() > MAX_RUN_METADATA_BYTES {
562 return Err(RunStoreError::InvalidRun("run metadata is too large"));
563 }
564 let capacity =
565 usize::try_from(metadata.len()).map_err(|_| RunStoreError::InvalidRun("metadata size"))?;
566 let mut bytes = Vec::with_capacity(capacity);
567 File::open(path)?
568 .take(MAX_RUN_METADATA_BYTES + 1)
569 .read_to_end(&mut bytes)?;
570 let metadata: RunMetadata = serde_json::from_slice(&bytes)
571 .map_err(|_| RunStoreError::InvalidRun("run metadata JSON"))?;
572 validate_integrity(&metadata.integrity)?;
573 Ok(metadata)
574}
575
576fn load_run(directory: &Path, entry: &str) -> Result<StoredRun, RunStoreError> {
577 if !valid_run_id(entry) {
578 return Err(RunStoreError::InvalidRun("run directory name"));
579 }
580 let directory_metadata = fs::symlink_metadata(directory)?;
581 if !directory_metadata.file_type().is_dir() {
582 return Err(RunStoreError::UnsafeStore(directory.to_owned()));
583 }
584 let metadata_path = directory.join("run.json");
585 let metadata = read_metadata(&metadata_path)?;
586 if metadata.id != entry {
587 return Err(RunStoreError::InvalidRun(
588 "metadata ID differs from directory",
589 ));
590 }
591 if metadata.raw_evidence.schema_version != EVIDENCE_ARCHIVE_SCHEMA_VERSION
592 || metadata.raw_evidence.format != "framed+gzip"
593 || metadata.raw_evidence.file != "evidence.raw.gz"
594 || metadata.raw_evidence.files == 0
595 {
596 return Err(RunStoreError::InvalidRun("raw evidence metadata"));
597 }
598 let evidence_path = directory.join("evidence.raw.gz");
599 let evidence_metadata = regular_file(&evidence_path)?;
600 if evidence_metadata.len() != metadata.raw_evidence.compressed_bytes {
601 return Err(RunStoreError::InvalidRun("raw evidence length"));
602 }
603 if read_archive_schema_version(&evidence_path)
604 .map_err(|_| RunStoreError::InvalidRun("raw evidence archive"))?
605 != metadata.raw_evidence.schema_version
606 {
607 return Err(RunStoreError::InvalidRun("raw evidence schema mismatch"));
608 }
609 Ok(StoredRun {
610 id: entry.into(),
611 directory: directory.to_owned(),
612 evidence_path,
613 metadata_path,
614 query_index_path: directory.join(RUST_QUERY_INDEX_FILE),
615 metadata,
616 })
617}
618
619pub fn discover_runs(project_root: &Path) -> Result<RunInventory, RunStoreError> {
620 let store = project_root.join(".supercov").join("runs");
621 let store_metadata = match fs::symlink_metadata(&store) {
622 Ok(metadata) => metadata,
623 Err(error) if error.kind() == io::ErrorKind::NotFound => {
624 return Ok(RunInventory {
625 runs: Vec::new(),
626 rejected: Vec::new(),
627 });
628 }
629 Err(error) => return Err(error.into()),
630 };
631 if !store_metadata.file_type().is_dir() {
632 return Err(RunStoreError::UnsafeStore(store));
633 }
634 let mut runs = Vec::new();
635 let mut rejected = Vec::new();
636 for entry in fs::read_dir(&store)? {
637 let entry = match entry {
638 Ok(entry) => entry,
639 Err(error) => {
640 rejected.push(RejectedRun {
641 entry: "<unreadable>".into(),
642 reason: error.to_string(),
643 });
644 continue;
645 }
646 };
647 let name = entry.file_name().to_string_lossy().into_owned();
648 match load_run(&entry.path(), &name) {
649 Ok(run) => runs.push(run),
650 Err(error) => rejected.push(RejectedRun {
651 entry: name,
652 reason: error.to_string(),
653 }),
654 }
655 }
656 runs.sort_by(|left, right| {
657 right
658 .metadata
659 .started_at
660 .cmp(&left.metadata.started_at)
661 .then_with(|| right.id.cmp(&left.id))
662 });
663 rejected.sort_by(|left, right| left.entry.cmp(&right.entry));
664 Ok(RunInventory { runs, rejected })
665}
666
667pub fn select_run<'a>(
668 inventory: &'a RunInventory,
669 selector: Option<&str>,
670) -> Result<&'a StoredRun, RunStoreError> {
671 if inventory.runs.is_empty() {
672 return Err(RunStoreError::NoRuns);
673 }
674 if selector.is_none() || selector == Some("latest") {
675 return Ok(&inventory.runs[0]);
676 }
677 let selector = selector.expect("checked selector");
678 inventory
679 .runs
680 .iter()
681 .find(|run| run.id == selector)
682 .or_else(|| {
683 inventory
684 .runs
685 .iter()
686 .find(|run| run.id.starts_with(selector))
687 })
688 .ok_or_else(|| RunStoreError::RunNotFound(selector.into()))
689}
690
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct IntegrityComparison {
693 pub stale: bool,
694 pub reasons: Vec<String>,
695}
696
697pub fn compare_run_integrity(
698 stored: Option<&RunIntegrity>,
699 current: &RunIntegrity,
700) -> IntegrityComparison {
701 let Some(stored) = stored else {
702 return IntegrityComparison {
703 stale: true,
704 reasons: vec!["run predates integrity fingerprints".into()],
705 };
706 };
707 if std::env::var_os("SUPERCOV_DEBUG_INTEGRITY").is_some() {
708 eprintln!("[integrity] stored : {stored:?}");
709 eprintln!("[integrity] current: {current:?}");
710 }
711 let mut reasons = Vec::new();
712 if stored.schema_version != current.schema_version {
713 reasons.push("coverage schema changed".into());
714 }
715 if stored.instrumenter_version != current.instrumenter_version {
721 reasons.push("instrumenter contract changed".into());
722 }
723 if stored.fingerprint.source != current.fingerprint.source {
724 reasons.push("instrumented source changed".into());
725 }
726 if stored.fingerprint.tests != current.fingerprint.tests {
727 reasons.push("test files changed".into());
728 }
729 if stored.fingerprint.dependencies != current.fingerprint.dependencies {
730 reasons.push("dependencies or lockfile changed".into());
731 }
732 if stored.fingerprint.configuration != current.fingerprint.configuration {
733 reasons.push("test/build configuration changed".into());
734 }
735 if reasons.is_empty() && stored.fingerprint.execution != current.fingerprint.execution {
736 reasons.push("execution environment changed".into());
737 }
738 IntegrityComparison {
739 stale: !reasons.is_empty(),
740 reasons,
741 }
742}
743
744fn file_sha256(path: &Path) -> Result<([u8; 32], u64), RunStoreError> {
745 let metadata = regular_file(path)?;
746 let mut file = File::open(path)?;
747 let mut hash = Sha256::new();
748 let mut buffer = [0_u8; 128 * 1024];
749 loop {
750 let read = file.read(&mut buffer)?;
751 if read == 0 {
752 break;
753 }
754 hash.update(&buffer[..read]);
755 }
756 Ok((hash.finalize().into(), metadata.len()))
757}
758
759fn domain_hash(domain: &str, version: u32) -> [u8; 32] {
760 let mut hash = Sha256::new();
761 hash.update(domain.as_bytes());
762 hash.update([0]);
763 hash.update(version.to_le_bytes());
764 hash.update([0]);
765 hash.update(env!("SUPERCOV_ENGINE_SOURCE_SHA256").as_bytes());
766 hash.finalize().into()
767}
768
769pub fn query_index_identity(run: &StoredRun) -> Result<QueryIndexIdentity, RunStoreError> {
770 let (evidence_sha256, evidence_bytes) = file_sha256(&run.evidence_path)?;
771 Ok(QueryIndexIdentity {
772 evidence_sha256,
773 evidence_bytes,
774 analysis_sha256: domain_hash("supercov-analysis", RUST_ANALYSIS_ABI_VERSION),
775 producer_sha256: domain_hash(
776 "supercov-query-producer",
777 RUST_QUERY_PRODUCER_ABI_VERSION ^ QUERY_INDEX_SCHEMA_VERSION,
778 ),
779 archive_schema_version: run.metadata.raw_evidence.schema_version,
780 })
781}
782
783#[derive(Debug)]
784pub enum RunIndexError {
785 RunStore(RunStoreError),
786 QueryIndex(QueryIndexError),
787 CoverageIndex(CoverageIndexError),
788 Report(ReportError),
789 Metadata(serde_json::Error),
790 EvidenceChanged,
791}
792
793impl std::fmt::Display for RunIndexError {
794 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
795 match self {
796 Self::RunStore(error) => write!(formatter, "{error}"),
797 Self::QueryIndex(error) => write!(formatter, "{error}"),
798 Self::CoverageIndex(error) => write!(formatter, "{error}"),
799 Self::Report(ReportError::NoEvidence(_)) => {
800 write!(formatter, "no coverage evidence was published")
801 }
802 Self::Report(ReportError::NoTestMatches(filter)) => {
803 write!(formatter, "no test of the run matches {filter}")
804 }
805 Self::Report(error) => write!(formatter, "coverage analysis failed: {error:?}"),
806 Self::Metadata(error) => write!(formatter, "run integrity is invalid: {error}"),
807 Self::EvidenceChanged => write!(formatter, "evidence changed while indexing"),
808 }
809 }
810}
811
812impl std::error::Error for RunIndexError {}
813
814impl From<RunStoreError> for RunIndexError {
815 fn from(value: RunStoreError) -> Self {
816 Self::RunStore(value)
817 }
818}
819
820impl From<QueryIndexError> for RunIndexError {
821 fn from(value: QueryIndexError) -> Self {
822 Self::QueryIndex(value)
823 }
824}
825
826impl From<CoverageIndexError> for RunIndexError {
827 fn from(value: CoverageIndexError) -> Self {
828 Self::CoverageIndex(value)
829 }
830}
831
832impl From<ReportError> for RunIndexError {
833 fn from(value: ReportError) -> Self {
834 Self::Report(value)
835 }
836}
837
838impl From<serde_json::Error> for RunIndexError {
839 fn from(value: serde_json::Error) -> Self {
840 Self::Metadata(value)
841 }
842}
843
844fn open_validated_query_index(
845 path: &Path,
846 identity: &QueryIndexIdentity,
847) -> Result<QueryIndex, RunIndexError> {
848 let index = QueryIndex::open(path, identity)?;
849 index.verify_all()?;
850 CoverageIndex::new(&index)?;
851 Ok(index)
852}
853
854fn open_written_query_index(
863 path: &Path,
864 identity: &QueryIndexIdentity,
865) -> Result<QueryIndex, RunIndexError> {
866 let index = QueryIndex::open(path, identity)?;
867 CoverageIndex::new(&index)?;
868 Ok(index)
869}
870
871pub fn open_existing_query_index(run: &StoredRun) -> Result<Option<QueryIndex>, RunIndexError> {
873 match fs::symlink_metadata(&run.query_index_path) {
874 Ok(_) => {
875 open_validated_query_index(&run.query_index_path, &query_index_identity(run)?).map(Some)
876 }
877 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
878 Err(error) => Err(RunStoreError::Io(error).into()),
879 }
880}
881
882pub fn analyze_stored_run(run: &StoredRun) -> Result<CoverageReport, RunIndexError> {
884 analyze_stored_run_filtered(run, None)
885}
886
887pub fn analyze_stored_run_filtered(
890 run: &StoredRun,
891 filter: Option<&crate::coverage_report::TestFilter>,
892) -> Result<CoverageReport, RunIndexError> {
893 Ok(analyze_coverage_archive(&ArchiveReportRequest {
894 archive_path: run.evidence_path.clone(),
895 run_id: run.id.clone(),
896 generated_at: run.metadata.started_at.clone(),
897 integrity: Some(serde_json::to_value(&run.metadata.integrity)?),
898 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
899 test_filter: filter.cloned(),
900 })?)
901}
902
903fn filter_digest(filter: &crate::coverage_report::TestFilter) -> [u8; 32] {
906 let mut parts = [
907 filter
908 .tags
909 .iter()
910 .map(|tag| tag.trim_start_matches('@').to_lowercase())
911 .collect::<Vec<_>>(),
912 filter
913 .titles
914 .iter()
915 .map(|title| title.to_lowercase())
916 .collect(),
917 filter
918 .files
919 .iter()
920 .map(|file| {
921 file.trim_start_matches("./")
922 .trim_end_matches('/')
923 .to_owned()
924 })
925 .collect(),
926 ];
927 parts[0].extend(filter.kind.iter().map(|kind| format!("kind\0{kind}")));
928 parts[0].extend(
929 filter
930 .runner
931 .iter()
932 .map(|runner| format!("runner\0{runner}")),
933 );
934 let mut hash = Sha256::new();
935 hash.update(domain_hash(
936 "supercov-test-filter",
937 QUERY_INDEX_SCHEMA_VERSION,
938 ));
939 for part in &mut parts {
940 part.sort();
941 part.dedup();
942 for value in part.iter() {
943 hash.update(value.as_bytes());
944 hash.update([0]);
945 }
946 hash.update([1]);
947 }
948 hash.finalize().into()
949}
950
951pub fn open_or_build_filtered_query_index(
956 run: &StoredRun,
957 filter: &crate::coverage_report::TestFilter,
958) -> Result<QueryIndex, RunIndexError> {
959 let digest = filter_digest(filter);
960 let mut identity = query_index_identity(run)?;
961 identity.analysis_sha256 = digest;
962 let name = digest
963 .iter()
964 .take(8)
965 .map(|byte| format!("{byte:02x}"))
966 .collect::<String>();
967 let path = run
968 .query_index_path
969 .with_file_name(format!("filtered-{name}.index"));
970 if let Ok(index) = open_validated_query_index(&path, &identity) {
971 touch(&path);
972 return Ok(index);
973 }
974 let report = analyze_stored_run_filtered(run, Some(filter))?;
975 let sections = coverage_index_sections(&report)?;
976 std::thread::spawn(move || drop(report));
977 let mut current = query_index_identity(run)?;
978 current.analysis_sha256 = digest;
979 if current != identity {
980 return Err(RunIndexError::EvidenceChanged);
981 }
982 write_query_index(§ions, &identity, &path)?;
983 std::thread::spawn(move || drop(sections));
984 prune_filtered_indexes(&path);
985 open_written_query_index(&path, &identity)
986}
987
988const KEPT_FILTERED_INDEXES: usize = 8;
991
992fn touch(path: &Path) {
994 if let Ok(file) = fs::File::options().append(true).open(path) {
995 let _ = file.set_modified(std::time::SystemTime::now());
996 }
997}
998
999fn prune_filtered_indexes(kept: &Path) {
1001 let Some(directory) = kept.parent() else {
1002 return;
1003 };
1004 let Ok(entries) = fs::read_dir(directory) else {
1005 return;
1006 };
1007 let mut filtered = entries
1008 .filter_map(Result::ok)
1009 .filter(|entry| {
1010 let name = entry.file_name();
1011 let name = name.to_string_lossy();
1012 name.starts_with("filtered-") && name.ends_with(".index")
1013 })
1014 .filter_map(|entry| Some((entry.metadata().ok()?.modified().ok()?, entry.path())))
1015 .collect::<Vec<_>>();
1016 filtered.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
1017 for (_, path) in filtered.into_iter().skip(KEPT_FILTERED_INDEXES) {
1018 if path != kept {
1019 let _ = fs::remove_file(path);
1020 }
1021 }
1022}
1023
1024pub fn statement_spans(report: &CoverageReport) -> Vec<serde_json::Value> {
1033 report
1034 .view
1035 .points
1036 .iter()
1037 .filter(|p| p.meta.kind == crate::coverage_analysis::PointKind::Statement)
1038 .filter_map(|p| {
1039 let extra = p.meta.source.matches('\n').count();
1040 (extra > 0).then(|| {
1041 serde_json::json!({
1042 "file": p.meta.file, "line": p.meta.line,
1043 "end": p.meta.line + extra, "covered": p.covered,
1044 })
1045 })
1046 })
1047 .collect()
1048}
1049
1050pub fn read_statement_spans(run: &StoredRun) -> Option<Vec<serde_json::Value>> {
1052 let bytes = fs::read(run.directory.join(STATEMENT_SPANS_FILE)).ok()?;
1053 serde_json::from_slice(&bytes).ok()
1054}
1055
1056pub(crate) fn write_statement_spans(run: &StoredRun, report: &CoverageReport) -> io::Result<()> {
1057 let bytes = serde_json::to_vec(&statement_spans(report)).map_err(io::Error::other)?;
1058 fs::write(run.directory.join(STATEMENT_SPANS_FILE), bytes)
1059}
1060
1061pub fn read_assessment_input(
1063 run: &StoredRun,
1064) -> Option<crate::assertion_coverage::AssessmentInput> {
1065 let file = fs::File::open(run.directory.join(ASSESSMENT_INPUT_FILE)).ok()?;
1066 serde_json::from_reader(flate2::read::GzDecoder::new(io::BufReader::new(file))).ok()
1067}
1068
1069pub(crate) fn write_assessment_input(run: &StoredRun, report: &CoverageReport) -> io::Result<()> {
1070 use std::io::Write;
1071 let bytes = serde_json::to_vec(&crate::assertion_coverage::assessment_input(report))
1072 .map_err(io::Error::other)?;
1073 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1074 encoder.write_all(&bytes)?;
1075 fs::write(run.directory.join(ASSESSMENT_INPUT_FILE), encoder.finish()?)
1076}
1077
1078pub(crate) fn write_query_index_from(
1079 run: &StoredRun,
1080 report: &CoverageReport,
1081) -> Result<(), RunIndexError> {
1082 let identity = query_index_identity(run)?;
1083 let sections = coverage_index_sections(report)?;
1084 write_query_index(§ions, &identity, &run.query_index_path)?;
1085 std::thread::spawn(move || drop(sections));
1086 Ok(())
1087}
1088
1089pub fn open_or_rebuild_query_index(run: &StoredRun) -> Result<QueryIndex, RunIndexError> {
1096 let identity = query_index_identity(run)?;
1097 if let Ok(index) = open_validated_query_index(&run.query_index_path, &identity) {
1098 return Ok(index);
1099 }
1100
1101 let report = analyze_stored_run(run)?;
1102 let sections = coverage_index_sections(&report)?;
1103 std::thread::spawn(move || drop(report));
1106 if query_index_identity(run)? != identity {
1107 return Err(RunIndexError::EvidenceChanged);
1108 }
1109 write_query_index(§ions, &identity, &run.query_index_path)?;
1110 std::thread::spawn(move || drop(sections));
1111 let index = open_written_query_index(&run.query_index_path, &identity)?;
1112 if query_index_identity(run)? != identity {
1113 return Err(RunIndexError::EvidenceChanged);
1114 }
1115 Ok(index)
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use std::{
1121 fs,
1122 time::{SystemTime, UNIX_EPOCH},
1123 };
1124
1125 use crate::evidence_archive::{EvidenceArchiveEntry, read_archive, write_archive};
1126
1127 use super::*;
1128
1129 fn temporary_directory(label: &str) -> PathBuf {
1130 let nonce = SystemTime::now()
1131 .duration_since(UNIX_EPOCH)
1132 .unwrap()
1133 .as_nanos();
1134 let path = std::env::temp_dir().join(format!(
1135 "supercov-run-store-{label}-{}-{nonce}",
1136 std::process::id()
1137 ));
1138 fs::create_dir_all(&path).unwrap();
1139 path
1140 }
1141
1142 fn digest(character: char) -> String {
1143 std::iter::repeat_n(character, 64).collect()
1144 }
1145
1146 fn integrity() -> RunIntegrity {
1147 RunIntegrity {
1148 schema_version: 2,
1149 instrumenter_version: "2.0.0".into(),
1150 git: Some(GitIntegrity {
1151 revision: Some(std::iter::repeat_n('a', 40).collect()),
1152 branch: None,
1153 dirty: false,
1154 }),
1155 fingerprint: RunFingerprint {
1156 algorithm: "sha256".into(),
1157 source: digest('a'),
1158 tests: digest('b'),
1159 dependencies: digest('c'),
1160 configuration: digest('d'),
1161 instrumenter: digest('e'),
1162 execution: digest('f'),
1163 combined: digest('0'),
1164 source_files: 1,
1165 test_files: 1,
1166 },
1167 stale: None,
1168 stale_reasons: None,
1169 }
1170 }
1171
1172 fn create_run(root: &Path, id: &str) -> PathBuf {
1173 let directory = root.join(".supercov/runs").join(id);
1174 fs::create_dir_all(&directory).unwrap();
1175 let archive = write_archive(
1176 vec![
1177 EvidenceArchiveEntry {
1178 path: "coverage-model.json".into(),
1179 contents: br#"{"schemaVersion":1,"language":"fixture","variant":"fixture-v1","name":"Fixture model","completenessMeaning":"Fixture archive identity only.","measured":["fixture"],"notMeasured":[]}"#.to_vec(),
1180 },
1181 EvidenceArchiveEntry {
1182 path: "frontend.json".into(),
1183 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(),
1184 },
1185 EvidenceArchiveEntry {
1186 path: "manifest.json".into(),
1187 contents: b"{}".to_vec(),
1188 },
1189 ],
1190 &directory.join("evidence.raw.gz"),
1191 )
1192 .unwrap();
1193 let metadata = RunMetadata {
1194 id: id.into(),
1195 started_at: id.into(),
1196 duration_ms: 1.0,
1197 command: vec!["npm".into(), "test".into()],
1198 test_exit_code: Some(0),
1199 integrity: integrity(),
1200 raw_evidence: RawEvidenceMetadata {
1201 schema_version: archive.schema_version,
1202 format: archive.format.into(),
1203 file: archive.file.into(),
1204 files: archive.files,
1205 uncompressed_bytes: archive.uncompressed_bytes,
1206 compressed_bytes: archive.compressed_bytes,
1207 },
1208 isolated_build: Some(true),
1209 instrumented_build_cache: None,
1210 timings: None,
1211 merged: None,
1212 parents: None,
1213 source_roots: None,
1214 copy: None,
1215 merged_across: None,
1216 recorded_by: None,
1217 companions: None,
1218 };
1219 fs::write(
1220 directory.join("run.json"),
1221 serde_json::to_vec_pretty(&metadata).unwrap(),
1222 )
1223 .unwrap();
1224 directory
1225 }
1226
1227 fn create_indexable_run(root: &Path) -> StoredRun {
1228 create_analyzable_test_run(root, "test-run");
1229 discover_runs(root).unwrap().runs.remove(0)
1230 }
1231
1232 fn create_indexable_python_run(root: &Path) -> StoredRun {
1233 let directory = create_analyzable_test_run(root, "python-run");
1234 let evidence_path = directory.join("evidence.raw.gz");
1235 let mut entries = read_archive(&evidence_path).unwrap();
1236 for entry in &mut entries {
1237 if entry.path.ends_with("/mcdc.json") {
1238 let mut result: serde_json::Value =
1239 serde_json::from_slice(&entry.contents).unwrap();
1240 result["provenance"]["runner"] = "pytest".into();
1241 result["provenance"]["kind"] = "unit".into();
1242 result["testFile"] = "tests/test_app.py".into();
1243 entry.contents = serde_json::to_vec(&result).unwrap();
1244 }
1245 }
1246 entries
1247 .iter_mut()
1248 .find(|entry| entry.path == "frontend.json")
1249 .unwrap()
1250 .contents = serde_json::to_vec(&serde_json::json!({
1251 "protocolVersion": 2,
1252 "frontendId": "python",
1253 "frontendVersion": "python-owned-v1",
1254 "language": "python",
1255 "structuralSource": "owned-probes",
1256 "runners": [{
1257 "runner": "pytest",
1258 "executionModel": "serial-in-process",
1259 "attribution": {
1260 "run": "exact",
1261 "worker": "unavailable",
1262 "test": "exact",
1263 "retry": "exact",
1264 "phase": "exact",
1265 "action": "exact",
1266 "assertion": "exact"
1267 },
1268 "limitations": [{
1269 "id": "test-fixture-worker-unavailable",
1270 "scopes": ["worker"],
1271 "reason": "The persisted-run fixture intentionally has no worker identity"
1272 }]
1273 }],
1274 "structuralLimitations": []
1275 }))
1276 .unwrap();
1277 entries
1278 .iter_mut()
1279 .find(|entry| entry.path == "coverage-model.json")
1280 .unwrap()
1281 .contents = serde_json::to_vec(&serde_json::json!({
1282 "schemaVersion": 1,
1283 "language": "python",
1284 "variant": "all",
1285 "name": "python-owned-control-flow",
1286 "completenessMeaning": "Every declared owned-probe obligation was observed.",
1287 "measured": ["owned statements", "owned decisions"],
1288 "notMeasured": []
1289 }))
1290 .unwrap();
1291 let archive = write_archive(entries, &evidence_path).unwrap();
1292 let metadata_path = directory.join("run.json");
1293 let mut metadata: RunMetadata =
1294 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1295 metadata.raw_evidence = RawEvidenceMetadata {
1296 schema_version: archive.schema_version,
1297 format: archive.format.into(),
1298 file: archive.file.into(),
1299 files: archive.files,
1300 uncompressed_bytes: archive.uncompressed_bytes,
1301 compressed_bytes: archive.compressed_bytes,
1302 };
1303 fs::write(metadata_path, serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
1304 discover_runs(root).unwrap().runs.remove(0)
1305 }
1306
1307 #[test]
1308 fn discovers_valid_runs_in_reverse_order_and_selects_exact_prefix_or_latest() {
1309 let root = temporary_directory("discovery");
1310 create_run(&root, "2026-08-24T00-00-00-000Z");
1311 create_run(&root, "2026-08-25T00-00-00-000Z");
1312 create_run(&root, "zz-custom-old-run");
1313 let custom_metadata = root.join(".supercov/runs/zz-custom-old-run/run.json");
1314 let mut custom: serde_json::Value =
1315 serde_json::from_slice(&fs::read(&custom_metadata).unwrap()).unwrap();
1316 custom["startedAt"] = "2026-08-23T00:00:00.000Z".into();
1317 fs::write(
1318 &custom_metadata,
1319 serde_json::to_vec_pretty(&custom).unwrap(),
1320 )
1321 .unwrap();
1322 let inventory = discover_runs(&root).unwrap();
1323 assert!(inventory.rejected.is_empty());
1324 assert_eq!(
1325 inventory
1326 .runs
1327 .iter()
1328 .map(|run| run.id.as_str())
1329 .collect::<Vec<_>>(),
1330 [
1331 "2026-08-25T00-00-00-000Z",
1332 "2026-08-24T00-00-00-000Z",
1333 "zz-custom-old-run"
1334 ]
1335 );
1336 assert_eq!(
1337 select_run(&inventory, None).unwrap().id,
1338 inventory.runs[0].id
1339 );
1340 assert_eq!(
1341 select_run(&inventory, Some("2026-08-24")).unwrap().id,
1342 "2026-08-24T00-00-00-000Z"
1343 );
1344 assert!(matches!(
1345 select_run(&inventory, Some("missing")),
1346 Err(RunStoreError::RunNotFound(_))
1347 ));
1348 fs::remove_dir_all(root).unwrap();
1349 }
1350
1351 #[test]
1352 fn reports_damaged_entries_without_hiding_valid_runs() {
1353 let root = temporary_directory("rejected");
1354 create_run(&root, "valid");
1355 let mismatched = create_run(&root, "mismatched");
1356 let mut metadata: serde_json::Value =
1357 serde_json::from_slice(&fs::read(mismatched.join("run.json")).unwrap()).unwrap();
1358 metadata["id"] = "different".into();
1359 fs::write(
1360 mismatched.join("run.json"),
1361 serde_json::to_vec(&metadata).unwrap(),
1362 )
1363 .unwrap();
1364 let corrupt = create_run(&root, "wrong-length");
1365 fs::write(corrupt.join("evidence.raw.gz"), b"truncated").unwrap();
1366 let schema_mismatch = create_run(&root, "schema-mismatch");
1367 let metadata_path = schema_mismatch.join("run.json");
1368 let mut metadata: serde_json::Value =
1369 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1370 metadata["rawEvidence"]["schemaVersion"] = 2.into();
1371 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1372
1373 let inventory = discover_runs(&root).unwrap();
1374 assert_eq!(inventory.runs.len(), 1);
1375 assert_eq!(inventory.runs[0].id, "valid");
1376 assert_eq!(inventory.rejected.len(), 3);
1377 assert!(inventory.rejected[0].reason.contains("metadata ID"));
1378 assert!(
1379 inventory
1380 .rejected
1381 .iter()
1382 .any(|run| run.reason.contains("raw evidence metadata"))
1383 );
1384 assert!(
1385 inventory
1386 .rejected
1387 .iter()
1388 .any(|run| run.reason.contains("raw evidence length"))
1389 );
1390 fs::remove_dir_all(root).unwrap();
1391 }
1392
1393 #[cfg(unix)]
1394 #[test]
1395 fn refuses_linked_run_directories_and_files() {
1396 use std::os::unix::fs::symlink;
1397
1398 let root = temporary_directory("links");
1399 let target = create_run(&root, "target");
1400 symlink(&target, root.join(".supercov/runs/linked-run")).unwrap();
1401 let linked_metadata = create_run(&root, "linked-metadata");
1402 fs::remove_file(linked_metadata.join("run.json")).unwrap();
1403 symlink(target.join("run.json"), linked_metadata.join("run.json")).unwrap();
1404 let linked_evidence = create_run(&root, "linked-evidence");
1405 fs::remove_file(linked_evidence.join("evidence.raw.gz")).unwrap();
1406 symlink(
1407 target.join("evidence.raw.gz"),
1408 linked_evidence.join("evidence.raw.gz"),
1409 )
1410 .unwrap();
1411
1412 let inventory = discover_runs(&root).unwrap();
1413 assert_eq!(inventory.runs.len(), 1);
1414 assert_eq!(inventory.runs[0].id, "target");
1415 assert_eq!(inventory.rejected.len(), 3);
1416 assert!(
1417 inventory
1418 .rejected
1419 .iter()
1420 .all(|rejected| rejected.reason.contains("unsafe run-store path"))
1421 );
1422 fs::remove_dir_all(root).unwrap();
1423 }
1424
1425 #[test]
1426 fn compares_integrity_in_stable_contract_order_and_binds_identity_to_evidence() {
1427 let root = temporary_directory("identity");
1428 create_run(&root, "run");
1429 let inventory = discover_runs(&root).unwrap();
1430 let run = &inventory.runs[0];
1431 let first = query_index_identity(run).unwrap();
1432 fs::write(&run.evidence_path, b"different bytes").unwrap();
1433 let second = query_index_identity(run).unwrap();
1434 assert_ne!(first.evidence_sha256, second.evidence_sha256);
1435 assert_ne!(first.evidence_bytes, second.evidence_bytes);
1436 assert_eq!(first.analysis_sha256, second.analysis_sha256);
1437 assert_eq!(env!("SUPERCOV_ENGINE_SOURCE_SHA256").len(), 64);
1438
1439 let mut current = integrity();
1440 current.schema_version += 1;
1441 current.instrumenter_version = "javascript-v2".into();
1442 current.fingerprint.source = digest('2');
1443 current.fingerprint.tests = digest('3');
1444 current.fingerprint.dependencies = digest('4');
1445 current.fingerprint.configuration = digest('5');
1446 assert_eq!(
1447 compare_run_integrity(Some(&integrity()), ¤t).reasons,
1448 [
1449 "coverage schema changed",
1450 "instrumenter contract changed",
1451 "instrumented source changed",
1452 "test files changed",
1453 "dependencies or lockfile changed",
1454 "test/build configuration changed",
1455 ]
1456 );
1457
1458 let mut rebuilt = integrity();
1463 rebuilt.fingerprint.instrumenter = digest('1');
1464 assert!(!compare_run_integrity(Some(&integrity()), &rebuilt).stale);
1465 fs::remove_dir_all(root).unwrap();
1466 }
1467
1468 #[test]
1469 fn lazily_builds_reuses_and_repairs_a_fully_authenticated_typed_index() {
1470 let root = temporary_directory("lazy-index");
1471 let run = create_indexable_run(&root);
1472 assert!(!run.query_index_path.exists());
1473
1474 {
1475 let index = open_or_rebuild_query_index(&run).unwrap();
1476 index.verify_all().unwrap();
1477 CoverageIndex::new(&index).unwrap();
1478 }
1479 let canonical = fs::read(&run.query_index_path).unwrap();
1480 assert!(canonical.len() > crate::query_index::QUERY_INDEX_HEADER_SIZE);
1481 {
1482 let index = open_or_rebuild_query_index(&run).unwrap();
1483 index.verify_all().unwrap();
1484 }
1485 assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1486
1487 let mut corrupt = canonical.clone();
1488 let offset = crate::query_index::QUERY_INDEX_HEADER_SIZE + 8;
1489 corrupt[offset] ^= 0xff;
1490 fs::write(&run.query_index_path, corrupt).unwrap();
1491 {
1492 let index = open_or_rebuild_query_index(&run).unwrap();
1493 index.verify_all().unwrap();
1494 }
1495 assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1496 fs::remove_dir_all(root).unwrap();
1497 }
1498
1499 #[test]
1500 fn an_index_opened_without_the_full_check_still_refuses_a_corrupt_page() {
1501 let root = temporary_directory("written-index");
1506 let run = create_indexable_python_run(&root);
1507 let identity = query_index_identity(&run).unwrap();
1508 drop(open_or_rebuild_query_index(&run).unwrap());
1509 let lines = QueryIndex::open(&run.query_index_path, &identity)
1510 .unwrap()
1511 .descriptor(crate::coverage_index::SECTION_LINES)
1512 .unwrap();
1513 assert!(lines.length > 0, "the fixture has line records to corrupt");
1514
1515 let mut bytes = fs::read(&run.query_index_path).unwrap();
1516 bytes[lines.offset as usize] ^= 0xff;
1517 fs::write(&run.query_index_path, bytes).unwrap();
1518
1519 let index = open_written_query_index(&run.query_index_path, &identity)
1520 .expect("opening does not hash every page");
1521 assert!(
1522 matches!(
1523 index.bytes(crate::coverage_index::SECTION_LINES, 0, 1),
1524 Err(crate::query_index::QueryIndexError::CorruptPage { .. })
1525 ),
1526 "the corrupt page is refused when it is read"
1527 );
1528 assert!(
1529 index.verify_all().is_err(),
1530 "and a full check still finds it"
1531 );
1532 fs::remove_dir_all(root).unwrap();
1533 }
1534
1535 #[test]
1536 fn indexes_the_declared_language_model() {
1537 let root = temporary_directory("v3-index");
1538 let run = create_indexable_python_run(&root);
1539 assert_eq!(
1540 run.metadata.raw_evidence.schema_version,
1541 EVIDENCE_ARCHIVE_SCHEMA_VERSION
1542 );
1543 assert_eq!(
1544 query_index_identity(&run).unwrap().archive_schema_version,
1545 EVIDENCE_ARCHIVE_SCHEMA_VERSION
1546 );
1547 let index = open_or_rebuild_query_index(&run).unwrap();
1548 index.verify_all().unwrap();
1549 let coverage_index = CoverageIndex::new(&index).unwrap();
1550 assert_eq!(
1551 coverage_index.model().unwrap().name,
1552 "python-owned-control-flow"
1553 );
1554 let summary = coverage_index
1555 .summary(crate::coverage_index::CoverageViewId::All)
1556 .unwrap();
1557 assert_eq!(summary.lines.percentage, 100.0);
1558 fs::remove_dir_all(root).unwrap();
1559 }
1560
1561 #[cfg(unix)]
1562 #[test]
1563 fn atomically_replaces_a_linked_disposable_index_without_touching_its_target() {
1564 use std::os::unix::fs::symlink;
1565
1566 let root = temporary_directory("linked-index");
1567 let run = create_indexable_run(&root);
1568 let outside = root.join("outside");
1569 fs::write(&outside, b"user data").unwrap();
1570 symlink(&outside, &run.query_index_path).unwrap();
1571
1572 let index = open_or_rebuild_query_index(&run).unwrap();
1573 index.verify_all().unwrap();
1574 assert_eq!(fs::read(outside).unwrap(), b"user data");
1575 assert!(
1576 fs::symlink_metadata(&run.query_index_path)
1577 .unwrap()
1578 .file_type()
1579 .is_file()
1580 );
1581 fs::remove_dir_all(root).unwrap();
1582 }
1583}