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