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.fingerprint.instrumenter != current.fingerprint.instrumenter {
602 reasons.push("instrumenter changed".into());
603 }
604 if stored.fingerprint.source != current.fingerprint.source {
605 reasons.push("instrumented source changed".into());
606 }
607 if stored.fingerprint.tests != current.fingerprint.tests {
608 reasons.push("test files changed".into());
609 }
610 if stored.fingerprint.dependencies != current.fingerprint.dependencies {
611 reasons.push("dependencies or lockfile changed".into());
612 }
613 if stored.fingerprint.configuration != current.fingerprint.configuration {
614 reasons.push("test/build configuration changed".into());
615 }
616 if reasons.is_empty() && stored.fingerprint.execution != current.fingerprint.execution {
617 reasons.push("execution environment changed".into());
618 }
619 IntegrityComparison {
620 stale: !reasons.is_empty(),
621 reasons,
622 }
623}
624
625fn file_sha256(path: &Path) -> Result<([u8; 32], u64), RunStoreError> {
626 let metadata = regular_file(path)?;
627 let mut file = File::open(path)?;
628 let mut hash = Sha256::new();
629 let mut buffer = [0_u8; 128 * 1024];
630 loop {
631 let read = file.read(&mut buffer)?;
632 if read == 0 {
633 break;
634 }
635 hash.update(&buffer[..read]);
636 }
637 Ok((hash.finalize().into(), metadata.len()))
638}
639
640fn domain_hash(domain: &str, version: u32) -> [u8; 32] {
641 let mut hash = Sha256::new();
642 hash.update(domain.as_bytes());
643 hash.update([0]);
644 hash.update(version.to_le_bytes());
645 hash.update([0]);
646 hash.update(env!("SUPERCOV_ENGINE_SOURCE_SHA256").as_bytes());
647 hash.finalize().into()
648}
649
650pub fn query_index_identity(run: &StoredRun) -> Result<QueryIndexIdentity, RunStoreError> {
651 let (evidence_sha256, evidence_bytes) = file_sha256(&run.evidence_path)?;
652 Ok(QueryIndexIdentity {
653 evidence_sha256,
654 evidence_bytes,
655 analysis_sha256: domain_hash("supercov-analysis", RUST_ANALYSIS_ABI_VERSION),
656 producer_sha256: domain_hash(
657 "supercov-query-producer",
658 RUST_QUERY_PRODUCER_ABI_VERSION ^ QUERY_INDEX_SCHEMA_VERSION,
659 ),
660 archive_schema_version: run.metadata.raw_evidence.schema_version,
661 })
662}
663
664#[derive(Debug)]
665pub enum RunIndexError {
666 RunStore(RunStoreError),
667 QueryIndex(QueryIndexError),
668 CoverageIndex(CoverageIndexError),
669 Report(ReportError),
670 Metadata(serde_json::Error),
671 EvidenceChanged,
672}
673
674impl std::fmt::Display for RunIndexError {
675 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676 match self {
677 Self::RunStore(error) => write!(formatter, "{error}"),
678 Self::QueryIndex(error) => write!(formatter, "{error}"),
679 Self::CoverageIndex(error) => write!(formatter, "{error}"),
680 Self::Report(ReportError::NoEvidence(_)) => {
681 write!(formatter, "no coverage evidence was published")
682 }
683 Self::Report(error) => write!(formatter, "coverage analysis failed: {error:?}"),
684 Self::Metadata(error) => write!(formatter, "run integrity is invalid: {error}"),
685 Self::EvidenceChanged => write!(formatter, "evidence changed while indexing"),
686 }
687 }
688}
689
690impl std::error::Error for RunIndexError {}
691
692impl From<RunStoreError> for RunIndexError {
693 fn from(value: RunStoreError) -> Self {
694 Self::RunStore(value)
695 }
696}
697
698impl From<QueryIndexError> for RunIndexError {
699 fn from(value: QueryIndexError) -> Self {
700 Self::QueryIndex(value)
701 }
702}
703
704impl From<CoverageIndexError> for RunIndexError {
705 fn from(value: CoverageIndexError) -> Self {
706 Self::CoverageIndex(value)
707 }
708}
709
710impl From<ReportError> for RunIndexError {
711 fn from(value: ReportError) -> Self {
712 Self::Report(value)
713 }
714}
715
716impl From<serde_json::Error> for RunIndexError {
717 fn from(value: serde_json::Error) -> Self {
718 Self::Metadata(value)
719 }
720}
721
722fn open_validated_query_index(
723 path: &Path,
724 identity: &QueryIndexIdentity,
725) -> Result<QueryIndex, RunIndexError> {
726 let index = QueryIndex::open(path, identity)?;
727 index.verify_all()?;
728 CoverageIndex::new(&index)?;
729 Ok(index)
730}
731
732pub fn open_existing_query_index(run: &StoredRun) -> Result<Option<QueryIndex>, RunIndexError> {
734 match fs::symlink_metadata(&run.query_index_path) {
735 Ok(_) => {
736 open_validated_query_index(&run.query_index_path, &query_index_identity(run)?).map(Some)
737 }
738 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
739 Err(error) => Err(RunStoreError::Io(error).into()),
740 }
741}
742
743pub fn open_or_rebuild_query_index(run: &StoredRun) -> Result<QueryIndex, RunIndexError> {
750 let identity = query_index_identity(run)?;
751 if let Ok(index) = open_validated_query_index(&run.query_index_path, &identity) {
752 return Ok(index);
753 }
754
755 let report = analyze_coverage_archive(&ArchiveReportRequest {
756 archive_path: run.evidence_path.clone(),
757 run_id: run.id.clone(),
758 generated_at: run.metadata.started_at.clone(),
759 integrity: Some(serde_json::to_value(&run.metadata.integrity)?),
760 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
761 })?;
762 let sections = coverage_index_sections(&report)?;
763 if query_index_identity(run)? != identity {
764 return Err(RunIndexError::EvidenceChanged);
765 }
766 write_query_index(§ions, &identity, &run.query_index_path)?;
767 let index = open_validated_query_index(&run.query_index_path, &identity)?;
768 if query_index_identity(run)? != identity {
769 return Err(RunIndexError::EvidenceChanged);
770 }
771 Ok(index)
772}
773
774#[cfg(test)]
775mod tests {
776 use std::{
777 fs,
778 time::{SystemTime, UNIX_EPOCH},
779 };
780
781 use crate::evidence_archive::{EvidenceArchiveEntry, read_archive, write_archive};
782
783 use super::*;
784
785 fn temporary_directory(label: &str) -> PathBuf {
786 let nonce = SystemTime::now()
787 .duration_since(UNIX_EPOCH)
788 .unwrap()
789 .as_nanos();
790 let path = std::env::temp_dir().join(format!(
791 "supercov-run-store-{label}-{}-{nonce}",
792 std::process::id()
793 ));
794 fs::create_dir_all(&path).unwrap();
795 path
796 }
797
798 fn digest(character: char) -> String {
799 std::iter::repeat_n(character, 64).collect()
800 }
801
802 fn integrity() -> RunIntegrity {
803 RunIntegrity {
804 schema_version: 2,
805 instrumenter_version: "2.0.0".into(),
806 git: Some(GitIntegrity {
807 revision: Some(std::iter::repeat_n('a', 40).collect()),
808 dirty: false,
809 }),
810 fingerprint: RunFingerprint {
811 algorithm: "sha256".into(),
812 source: digest('a'),
813 tests: digest('b'),
814 dependencies: digest('c'),
815 configuration: digest('d'),
816 instrumenter: digest('e'),
817 execution: digest('f'),
818 combined: digest('0'),
819 source_files: 1,
820 test_files: 1,
821 },
822 stale: None,
823 stale_reasons: None,
824 }
825 }
826
827 fn create_run(root: &Path, id: &str) -> PathBuf {
828 let directory = root.join(".supercov/runs").join(id);
829 fs::create_dir_all(&directory).unwrap();
830 let archive = write_archive(
831 vec![
832 EvidenceArchiveEntry {
833 path: "coverage-model.json".into(),
834 contents: br#"{"schemaVersion":1,"language":"fixture","variant":"fixture-v1","name":"Fixture model","completenessMeaning":"Fixture archive identity only.","measured":["fixture"],"notMeasured":[]}"#.to_vec(),
835 },
836 EvidenceArchiveEntry {
837 path: "frontend.json".into(),
838 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(),
839 },
840 EvidenceArchiveEntry {
841 path: "manifest.json".into(),
842 contents: b"{}".to_vec(),
843 },
844 ],
845 &directory.join("evidence.raw.gz"),
846 )
847 .unwrap();
848 let metadata = RunMetadata {
849 id: id.into(),
850 started_at: id.into(),
851 duration_ms: 1.0,
852 command: vec!["npm".into(), "test".into()],
853 test_exit_code: Some(0),
854 integrity: integrity(),
855 raw_evidence: RawEvidenceMetadata {
856 schema_version: archive.schema_version,
857 format: archive.format.into(),
858 file: archive.file.into(),
859 files: archive.files,
860 uncompressed_bytes: archive.uncompressed_bytes,
861 compressed_bytes: archive.compressed_bytes,
862 },
863 isolated_build: Some(true),
864 instrumented_build_cache: None,
865 timings: None,
866 merged: None,
867 parents: None,
868 };
869 fs::write(
870 directory.join("run.json"),
871 serde_json::to_vec_pretty(&metadata).unwrap(),
872 )
873 .unwrap();
874 directory
875 }
876
877 fn create_indexable_run(root: &Path) -> StoredRun {
878 create_analyzable_test_run(root, "test-run");
879 discover_runs(root).unwrap().runs.remove(0)
880 }
881
882 fn create_indexable_python_run(root: &Path) -> StoredRun {
883 let directory = create_analyzable_test_run(root, "python-run");
884 let evidence_path = directory.join("evidence.raw.gz");
885 let mut entries = read_archive(&evidence_path).unwrap();
886 for entry in &mut entries {
887 if entry.path.ends_with("/mcdc.json") {
888 let mut result: serde_json::Value =
889 serde_json::from_slice(&entry.contents).unwrap();
890 result["provenance"]["runner"] = "pytest".into();
891 result["provenance"]["kind"] = "unit".into();
892 result["testFile"] = "tests/test_app.py".into();
893 entry.contents = serde_json::to_vec(&result).unwrap();
894 }
895 }
896 entries
897 .iter_mut()
898 .find(|entry| entry.path == "frontend.json")
899 .unwrap()
900 .contents = serde_json::to_vec(&serde_json::json!({
901 "protocolVersion": 2,
902 "frontendId": "python",
903 "frontendVersion": "python-owned-v1",
904 "language": "python",
905 "structuralSource": "owned-probes",
906 "runners": [{
907 "runner": "pytest",
908 "executionModel": "serial-in-process",
909 "attribution": {
910 "run": "exact",
911 "worker": "unavailable",
912 "test": "exact",
913 "retry": "exact",
914 "phase": "exact",
915 "action": "exact",
916 "assertion": "exact"
917 },
918 "limitations": [{
919 "id": "test-fixture-worker-unavailable",
920 "scopes": ["worker"],
921 "reason": "The persisted-run fixture intentionally has no worker identity"
922 }]
923 }],
924 "structuralLimitations": []
925 }))
926 .unwrap();
927 entries
928 .iter_mut()
929 .find(|entry| entry.path == "coverage-model.json")
930 .unwrap()
931 .contents = serde_json::to_vec(&serde_json::json!({
932 "schemaVersion": 1,
933 "language": "python",
934 "variant": "all",
935 "name": "python-owned-control-flow",
936 "completenessMeaning": "Every declared owned-probe obligation was observed.",
937 "measured": ["owned statements", "owned decisions"],
938 "notMeasured": []
939 }))
940 .unwrap();
941 let archive = write_archive(entries, &evidence_path).unwrap();
942 let metadata_path = directory.join("run.json");
943 let mut metadata: RunMetadata =
944 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
945 metadata.raw_evidence = RawEvidenceMetadata {
946 schema_version: archive.schema_version,
947 format: archive.format.into(),
948 file: archive.file.into(),
949 files: archive.files,
950 uncompressed_bytes: archive.uncompressed_bytes,
951 compressed_bytes: archive.compressed_bytes,
952 };
953 fs::write(metadata_path, serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
954 discover_runs(root).unwrap().runs.remove(0)
955 }
956
957 #[test]
958 fn discovers_valid_runs_in_reverse_order_and_selects_exact_prefix_or_latest() {
959 let root = temporary_directory("discovery");
960 create_run(&root, "2026-08-24T00-00-00-000Z");
961 create_run(&root, "2026-08-25T00-00-00-000Z");
962 create_run(&root, "zz-custom-old-run");
963 let custom_metadata = root.join(".supercov/runs/zz-custom-old-run/run.json");
964 let mut custom: serde_json::Value =
965 serde_json::from_slice(&fs::read(&custom_metadata).unwrap()).unwrap();
966 custom["startedAt"] = "2026-08-23T00:00:00.000Z".into();
967 fs::write(
968 &custom_metadata,
969 serde_json::to_vec_pretty(&custom).unwrap(),
970 )
971 .unwrap();
972 let inventory = discover_runs(&root).unwrap();
973 assert!(inventory.rejected.is_empty());
974 assert_eq!(
975 inventory
976 .runs
977 .iter()
978 .map(|run| run.id.as_str())
979 .collect::<Vec<_>>(),
980 [
981 "2026-08-25T00-00-00-000Z",
982 "2026-08-24T00-00-00-000Z",
983 "zz-custom-old-run"
984 ]
985 );
986 assert_eq!(
987 select_run(&inventory, None).unwrap().id,
988 inventory.runs[0].id
989 );
990 assert_eq!(
991 select_run(&inventory, Some("2026-08-24")).unwrap().id,
992 "2026-08-24T00-00-00-000Z"
993 );
994 assert!(matches!(
995 select_run(&inventory, Some("missing")),
996 Err(RunStoreError::RunNotFound(_))
997 ));
998 fs::remove_dir_all(root).unwrap();
999 }
1000
1001 #[test]
1002 fn reports_damaged_entries_without_hiding_valid_runs() {
1003 let root = temporary_directory("rejected");
1004 create_run(&root, "valid");
1005 let mismatched = create_run(&root, "mismatched");
1006 let mut metadata: serde_json::Value =
1007 serde_json::from_slice(&fs::read(mismatched.join("run.json")).unwrap()).unwrap();
1008 metadata["id"] = "different".into();
1009 fs::write(
1010 mismatched.join("run.json"),
1011 serde_json::to_vec(&metadata).unwrap(),
1012 )
1013 .unwrap();
1014 let corrupt = create_run(&root, "wrong-length");
1015 fs::write(corrupt.join("evidence.raw.gz"), b"truncated").unwrap();
1016 let schema_mismatch = create_run(&root, "schema-mismatch");
1017 let metadata_path = schema_mismatch.join("run.json");
1018 let mut metadata: serde_json::Value =
1019 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1020 metadata["rawEvidence"]["schemaVersion"] = 2.into();
1021 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1022
1023 let inventory = discover_runs(&root).unwrap();
1024 assert_eq!(inventory.runs.len(), 1);
1025 assert_eq!(inventory.runs[0].id, "valid");
1026 assert_eq!(inventory.rejected.len(), 3);
1027 assert!(inventory.rejected[0].reason.contains("metadata ID"));
1028 assert!(
1029 inventory
1030 .rejected
1031 .iter()
1032 .any(|run| run.reason.contains("raw evidence metadata"))
1033 );
1034 assert!(
1035 inventory
1036 .rejected
1037 .iter()
1038 .any(|run| run.reason.contains("raw evidence length"))
1039 );
1040 fs::remove_dir_all(root).unwrap();
1041 }
1042
1043 #[cfg(unix)]
1044 #[test]
1045 fn refuses_linked_run_directories_and_files() {
1046 use std::os::unix::fs::symlink;
1047
1048 let root = temporary_directory("links");
1049 let target = create_run(&root, "target");
1050 symlink(&target, root.join(".supercov/runs/linked-run")).unwrap();
1051 let linked_metadata = create_run(&root, "linked-metadata");
1052 fs::remove_file(linked_metadata.join("run.json")).unwrap();
1053 symlink(target.join("run.json"), linked_metadata.join("run.json")).unwrap();
1054 let linked_evidence = create_run(&root, "linked-evidence");
1055 fs::remove_file(linked_evidence.join("evidence.raw.gz")).unwrap();
1056 symlink(
1057 target.join("evidence.raw.gz"),
1058 linked_evidence.join("evidence.raw.gz"),
1059 )
1060 .unwrap();
1061
1062 let inventory = discover_runs(&root).unwrap();
1063 assert_eq!(inventory.runs.len(), 1);
1064 assert_eq!(inventory.runs[0].id, "target");
1065 assert_eq!(inventory.rejected.len(), 3);
1066 assert!(
1067 inventory
1068 .rejected
1069 .iter()
1070 .all(|rejected| rejected.reason.contains("unsafe run-store path"))
1071 );
1072 fs::remove_dir_all(root).unwrap();
1073 }
1074
1075 #[test]
1076 fn compares_integrity_in_stable_contract_order_and_binds_identity_to_evidence() {
1077 let root = temporary_directory("identity");
1078 create_run(&root, "run");
1079 let inventory = discover_runs(&root).unwrap();
1080 let run = &inventory.runs[0];
1081 let first = query_index_identity(run).unwrap();
1082 fs::write(&run.evidence_path, b"different bytes").unwrap();
1083 let second = query_index_identity(run).unwrap();
1084 assert_ne!(first.evidence_sha256, second.evidence_sha256);
1085 assert_ne!(first.evidence_bytes, second.evidence_bytes);
1086 assert_eq!(first.analysis_sha256, second.analysis_sha256);
1087 assert_eq!(env!("SUPERCOV_ENGINE_SOURCE_SHA256").len(), 64);
1088
1089 let mut current = integrity();
1090 current.schema_version += 1;
1091 current.fingerprint.instrumenter = digest('1');
1092 current.fingerprint.source = digest('2');
1093 current.fingerprint.tests = digest('3');
1094 current.fingerprint.dependencies = digest('4');
1095 current.fingerprint.configuration = digest('5');
1096 assert_eq!(
1097 compare_run_integrity(Some(&integrity()), ¤t).reasons,
1098 [
1099 "coverage schema changed",
1100 "instrumenter changed",
1101 "instrumented source changed",
1102 "test files changed",
1103 "dependencies or lockfile changed",
1104 "test/build configuration changed",
1105 ]
1106 );
1107 fs::remove_dir_all(root).unwrap();
1108 }
1109
1110 #[test]
1111 fn lazily_builds_reuses_and_repairs_a_fully_authenticated_typed_index() {
1112 let root = temporary_directory("lazy-index");
1113 let run = create_indexable_run(&root);
1114 assert!(!run.query_index_path.exists());
1115
1116 {
1117 let index = open_or_rebuild_query_index(&run).unwrap();
1118 index.verify_all().unwrap();
1119 CoverageIndex::new(&index).unwrap();
1120 }
1121 let canonical = fs::read(&run.query_index_path).unwrap();
1122 assert!(canonical.len() > crate::query_index::QUERY_INDEX_HEADER_SIZE);
1123 {
1124 let index = open_or_rebuild_query_index(&run).unwrap();
1125 index.verify_all().unwrap();
1126 }
1127 assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1128
1129 let mut corrupt = canonical.clone();
1130 let offset = crate::query_index::QUERY_INDEX_HEADER_SIZE + 8;
1131 corrupt[offset] ^= 0xff;
1132 fs::write(&run.query_index_path, corrupt).unwrap();
1133 {
1134 let index = open_or_rebuild_query_index(&run).unwrap();
1135 index.verify_all().unwrap();
1136 }
1137 assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1138 fs::remove_dir_all(root).unwrap();
1139 }
1140
1141 #[test]
1142 fn indexes_the_declared_language_model() {
1143 let root = temporary_directory("v3-index");
1144 let run = create_indexable_python_run(&root);
1145 assert_eq!(
1146 run.metadata.raw_evidence.schema_version,
1147 EVIDENCE_ARCHIVE_SCHEMA_VERSION
1148 );
1149 assert_eq!(
1150 query_index_identity(&run).unwrap().archive_schema_version,
1151 EVIDENCE_ARCHIVE_SCHEMA_VERSION
1152 );
1153 let index = open_or_rebuild_query_index(&run).unwrap();
1154 index.verify_all().unwrap();
1155 let coverage_index = CoverageIndex::new(&index).unwrap();
1156 assert_eq!(
1157 coverage_index.model().unwrap().name,
1158 "python-owned-control-flow"
1159 );
1160 let summary = coverage_index
1161 .summary(crate::coverage_index::CoverageViewId::All)
1162 .unwrap();
1163 assert_eq!(summary.lines.percentage, 100.0);
1164 fs::remove_dir_all(root).unwrap();
1165 }
1166
1167 #[cfg(unix)]
1168 #[test]
1169 fn atomically_replaces_a_linked_disposable_index_without_touching_its_target() {
1170 use std::os::unix::fs::symlink;
1171
1172 let root = temporary_directory("linked-index");
1173 let run = create_indexable_run(&root);
1174 let outside = root.join("outside");
1175 fs::write(&outside, b"user data").unwrap();
1176 symlink(&outside, &run.query_index_path).unwrap();
1177
1178 let index = open_or_rebuild_query_index(&run).unwrap();
1179 index.verify_all().unwrap();
1180 assert_eq!(fs::read(outside).unwrap(), b"user data");
1181 assert!(
1182 fs::symlink_metadata(&run.query_index_path)
1183 .unwrap()
1184 .file_type()
1185 .is_file()
1186 );
1187 fs::remove_dir_all(root).unwrap();
1188 }
1189}