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