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