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