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