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