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