Skip to main content

supercov_engine/
run_store.rs

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