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