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] {
765 let mut hash = Sha256::new();
766 hash.update(domain.as_bytes());
767 hash.update([0]);
768 hash.update(version.to_le_bytes());
769 hash.update([0]);
770 hash.update(supercov_contracts::COVERAGE_MODEL_SCHEMA_VERSION.to_le_bytes());
771 hash.update([0]);
772 hash.update(env!("SUPERCOV_ANALYSIS_SOURCE_SHA256").as_bytes());
773 hash.finalize().into()
774}
775
776pub fn query_index_identity(run: &StoredRun) -> Result<QueryIndexIdentity, RunStoreError> {
777 let (evidence_sha256, evidence_bytes) = file_sha256(&run.evidence_path)?;
778 Ok(QueryIndexIdentity {
779 evidence_sha256,
780 evidence_bytes,
781 analysis_sha256: domain_hash("supercov-analysis", RUST_ANALYSIS_ABI_VERSION),
782 producer_sha256: domain_hash(
783 "supercov-query-producer",
784 RUST_QUERY_PRODUCER_ABI_VERSION ^ QUERY_INDEX_SCHEMA_VERSION,
785 ),
786 archive_schema_version: run.metadata.raw_evidence.schema_version,
787 })
788}
789
790#[derive(Debug)]
791pub enum RunIndexError {
792 RunStore(RunStoreError),
793 QueryIndex(QueryIndexError),
794 CoverageIndex(CoverageIndexError),
795 Report(ReportError),
796 Metadata(serde_json::Error),
797 EvidenceChanged,
798}
799
800impl std::fmt::Display for RunIndexError {
801 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
802 match self {
803 Self::RunStore(error) => write!(formatter, "{error}"),
804 Self::QueryIndex(error) => write!(formatter, "{error}"),
805 Self::CoverageIndex(error) => write!(formatter, "{error}"),
806 Self::Report(ReportError::NoEvidence(_)) => {
807 write!(formatter, "no coverage evidence was published")
808 }
809 Self::Report(ReportError::NoTestMatches(filter)) => {
810 write!(formatter, "no test of the run matches {filter}")
811 }
812 Self::Report(error) => write!(formatter, "coverage analysis failed: {error:?}"),
813 Self::Metadata(error) => write!(formatter, "run integrity is invalid: {error}"),
814 Self::EvidenceChanged => write!(formatter, "evidence changed while indexing"),
815 }
816 }
817}
818
819impl std::error::Error for RunIndexError {}
820
821impl From<RunStoreError> for RunIndexError {
822 fn from(value: RunStoreError) -> Self {
823 Self::RunStore(value)
824 }
825}
826
827impl From<QueryIndexError> for RunIndexError {
828 fn from(value: QueryIndexError) -> Self {
829 Self::QueryIndex(value)
830 }
831}
832
833impl From<CoverageIndexError> for RunIndexError {
834 fn from(value: CoverageIndexError) -> Self {
835 Self::CoverageIndex(value)
836 }
837}
838
839impl From<ReportError> for RunIndexError {
840 fn from(value: ReportError) -> Self {
841 Self::Report(value)
842 }
843}
844
845impl From<serde_json::Error> for RunIndexError {
846 fn from(value: serde_json::Error) -> Self {
847 Self::Metadata(value)
848 }
849}
850
851fn open_validated_query_index(
852 path: &Path,
853 identity: &QueryIndexIdentity,
854) -> Result<QueryIndex, RunIndexError> {
855 let index = QueryIndex::open(path, identity)?;
856 index.verify_all()?;
857 CoverageIndex::new(&index)?;
858 Ok(index)
859}
860
861fn open_written_query_index(
870 path: &Path,
871 identity: &QueryIndexIdentity,
872) -> Result<QueryIndex, RunIndexError> {
873 let index = QueryIndex::open(path, identity)?;
874 CoverageIndex::new(&index)?;
875 Ok(index)
876}
877
878pub fn open_existing_query_index(run: &StoredRun) -> Result<Option<QueryIndex>, RunIndexError> {
880 match fs::symlink_metadata(&run.query_index_path) {
881 Ok(_) => {
882 open_validated_query_index(&run.query_index_path, &query_index_identity(run)?).map(Some)
883 }
884 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
885 Err(error) => Err(RunStoreError::Io(error).into()),
886 }
887}
888
889pub fn analyze_stored_run(run: &StoredRun) -> Result<CoverageReport, RunIndexError> {
891 analyze_stored_run_filtered(run, None)
892}
893
894pub fn analyze_stored_run_filtered(
897 run: &StoredRun,
898 filter: Option<&crate::coverage_report::TestFilter>,
899) -> Result<CoverageReport, RunIndexError> {
900 Ok(analyze_coverage_archive(&ArchiveReportRequest {
901 archive_path: run.evidence_path.clone(),
902 run_id: run.id.clone(),
903 generated_at: run.metadata.started_at.clone(),
904 integrity: Some(serde_json::to_value(&run.metadata.integrity)?),
905 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
906 test_filter: filter.cloned(),
907 })?)
908}
909
910fn index_sections(
912 run: &StoredRun,
913 filter: Option<&crate::coverage_report::TestFilter>,
914) -> Result<Vec<crate::query_index::QueryIndexSection>, RunIndexError> {
915 use crate::coverage_index::CoverageViewId;
916 let (_, normalized, transport) =
917 crate::coverage_report::archive_request(&ArchiveReportRequest {
918 archive_path: run.evidence_path.clone(),
919 run_id: run.id.clone(),
920 generated_at: run.metadata.started_at.clone(),
921 integrity: Some(serde_json::to_value(&run.metadata.integrity)?),
922 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
923 test_filter: filter.cloned(),
924 })?;
925 let sections = crate::coverage_index::coverage_index_sections_from(
926 crate::coverage_report::coverage_views(
927 &normalized,
928 &[
929 CoverageViewId::All,
930 CoverageViewId::Passed,
931 CoverageViewId::Failed,
932 ],
933 Some(transport),
934 )?
935 .map(|view| view.map_err(RunIndexError::Report)),
936 )?;
937 std::thread::spawn(move || drop(normalized));
939 Ok(sections)
940}
941
942fn filter_digest(filter: &crate::coverage_report::TestFilter) -> [u8; 32] {
945 let mut parts = [
946 filter
947 .tags
948 .iter()
949 .map(|tag| tag.trim_start_matches('@').to_lowercase())
950 .collect::<Vec<_>>(),
951 filter
952 .titles
953 .iter()
954 .map(|title| title.to_lowercase())
955 .collect(),
956 filter
957 .files
958 .iter()
959 .map(|file| {
960 file.trim_start_matches("./")
961 .trim_end_matches('/')
962 .to_owned()
963 })
964 .collect(),
965 ];
966 parts[0].extend(filter.kind.iter().map(|kind| format!("kind\0{kind}")));
967 parts[0].extend(
968 filter
969 .runner
970 .iter()
971 .map(|runner| format!("runner\0{runner}")),
972 );
973 let mut hash = Sha256::new();
974 hash.update(domain_hash(
975 "supercov-test-filter",
976 QUERY_INDEX_SCHEMA_VERSION,
977 ));
978 for part in &mut parts {
979 part.sort();
980 part.dedup();
981 for value in part.iter() {
982 hash.update(value.as_bytes());
983 hash.update([0]);
984 }
985 hash.update([1]);
986 }
987 hash.finalize().into()
988}
989
990pub fn open_or_build_filtered_query_index(
995 run: &StoredRun,
996 filter: &crate::coverage_report::TestFilter,
997) -> Result<QueryIndex, RunIndexError> {
998 let digest = filter_digest(filter);
999 let mut identity = query_index_identity(run)?;
1000 identity.analysis_sha256 = digest;
1001 let name = digest
1002 .iter()
1003 .take(8)
1004 .map(|byte| format!("{byte:02x}"))
1005 .collect::<String>();
1006 let path = run
1007 .query_index_path
1008 .with_file_name(format!("filtered-{name}.index"));
1009 if let Ok(index) = open_validated_query_index(&path, &identity) {
1010 touch(&path);
1011 return Ok(index);
1012 }
1013 let sections = index_sections(run, Some(filter))?;
1014 let mut current = query_index_identity(run)?;
1015 current.analysis_sha256 = digest;
1016 if current != identity {
1017 return Err(RunIndexError::EvidenceChanged);
1018 }
1019 write_query_index(§ions, &identity, &path)?;
1020 std::thread::spawn(move || drop(sections));
1021 prune_filtered_indexes(&path);
1022 open_written_query_index(&path, &identity)
1023}
1024
1025const KEPT_FILTERED_INDEXES: usize = 8;
1028
1029fn touch(path: &Path) {
1031 if let Ok(file) = fs::File::options().append(true).open(path) {
1032 let _ = file.set_modified(std::time::SystemTime::now());
1033 }
1034}
1035
1036fn prune_filtered_indexes(kept: &Path) {
1038 let Some(directory) = kept.parent() else {
1039 return;
1040 };
1041 let Ok(entries) = fs::read_dir(directory) else {
1042 return;
1043 };
1044 let mut filtered = entries
1045 .filter_map(Result::ok)
1046 .filter(|entry| {
1047 let name = entry.file_name();
1048 let name = name.to_string_lossy();
1049 name.starts_with("filtered-") && name.ends_with(".index")
1050 })
1051 .filter_map(|entry| Some((entry.metadata().ok()?.modified().ok()?, entry.path())))
1052 .collect::<Vec<_>>();
1053 filtered.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
1054 for (_, path) in filtered.into_iter().skip(KEPT_FILTERED_INDEXES) {
1055 if path != kept {
1056 let _ = fs::remove_file(path);
1057 }
1058 }
1059}
1060
1061pub fn statement_spans(report: &CoverageReport) -> Vec<serde_json::Value> {
1070 report
1071 .view
1072 .points
1073 .iter()
1074 .filter(|p| p.meta.kind == crate::coverage_analysis::PointKind::Statement)
1075 .filter_map(|p| {
1076 let extra = p.meta.source.matches('\n').count();
1077 (extra > 0).then(|| {
1078 serde_json::json!({
1079 "file": p.meta.file, "line": p.meta.line,
1080 "end": p.meta.line + extra, "covered": p.covered,
1081 })
1082 })
1083 })
1084 .collect()
1085}
1086
1087pub fn read_statement_spans(run: &StoredRun) -> Option<Vec<serde_json::Value>> {
1089 let bytes = fs::read(run.directory.join(STATEMENT_SPANS_FILE)).ok()?;
1090 serde_json::from_slice(&bytes).ok()
1091}
1092
1093pub(crate) fn write_statement_spans(run: &StoredRun, report: &CoverageReport) -> io::Result<()> {
1094 let bytes = serde_json::to_vec(&statement_spans(report)).map_err(io::Error::other)?;
1095 fs::write(run.directory.join(STATEMENT_SPANS_FILE), bytes)
1096}
1097
1098pub fn read_assessment_input(
1100 run: &StoredRun,
1101) -> Option<crate::assertion_coverage::AssessmentInput> {
1102 let file = fs::File::open(run.directory.join(ASSESSMENT_INPUT_FILE)).ok()?;
1103 serde_json::from_reader(flate2::read::GzDecoder::new(io::BufReader::new(file))).ok()
1104}
1105
1106pub(crate) fn write_assessment_input(run: &StoredRun, report: &CoverageReport) -> io::Result<()> {
1107 use std::io::Write;
1108 let bytes = serde_json::to_vec(&crate::assertion_coverage::assessment_input(report))
1109 .map_err(io::Error::other)?;
1110 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1111 encoder.write_all(&bytes)?;
1112 fs::write(run.directory.join(ASSESSMENT_INPUT_FILE), encoder.finish()?)
1113}
1114
1115pub(crate) fn write_query_index_from(
1116 run: &StoredRun,
1117 report: &CoverageReport,
1118) -> Result<(), RunIndexError> {
1119 let identity = query_index_identity(run)?;
1120 let sections = coverage_index_sections(report)?;
1121 write_query_index(§ions, &identity, &run.query_index_path)?;
1122 std::thread::spawn(move || drop(sections));
1123 Ok(())
1124}
1125
1126pub fn open_or_rebuild_query_index(run: &StoredRun) -> Result<QueryIndex, RunIndexError> {
1133 let identity = query_index_identity(run)?;
1134 if let Ok(index) = open_validated_query_index(&run.query_index_path, &identity) {
1135 return Ok(index);
1136 }
1137
1138 let sections = index_sections(run, None)?;
1139 if query_index_identity(run)? != identity {
1140 return Err(RunIndexError::EvidenceChanged);
1141 }
1142 write_query_index(§ions, &identity, &run.query_index_path)?;
1143 std::thread::spawn(move || drop(sections));
1144 let index = open_written_query_index(&run.query_index_path, &identity)?;
1145 if query_index_identity(run)? != identity {
1146 return Err(RunIndexError::EvidenceChanged);
1147 }
1148 Ok(index)
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153 use std::{
1154 fs,
1155 time::{SystemTime, UNIX_EPOCH},
1156 };
1157
1158 use crate::evidence_archive::{EvidenceArchiveEntry, read_archive, write_archive};
1159
1160 use super::*;
1161
1162 fn temporary_directory(label: &str) -> PathBuf {
1163 let nonce = SystemTime::now()
1164 .duration_since(UNIX_EPOCH)
1165 .unwrap()
1166 .as_nanos();
1167 let path = std::env::temp_dir().join(format!(
1168 "supercov-run-store-{label}-{}-{nonce}",
1169 std::process::id()
1170 ));
1171 fs::create_dir_all(&path).unwrap();
1172 path
1173 }
1174
1175 fn digest(character: char) -> String {
1176 std::iter::repeat_n(character, 64).collect()
1177 }
1178
1179 fn integrity() -> RunIntegrity {
1180 RunIntegrity {
1181 schema_version: 2,
1182 instrumenter_version: "2.0.0".into(),
1183 git: Some(GitIntegrity {
1184 revision: Some(std::iter::repeat_n('a', 40).collect()),
1185 branch: None,
1186 dirty: false,
1187 }),
1188 fingerprint: RunFingerprint {
1189 algorithm: "sha256".into(),
1190 source: digest('a'),
1191 tests: digest('b'),
1192 dependencies: digest('c'),
1193 configuration: digest('d'),
1194 instrumenter: digest('e'),
1195 execution: digest('f'),
1196 combined: digest('0'),
1197 source_files: 1,
1198 test_files: 1,
1199 },
1200 stale: None,
1201 stale_reasons: None,
1202 }
1203 }
1204
1205 fn create_run(root: &Path, id: &str) -> PathBuf {
1206 let directory = root.join(".supercov/runs").join(id);
1207 fs::create_dir_all(&directory).unwrap();
1208 let archive = write_archive(
1209 vec![
1210 EvidenceArchiveEntry {
1211 path: "coverage-model.json".into(),
1212 contents: br#"{"schemaVersion":1,"language":"fixture","variant":"fixture-v1","name":"Fixture model","completenessMeaning":"Fixture archive identity only.","measured":["fixture"],"notMeasured":[]}"#.to_vec(),
1213 },
1214 EvidenceArchiveEntry {
1215 path: "frontend.json".into(),
1216 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(),
1217 },
1218 EvidenceArchiveEntry {
1219 path: "manifest.json".into(),
1220 contents: b"{}".to_vec(),
1221 },
1222 ],
1223 &directory.join("evidence.raw.gz"),
1224 )
1225 .unwrap();
1226 let metadata = RunMetadata {
1227 id: id.into(),
1228 started_at: id.into(),
1229 duration_ms: 1.0,
1230 command: vec!["npm".into(), "test".into()],
1231 test_exit_code: Some(0),
1232 integrity: integrity(),
1233 raw_evidence: RawEvidenceMetadata {
1234 schema_version: archive.schema_version,
1235 format: archive.format.into(),
1236 file: archive.file.into(),
1237 files: archive.files,
1238 uncompressed_bytes: archive.uncompressed_bytes,
1239 compressed_bytes: archive.compressed_bytes,
1240 },
1241 isolated_build: Some(true),
1242 instrumented_build_cache: None,
1243 timings: None,
1244 merged: None,
1245 parents: None,
1246 source_roots: None,
1247 copy: None,
1248 merged_across: None,
1249 recorded_by: None,
1250 companions: None,
1251 };
1252 fs::write(
1253 directory.join("run.json"),
1254 serde_json::to_vec_pretty(&metadata).unwrap(),
1255 )
1256 .unwrap();
1257 directory
1258 }
1259
1260 fn create_indexable_run(root: &Path) -> StoredRun {
1261 create_analyzable_test_run(root, "test-run");
1262 discover_runs(root).unwrap().runs.remove(0)
1263 }
1264
1265 fn create_indexable_python_run(root: &Path) -> StoredRun {
1266 let directory = create_analyzable_test_run(root, "python-run");
1267 let evidence_path = directory.join("evidence.raw.gz");
1268 let mut entries = read_archive(&evidence_path).unwrap();
1269 for entry in &mut entries {
1270 if entry.path.ends_with("/mcdc.json") {
1271 let mut result: serde_json::Value =
1272 serde_json::from_slice(&entry.contents).unwrap();
1273 result["provenance"]["runner"] = "pytest".into();
1274 result["provenance"]["kind"] = "unit".into();
1275 result["testFile"] = "tests/test_app.py".into();
1276 entry.contents = serde_json::to_vec(&result).unwrap();
1277 }
1278 }
1279 entries
1280 .iter_mut()
1281 .find(|entry| entry.path == "frontend.json")
1282 .unwrap()
1283 .contents = serde_json::to_vec(&serde_json::json!({
1284 "protocolVersion": 2,
1285 "frontendId": "python",
1286 "frontendVersion": "python-owned-v1",
1287 "language": "python",
1288 "structuralSource": "owned-probes",
1289 "runners": [{
1290 "runner": "pytest",
1291 "executionModel": "serial-in-process",
1292 "attribution": {
1293 "run": "exact",
1294 "worker": "unavailable",
1295 "test": "exact",
1296 "retry": "exact",
1297 "phase": "exact",
1298 "action": "exact",
1299 "assertion": "exact"
1300 },
1301 "limitations": [{
1302 "id": "test-fixture-worker-unavailable",
1303 "scopes": ["worker"],
1304 "reason": "The persisted-run fixture intentionally has no worker identity"
1305 }]
1306 }],
1307 "structuralLimitations": []
1308 }))
1309 .unwrap();
1310 entries
1311 .iter_mut()
1312 .find(|entry| entry.path == "coverage-model.json")
1313 .unwrap()
1314 .contents = serde_json::to_vec(&serde_json::json!({
1315 "schemaVersion": 1,
1316 "language": "python",
1317 "variant": "all",
1318 "name": "python-owned-control-flow",
1319 "completenessMeaning": "Every declared owned-probe obligation was observed.",
1320 "measured": ["owned statements", "owned decisions"],
1321 "notMeasured": []
1322 }))
1323 .unwrap();
1324 let archive = write_archive(entries, &evidence_path).unwrap();
1325 let metadata_path = directory.join("run.json");
1326 let mut metadata: RunMetadata =
1327 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1328 metadata.raw_evidence = RawEvidenceMetadata {
1329 schema_version: archive.schema_version,
1330 format: archive.format.into(),
1331 file: archive.file.into(),
1332 files: archive.files,
1333 uncompressed_bytes: archive.uncompressed_bytes,
1334 compressed_bytes: archive.compressed_bytes,
1335 };
1336 fs::write(metadata_path, serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
1337 discover_runs(root).unwrap().runs.remove(0)
1338 }
1339
1340 #[test]
1341 fn discovers_valid_runs_in_reverse_order_and_selects_exact_prefix_or_latest() {
1342 let root = temporary_directory("discovery");
1343 create_run(&root, "2026-08-24T00-00-00-000Z");
1344 create_run(&root, "2026-08-25T00-00-00-000Z");
1345 create_run(&root, "zz-custom-old-run");
1346 let custom_metadata = root.join(".supercov/runs/zz-custom-old-run/run.json");
1347 let mut custom: serde_json::Value =
1348 serde_json::from_slice(&fs::read(&custom_metadata).unwrap()).unwrap();
1349 custom["startedAt"] = "2026-08-23T00:00:00.000Z".into();
1350 fs::write(
1351 &custom_metadata,
1352 serde_json::to_vec_pretty(&custom).unwrap(),
1353 )
1354 .unwrap();
1355 let inventory = discover_runs(&root).unwrap();
1356 assert!(inventory.rejected.is_empty());
1357 assert_eq!(
1358 inventory
1359 .runs
1360 .iter()
1361 .map(|run| run.id.as_str())
1362 .collect::<Vec<_>>(),
1363 [
1364 "2026-08-25T00-00-00-000Z",
1365 "2026-08-24T00-00-00-000Z",
1366 "zz-custom-old-run"
1367 ]
1368 );
1369 assert_eq!(
1370 select_run(&inventory, None).unwrap().id,
1371 inventory.runs[0].id
1372 );
1373 assert_eq!(
1374 select_run(&inventory, Some("2026-08-24")).unwrap().id,
1375 "2026-08-24T00-00-00-000Z"
1376 );
1377 assert!(matches!(
1378 select_run(&inventory, Some("missing")),
1379 Err(RunStoreError::RunNotFound(_))
1380 ));
1381 fs::remove_dir_all(root).unwrap();
1382 }
1383
1384 #[test]
1385 fn reports_damaged_entries_without_hiding_valid_runs() {
1386 let root = temporary_directory("rejected");
1387 create_run(&root, "valid");
1388 let mismatched = create_run(&root, "mismatched");
1389 let mut metadata: serde_json::Value =
1390 serde_json::from_slice(&fs::read(mismatched.join("run.json")).unwrap()).unwrap();
1391 metadata["id"] = "different".into();
1392 fs::write(
1393 mismatched.join("run.json"),
1394 serde_json::to_vec(&metadata).unwrap(),
1395 )
1396 .unwrap();
1397 let corrupt = create_run(&root, "wrong-length");
1398 fs::write(corrupt.join("evidence.raw.gz"), b"truncated").unwrap();
1399 let schema_mismatch = create_run(&root, "schema-mismatch");
1400 let metadata_path = schema_mismatch.join("run.json");
1401 let mut metadata: serde_json::Value =
1402 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1403 metadata["rawEvidence"]["schemaVersion"] = 2.into();
1404 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1405
1406 let inventory = discover_runs(&root).unwrap();
1407 assert_eq!(inventory.runs.len(), 1);
1408 assert_eq!(inventory.runs[0].id, "valid");
1409 assert_eq!(inventory.rejected.len(), 3);
1410 assert!(inventory.rejected[0].reason.contains("metadata ID"));
1411 assert!(
1412 inventory
1413 .rejected
1414 .iter()
1415 .any(|run| run.reason.contains("raw evidence metadata"))
1416 );
1417 assert!(
1418 inventory
1419 .rejected
1420 .iter()
1421 .any(|run| run.reason.contains("raw evidence length"))
1422 );
1423 fs::remove_dir_all(root).unwrap();
1424 }
1425
1426 #[cfg(unix)]
1427 #[test]
1428 fn refuses_linked_run_directories_and_files() {
1429 use std::os::unix::fs::symlink;
1430
1431 let root = temporary_directory("links");
1432 let target = create_run(&root, "target");
1433 symlink(&target, root.join(".supercov/runs/linked-run")).unwrap();
1434 let linked_metadata = create_run(&root, "linked-metadata");
1435 fs::remove_file(linked_metadata.join("run.json")).unwrap();
1436 symlink(target.join("run.json"), linked_metadata.join("run.json")).unwrap();
1437 let linked_evidence = create_run(&root, "linked-evidence");
1438 fs::remove_file(linked_evidence.join("evidence.raw.gz")).unwrap();
1439 symlink(
1440 target.join("evidence.raw.gz"),
1441 linked_evidence.join("evidence.raw.gz"),
1442 )
1443 .unwrap();
1444
1445 let inventory = discover_runs(&root).unwrap();
1446 assert_eq!(inventory.runs.len(), 1);
1447 assert_eq!(inventory.runs[0].id, "target");
1448 assert_eq!(inventory.rejected.len(), 3);
1449 assert!(
1450 inventory
1451 .rejected
1452 .iter()
1453 .all(|rejected| rejected.reason.contains("unsafe run-store path"))
1454 );
1455 fs::remove_dir_all(root).unwrap();
1456 }
1457
1458 #[test]
1459 fn compares_integrity_in_stable_contract_order_and_binds_identity_to_evidence() {
1460 let root = temporary_directory("identity");
1461 create_run(&root, "run");
1462 let inventory = discover_runs(&root).unwrap();
1463 let run = &inventory.runs[0];
1464 let first = query_index_identity(run).unwrap();
1465 fs::write(&run.evidence_path, b"different bytes").unwrap();
1466 let second = query_index_identity(run).unwrap();
1467 assert_ne!(first.evidence_sha256, second.evidence_sha256);
1468 assert_ne!(first.evidence_bytes, second.evidence_bytes);
1469 assert_eq!(first.analysis_sha256, second.analysis_sha256);
1470 assert_eq!(env!("SUPERCOV_ENGINE_SOURCE_SHA256").len(), 64);
1471 assert_eq!(env!("SUPERCOV_ANALYSIS_SOURCE_SHA256").len(), 64);
1473 assert_ne!(
1474 env!("SUPERCOV_ANALYSIS_SOURCE_SHA256"),
1475 env!("SUPERCOV_ENGINE_SOURCE_SHA256")
1476 );
1477
1478 let mut current = integrity();
1479 current.schema_version += 1;
1480 current.instrumenter_version = "javascript-v2".into();
1481 current.fingerprint.source = digest('2');
1482 current.fingerprint.tests = digest('3');
1483 current.fingerprint.dependencies = digest('4');
1484 current.fingerprint.configuration = digest('5');
1485 assert_eq!(
1486 compare_run_integrity(Some(&integrity()), ¤t).reasons,
1487 [
1488 "coverage schema changed",
1489 "instrumenter contract changed",
1490 "instrumented source changed",
1491 "test files changed",
1492 "dependencies or lockfile changed",
1493 "test/build configuration changed",
1494 ]
1495 );
1496
1497 let mut rebuilt = integrity();
1502 rebuilt.fingerprint.instrumenter = digest('1');
1503 assert!(!compare_run_integrity(Some(&integrity()), &rebuilt).stale);
1504 fs::remove_dir_all(root).unwrap();
1505 }
1506
1507 #[test]
1508 fn lazily_builds_reuses_and_repairs_a_fully_authenticated_typed_index() {
1509 let root = temporary_directory("lazy-index");
1510 let run = create_indexable_run(&root);
1511 assert!(!run.query_index_path.exists());
1512
1513 {
1514 let index = open_or_rebuild_query_index(&run).unwrap();
1515 index.verify_all().unwrap();
1516 CoverageIndex::new(&index).unwrap();
1517 }
1518 let canonical = fs::read(&run.query_index_path).unwrap();
1519 assert!(canonical.len() > crate::query_index::QUERY_INDEX_HEADER_SIZE);
1520 {
1521 let index = open_or_rebuild_query_index(&run).unwrap();
1522 index.verify_all().unwrap();
1523 }
1524 assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1525
1526 let mut corrupt = canonical.clone();
1527 let offset = crate::query_index::QUERY_INDEX_HEADER_SIZE + 8;
1528 corrupt[offset] ^= 0xff;
1529 fs::write(&run.query_index_path, corrupt).unwrap();
1530 {
1531 let index = open_or_rebuild_query_index(&run).unwrap();
1532 index.verify_all().unwrap();
1533 }
1534 assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1535 fs::remove_dir_all(root).unwrap();
1536 }
1537
1538 #[test]
1539 fn an_index_opened_without_the_full_check_still_refuses_a_corrupt_page() {
1540 let root = temporary_directory("written-index");
1545 let run = create_indexable_python_run(&root);
1546 let identity = query_index_identity(&run).unwrap();
1547 drop(open_or_rebuild_query_index(&run).unwrap());
1548 let lines = QueryIndex::open(&run.query_index_path, &identity)
1549 .unwrap()
1550 .descriptor(crate::coverage_index::SECTION_LINES)
1551 .unwrap();
1552 assert!(lines.length > 0, "the fixture has line records to corrupt");
1553
1554 let mut bytes = fs::read(&run.query_index_path).unwrap();
1555 bytes[lines.offset as usize] ^= 0xff;
1556 fs::write(&run.query_index_path, bytes).unwrap();
1557
1558 let index = open_written_query_index(&run.query_index_path, &identity)
1559 .expect("opening does not hash every page");
1560 assert!(
1561 matches!(
1562 index.bytes(crate::coverage_index::SECTION_LINES, 0, 1),
1563 Err(crate::query_index::QueryIndexError::CorruptPage { .. })
1564 ),
1565 "the corrupt page is refused when it is read"
1566 );
1567 assert!(
1568 index.verify_all().is_err(),
1569 "and a full check still finds it"
1570 );
1571 fs::remove_dir_all(root).unwrap();
1572 }
1573
1574 #[test]
1575 fn indexes_the_declared_language_model() {
1576 let root = temporary_directory("v3-index");
1577 let run = create_indexable_python_run(&root);
1578 assert_eq!(
1579 run.metadata.raw_evidence.schema_version,
1580 EVIDENCE_ARCHIVE_SCHEMA_VERSION
1581 );
1582 assert_eq!(
1583 query_index_identity(&run).unwrap().archive_schema_version,
1584 EVIDENCE_ARCHIVE_SCHEMA_VERSION
1585 );
1586 let index = open_or_rebuild_query_index(&run).unwrap();
1587 index.verify_all().unwrap();
1588 let coverage_index = CoverageIndex::new(&index).unwrap();
1589 assert_eq!(
1590 coverage_index.model().unwrap().name,
1591 "python-owned-control-flow"
1592 );
1593 let summary = coverage_index
1594 .summary(crate::coverage_index::CoverageViewId::All)
1595 .unwrap();
1596 assert_eq!(summary.lines.percentage, 100.0);
1597 fs::remove_dir_all(root).unwrap();
1598 }
1599
1600 #[cfg(unix)]
1601 #[test]
1602 fn atomically_replaces_a_linked_disposable_index_without_touching_its_target() {
1603 use std::os::unix::fs::symlink;
1604
1605 let root = temporary_directory("linked-index");
1606 let run = create_indexable_run(&root);
1607 let outside = root.join("outside");
1608 fs::write(&outside, b"user data").unwrap();
1609 symlink(&outside, &run.query_index_path).unwrap();
1610
1611 let index = open_or_rebuild_query_index(&run).unwrap();
1612 index.verify_all().unwrap();
1613 assert_eq!(fs::read(outside).unwrap(), b"user data");
1614 assert!(
1615 fs::symlink_metadata(&run.query_index_path)
1616 .unwrap()
1617 .file_type()
1618 .is_file()
1619 );
1620 fs::remove_dir_all(root).unwrap();
1621 }
1622}