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::{
21        ArchiveReportRequest, CoverageReport, ExitCodeInput, ReportError, analyze_coverage_archive,
22    },
23    evidence_archive::read_archive_schema_version,
24    query_index::{QueryIndex, QueryIndexError, write_query_index},
25};
26
27const MAX_RUN_METADATA_BYTES: u64 = 1024 * 1024;
28pub const RUST_ANALYSIS_ABI_VERSION: u32 = 1;
29pub const RUST_QUERY_PRODUCER_ABI_VERSION: u32 = 4;
30pub const RUST_QUERY_INDEX_FILE: &str = "query-index.v1.bin";
31/// The statements that continue onto later lines, written at publication.
32pub const STATEMENT_SPANS_FILE: &str = "statement-spans.json";
33/// What `assertions assess` reads from the analysis, written at publication.
34pub const ASSESSMENT_INPUT_FILE: &str = "assessment-input.json.gz";
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38pub struct RunFingerprint {
39    pub algorithm: String,
40    pub source: String,
41    pub tests: String,
42    pub dependencies: String,
43    pub configuration: String,
44    pub instrumenter: String,
45    pub execution: String,
46    pub combined: String,
47    pub source_files: usize,
48    pub test_files: usize,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct GitIntegrity {
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub revision: Option<String>,
56    /// Branch at measurement time. Older runs and detached HEADs may omit it.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub branch: Option<String>,
59    pub dirty: bool,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase", deny_unknown_fields)]
64pub struct RunIntegrity {
65    pub schema_version: u32,
66    pub instrumenter_version: String,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub git: Option<GitIntegrity>,
69    pub fingerprint: RunFingerprint,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub stale: Option<bool>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub stale_reasons: Option<Vec<String>>,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78pub struct RunTimings {
79    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
80    pub initialization_ms: f64,
81    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
82    pub workspace_preparation_ms: f64,
83    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
84    pub adapter_setup_ms: f64,
85    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
86    pub instrumented_build_ms: f64,
87    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
88    pub test_command_ms: f64,
89    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
90    pub evidence_publication_ms: f64,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95pub struct InstrumentedBuildCache {
96    pub key: String,
97    pub reused: bool,
98}
99
100#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase", deny_unknown_fields)]
102pub struct RawEvidenceMetadata {
103    pub schema_version: u32,
104    pub format: String,
105    pub file: String,
106    pub files: usize,
107    pub uncompressed_bytes: u64,
108    pub compressed_bytes: u64,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase", deny_unknown_fields)]
113pub struct RunMetadata {
114    pub id: String,
115    pub started_at: String,
116    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
117    pub duration_ms: f64,
118    pub command: Vec<String>,
119    pub test_exit_code: Option<i32>,
120    pub integrity: RunIntegrity,
121    pub raw_evidence: RawEvidenceMetadata,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub isolated_build: Option<bool>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub instrumented_build_cache: Option<InstrumentedBuildCache>,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub timings: Option<RunTimings>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub merged: Option<bool>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub parents: Option<Vec<String>>,
132    /// The `SUPERCOV_SOURCE_ROOTS` the run was measured under. It decides what
133    /// the run measures, so it is part of what the run is, replayed like the
134    /// command when a later query asks whether the checkout has moved on. The
135    /// environment of that later query is no guide: it is usually a different
136    /// shell, and a run judged against roots it was not measured under read as
137    /// stale the moment it was recorded.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub source_roots: Option<Vec<String>>,
140    /// Which copy of the project the command ran in, where it ran in one: 1
141    /// for a run alone, 2 for one started while that copy was in use. It is
142    /// where what the command wrote is to be found.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub copy: Option<usize>,
145    /// For a merge made with `--allow-changed`: what differed between the
146    /// runs merged, files the tests load and `configuration`. The merge is
147    /// of tests that ran against those as they were then.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub merged_across: Option<Vec<String>>,
150    /// The Supercov release that recorded the run. A run of another release
151    /// was instrumented differently and cannot be merged with this one's;
152    /// the version is what says so. Absent in runs recorded before 4.7.0.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub recorded_by: Option<String>,
155    /// The runs one command published beside this one: the runs of servers
156    /// a browser suite drove, or the browser run a server's run took its
157    /// tests from.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub companions: Option<Vec<CompanionRun>>,
160}
161
162/// A run published from the same command as another.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase", deny_unknown_fields)]
165pub struct CompanionRun {
166    pub id: String,
167    /// `JavaScript`, `Python`, `Ruby`.
168    pub language: String,
169    /// `server`: a server the run's tests drove. `browser`: the browser run
170    /// whose tests this server's run took.
171    pub role: String,
172}
173
174#[cfg(test)]
175pub(crate) fn create_analyzable_test_run(root: &Path, id: &str) -> PathBuf {
176    create_analyzable_run(root, id, None)
177}
178
179/// The same run as a suite that ran its tests at once: the coverage sits in
180/// the record nobody could be credited with, and the test beside it is named,
181/// run-wide, and holds nothing of its own. It is the shape Go publishes for a
182/// package whose tests all call `t.Parallel()`.
183#[cfg(test)]
184pub(crate) fn create_run_wide_test_run(root: &Path, id: &str) -> PathBuf {
185    create_analyzable_run(root, id, Some(crate::coverage_report::ATTRIBUTION_RUN_WIDE))
186}
187
188/// The same run as a Ruby suite: the test keeps the coverage it was credited
189/// with, and that coverage is a lower bound rather than the whole of what it
190/// ran. What its own record proves, it still proves.
191#[cfg(test)]
192pub(crate) fn create_partial_test_run(root: &Path, id: &str) -> PathBuf {
193    create_analyzable_run(root, id, Some(crate::coverage_report::ATTRIBUTION_PARTIAL))
194}
195
196#[cfg(test)]
197fn create_analyzable_run(root: &Path, id: &str, attribution: Option<&str>) -> PathBuf {
198    use crate::{
199        coverage_analysis::{McdcVector, PointKind},
200        coverage_report::{
201            BranchAlternativeMeta, BranchMeta, CoverageManifest, DecisionMeta, DecisionSnapshot,
202            PointMeta, RawTestResult, RuntimeSnapshot, TestProvenance,
203        },
204        evidence_archive::{EvidenceArchiveEntry, write_archive},
205    };
206
207    let directory = root.join(".supercov/runs").join(id);
208    fs::create_dir_all(&directory).unwrap();
209    let decision = DecisionMeta {
210        id: "decision".into(),
211        file: "src/app.js".into(),
212        line: 1,
213        column: 0,
214        source: "left && right".into(),
215        conditions: vec!["left".into(), "right".into()],
216        kind: "if".into(),
217    };
218    let manifest = CoverageManifest {
219        unmeasured: Vec::new(),
220        decisions: vec![decision.clone()],
221        points: vec![PointMeta {
222            id: "statement".into(),
223            kind: PointKind::Statement,
224            file: "src/app.js".into(),
225            line: 1,
226            column: 0,
227            source: "work();".into(),
228            label: None,
229        }],
230        branches: vec![BranchMeta {
231            id: "branch".into(),
232            kind: "if".into(),
233            file: "src/app.js".into(),
234            line: 1,
235            column: 0,
236            source: "if (left && right)".into(),
237            alternatives: vec![
238                BranchAlternativeMeta {
239                    id: "branch:true".into(),
240                    label: "true".into(),
241                },
242                BranchAlternativeMeta {
243                    id: "branch:false".into(),
244                    label: "false".into(),
245                },
246            ],
247        }],
248        limitations: vec![],
249        scope: None,
250        assertion_sites: Vec::new(),
251    };
252    let result = RawTestResult {
253        test_id: Some("test".into()),
254        scope: None,
255        test: "test".into(),
256        test_file: Some("tests/app.test.js".into()),
257        title: None,
258        retry: Some(0),
259        status: Some("passed".into()),
260        expected_status: None,
261        flaky: false,
262        provenance: TestProvenance {
263            runner: "node:test".into(),
264            kind: "unit".into(),
265            project: None,
266            source: "test-fixture".into(),
267            tags: Vec::new(),
268        },
269        role: "test".into(),
270        attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
271        phases: vec![],
272        runtime: vec![RuntimeSnapshot {
273            decisions: vec![DecisionSnapshot {
274                meta: decision,
275                vectors: vec![
276                    McdcVector {
277                        values: vec![Some(false), Some(false)],
278                        outcome: false,
279                    },
280                    McdcVector {
281                        values: vec![Some(false), Some(true)],
282                        outcome: false,
283                    },
284                    McdcVector {
285                        values: vec![Some(true), Some(false)],
286                        outcome: false,
287                    },
288                    McdcVector {
289                        values: vec![Some(true), Some(true)],
290                        outcome: true,
291                    },
292                ],
293            }],
294            hits: vec![
295                "statement".into(),
296                "branch:true".into(),
297                "branch:false".into(),
298            ],
299            events: vec![],
300            logicals: vec![],
301            phase_id: None,
302        }],
303        browser: vec![],
304        server: vec![],
305    };
306    let results = if attribution == Some(crate::coverage_report::ATTRIBUTION_PARTIAL) {
307        // It keeps its hits: a lower bound is still a claim.
308        let mut partial = result.clone();
309        partial.attribution = crate::coverage_report::ATTRIBUTION_PARTIAL.into();
310        vec![partial]
311    } else if attribution == Some(crate::coverage_report::ATTRIBUTION_RUN_WIDE) {
312        let mut unclaimed = result.clone();
313        unclaimed.test = "run".into();
314        unclaimed.test_id = Some("run".into());
315        unclaimed.test_file = None;
316        unclaimed.role = "background".into();
317        let mut parallel = result.clone();
318        parallel.attribution = crate::coverage_report::ATTRIBUTION_RUN_WIDE.into();
319        // Named, with its real outcome, and credited with nothing.
320        parallel.runtime = vec![RuntimeSnapshot {
321            decisions: vec![],
322            hits: vec![],
323            events: vec![],
324            logicals: vec![],
325            phase_id: None,
326        }];
327        vec![unclaimed, parallel]
328    } else {
329        vec![result]
330    };
331    let mut entries = vec![
332        EvidenceArchiveEntry {
333            path: "coverage-model.json".into(),
334            contents: serde_json::to_vec(&serde_json::json!({
335                "schemaVersion": 1,
336                "language": "javascript",
337                "variant": "fixture-v1",
338                "name": "Fixture model",
339                "completenessMeaning": "Every fixture obligation was observed.",
340                "measured": ["fixture obligations"],
341                "notMeasured": []
342            }))
343            .unwrap(),
344        },
345        EvidenceArchiveEntry {
346            path: "frontend.json".into(),
347            contents: serde_json::to_vec(&serde_json::json!({
348                "protocolVersion": 2,
349                "frontendId": "javascript",
350                "frontendVersion": "fixture-v1",
351                "language": "javascript",
352                "structuralSource": "owned-probes",
353                "runners": [{
354                    "runner": "node:test",
355                    "executionModel": "serial-in-process",
356                    "attribution": {
357                        "run": "exact",
358                        "worker": "unavailable",
359                        "test": "exact",
360                        "retry": "exact",
361                        "phase": "exact",
362                        "action": "exact",
363                        "assertion": "exact"
364                    },
365                    "limitations": [{
366                        "id": "fixture-worker-unavailable",
367                        "scopes": ["worker"],
368                        "reason": "The fixture intentionally has no worker identity"
369                    }]
370                }],
371                "structuralLimitations": []
372            }))
373            .unwrap(),
374        },
375        EvidenceArchiveEntry {
376            path: "manifest.json".into(),
377            contents: serde_json::to_vec(&manifest).unwrap(),
378        },
379    ];
380    for (at, result) in results.iter().enumerate() {
381        entries.push(EvidenceArchiveEntry {
382            // One record per worker directory: the reader matches
383            // `<anything>/mcdc.json`, so a second record needs its own
384            // directory rather than a decorated file name.
385            path: format!(
386                "worker{}/mcdc.json",
387                if at == 0 {
388                    String::new()
389                } else {
390                    format!("-{at}")
391                }
392            ),
393            contents: serde_json::to_vec(result).unwrap(),
394        });
395    }
396    let archive = write_archive(entries, &directory.join("evidence.raw.gz")).unwrap();
397    let digest = |character: char| std::iter::repeat_n(character, 64).collect::<String>();
398    let metadata = RunMetadata {
399        id: id.into(),
400        started_at: id.into(),
401        duration_ms: 1.0,
402        command: vec!["node".into(), "--test".into()],
403        test_exit_code: Some(0),
404        integrity: RunIntegrity {
405            schema_version: 2,
406            instrumenter_version: "test".into(),
407            git: None,
408            fingerprint: RunFingerprint {
409                algorithm: "sha256".into(),
410                source: digest('a'),
411                tests: digest('b'),
412                dependencies: digest('c'),
413                configuration: digest('d'),
414                instrumenter: digest('e'),
415                execution: digest('f'),
416                combined: digest('0'),
417                source_files: 1,
418                test_files: 1,
419            },
420            stale: None,
421            stale_reasons: None,
422        },
423        raw_evidence: RawEvidenceMetadata {
424            schema_version: archive.schema_version,
425            format: archive.format.into(),
426            file: archive.file.into(),
427            files: archive.files,
428            uncompressed_bytes: archive.uncompressed_bytes,
429            compressed_bytes: archive.compressed_bytes,
430        },
431        isolated_build: Some(true),
432        instrumented_build_cache: None,
433        timings: None,
434        merged: None,
435        parents: None,
436        source_roots: None,
437        copy: None,
438        merged_across: None,
439        recorded_by: None,
440        companions: None,
441    };
442    fs::write(
443        directory.join("run.json"),
444        serde_json::to_vec_pretty(&metadata).unwrap(),
445    )
446    .unwrap();
447    directory
448}
449
450#[derive(Debug, Clone, PartialEq)]
451pub struct StoredRun {
452    pub id: String,
453    pub directory: PathBuf,
454    pub evidence_path: PathBuf,
455    pub metadata_path: PathBuf,
456    pub query_index_path: PathBuf,
457    pub metadata: RunMetadata,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
461#[serde(rename_all = "camelCase")]
462pub struct RejectedRun {
463    pub entry: String,
464    pub reason: String,
465}
466
467#[derive(Debug, Clone, PartialEq)]
468pub struct RunInventory {
469    pub runs: Vec<StoredRun>,
470    pub rejected: Vec<RejectedRun>,
471}
472
473#[derive(Debug)]
474pub enum RunStoreError {
475    Io(io::Error),
476    UnsafeStore(PathBuf),
477    NoRuns,
478    RunNotFound(String),
479    InvalidRun(&'static str),
480}
481
482impl From<io::Error> for RunStoreError {
483    fn from(value: io::Error) -> Self {
484        Self::Io(value)
485    }
486}
487
488impl std::fmt::Display for RunStoreError {
489    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
490        match self {
491            Self::Io(error) => write!(formatter, "{error}"),
492            Self::UnsafeStore(path) => {
493                write!(formatter, "unsafe run-store path: {}", path.display())
494            }
495            Self::NoRuns => write!(formatter, "no local coverage runs"),
496            Self::RunNotFound(selector) => write!(formatter, "coverage run not found: {selector}"),
497            Self::InvalidRun(reason) => write!(formatter, "invalid coverage run: {reason}"),
498        }
499    }
500}
501
502impl std::error::Error for RunStoreError {}
503
504fn regular_file(path: &Path) -> Result<fs::Metadata, RunStoreError> {
505    let metadata = fs::symlink_metadata(path)?;
506    if !metadata.file_type().is_file() {
507        return Err(RunStoreError::UnsafeStore(path.to_owned()));
508    }
509    Ok(metadata)
510}
511
512pub(crate) fn valid_run_id(value: &str) -> bool {
513    !value.is_empty()
514        && value != "."
515        && value != ".."
516        && !value
517            .chars()
518            .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
519}
520
521fn valid_hex_digest(value: &str, length: usize) -> bool {
522    value.len() == length
523        && value
524            .bytes()
525            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
526}
527
528fn valid_sha256(value: &str) -> bool {
529    valid_hex_digest(value, 64)
530}
531
532fn validate_integrity(integrity: &RunIntegrity) -> Result<(), RunStoreError> {
533    let fingerprint = &integrity.fingerprint;
534    if fingerprint.algorithm != "sha256"
535        || ![
536            &fingerprint.source,
537            &fingerprint.tests,
538            &fingerprint.dependencies,
539            &fingerprint.configuration,
540            &fingerprint.instrumenter,
541            &fingerprint.execution,
542            &fingerprint.combined,
543        ]
544        .into_iter()
545        .all(|digest| valid_sha256(digest))
546    {
547        return Err(RunStoreError::InvalidRun("integrity fingerprint"));
548    }
549    if let Some(git) = &integrity.git
550        && git.revision.as_ref().is_some_and(|revision| {
551            !valid_hex_digest(revision, 40) && !valid_hex_digest(revision, 64)
552        })
553    {
554        return Err(RunStoreError::InvalidRun("git revision"));
555    }
556    Ok(())
557}
558
559fn read_metadata(path: &Path) -> Result<RunMetadata, RunStoreError> {
560    let metadata = regular_file(path)?;
561    if metadata.len() > MAX_RUN_METADATA_BYTES {
562        return Err(RunStoreError::InvalidRun("run metadata is too large"));
563    }
564    let capacity =
565        usize::try_from(metadata.len()).map_err(|_| RunStoreError::InvalidRun("metadata size"))?;
566    let mut bytes = Vec::with_capacity(capacity);
567    File::open(path)?
568        .take(MAX_RUN_METADATA_BYTES + 1)
569        .read_to_end(&mut bytes)?;
570    let metadata: RunMetadata = serde_json::from_slice(&bytes)
571        .map_err(|_| RunStoreError::InvalidRun("run metadata JSON"))?;
572    validate_integrity(&metadata.integrity)?;
573    Ok(metadata)
574}
575
576fn load_run(directory: &Path, entry: &str) -> Result<StoredRun, RunStoreError> {
577    if !valid_run_id(entry) {
578        return Err(RunStoreError::InvalidRun("run directory name"));
579    }
580    let directory_metadata = fs::symlink_metadata(directory)?;
581    if !directory_metadata.file_type().is_dir() {
582        return Err(RunStoreError::UnsafeStore(directory.to_owned()));
583    }
584    let metadata_path = directory.join("run.json");
585    let metadata = read_metadata(&metadata_path)?;
586    if metadata.id != entry {
587        return Err(RunStoreError::InvalidRun(
588            "metadata ID differs from directory",
589        ));
590    }
591    if metadata.raw_evidence.schema_version != EVIDENCE_ARCHIVE_SCHEMA_VERSION
592        || metadata.raw_evidence.format != "framed+gzip"
593        || metadata.raw_evidence.file != "evidence.raw.gz"
594        || metadata.raw_evidence.files == 0
595    {
596        return Err(RunStoreError::InvalidRun("raw evidence metadata"));
597    }
598    let evidence_path = directory.join("evidence.raw.gz");
599    let evidence_metadata = regular_file(&evidence_path)?;
600    if evidence_metadata.len() != metadata.raw_evidence.compressed_bytes {
601        return Err(RunStoreError::InvalidRun("raw evidence length"));
602    }
603    if read_archive_schema_version(&evidence_path)
604        .map_err(|_| RunStoreError::InvalidRun("raw evidence archive"))?
605        != metadata.raw_evidence.schema_version
606    {
607        return Err(RunStoreError::InvalidRun("raw evidence schema mismatch"));
608    }
609    Ok(StoredRun {
610        id: entry.into(),
611        directory: directory.to_owned(),
612        evidence_path,
613        metadata_path,
614        query_index_path: directory.join(RUST_QUERY_INDEX_FILE),
615        metadata,
616    })
617}
618
619pub fn discover_runs(project_root: &Path) -> Result<RunInventory, RunStoreError> {
620    let store = project_root.join(".supercov").join("runs");
621    let store_metadata = match fs::symlink_metadata(&store) {
622        Ok(metadata) => metadata,
623        Err(error) if error.kind() == io::ErrorKind::NotFound => {
624            return Ok(RunInventory {
625                runs: Vec::new(),
626                rejected: Vec::new(),
627            });
628        }
629        Err(error) => return Err(error.into()),
630    };
631    if !store_metadata.file_type().is_dir() {
632        return Err(RunStoreError::UnsafeStore(store));
633    }
634    let mut runs = Vec::new();
635    let mut rejected = Vec::new();
636    for entry in fs::read_dir(&store)? {
637        let entry = match entry {
638            Ok(entry) => entry,
639            Err(error) => {
640                rejected.push(RejectedRun {
641                    entry: "<unreadable>".into(),
642                    reason: error.to_string(),
643                });
644                continue;
645            }
646        };
647        let name = entry.file_name().to_string_lossy().into_owned();
648        match load_run(&entry.path(), &name) {
649            Ok(run) => runs.push(run),
650            Err(error) => rejected.push(RejectedRun {
651                entry: name,
652                reason: error.to_string(),
653            }),
654        }
655    }
656    runs.sort_by(|left, right| {
657        right
658            .metadata
659            .started_at
660            .cmp(&left.metadata.started_at)
661            .then_with(|| right.id.cmp(&left.id))
662    });
663    rejected.sort_by(|left, right| left.entry.cmp(&right.entry));
664    Ok(RunInventory { runs, rejected })
665}
666
667pub fn select_run<'a>(
668    inventory: &'a RunInventory,
669    selector: Option<&str>,
670) -> Result<&'a StoredRun, RunStoreError> {
671    if inventory.runs.is_empty() {
672        return Err(RunStoreError::NoRuns);
673    }
674    if selector.is_none() || selector == Some("latest") {
675        return Ok(&inventory.runs[0]);
676    }
677    let selector = selector.expect("checked selector");
678    inventory
679        .runs
680        .iter()
681        .find(|run| run.id == selector)
682        .or_else(|| {
683            inventory
684                .runs
685                .iter()
686                .find(|run| run.id.starts_with(selector))
687        })
688        .ok_or_else(|| RunStoreError::RunNotFound(selector.into()))
689}
690
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct IntegrityComparison {
693    pub stale: bool,
694    pub reasons: Vec<String>,
695}
696
697pub fn compare_run_integrity(
698    stored: Option<&RunIntegrity>,
699    current: &RunIntegrity,
700) -> IntegrityComparison {
701    let Some(stored) = stored else {
702        return IntegrityComparison {
703            stale: true,
704            reasons: vec!["run predates integrity fingerprints".into()],
705        };
706    };
707    if std::env::var_os("SUPERCOV_DEBUG_INTEGRITY").is_some() {
708        eprintln!("[integrity] stored : {stored:?}");
709        eprintln!("[integrity] current: {current:?}");
710    }
711    let mut reasons = Vec::new();
712    if stored.schema_version != current.schema_version {
713        reasons.push("coverage schema changed".into());
714    }
715    // The declared contract, not the build that produced it. The instrumenter
716    // digest covers Supercov's own source, so comparing it here marked every
717    // stored run stale on every release -- for a checkout that had not changed
718    // and evidence that is still a true record of what ran. A change in what
719    // instrumentation *means* is a deliberate act, and it moves this version.
720    if stored.instrumenter_version != current.instrumenter_version {
721        reasons.push("instrumenter contract changed".into());
722    }
723    if stored.fingerprint.source != current.fingerprint.source {
724        reasons.push("instrumented source changed".into());
725    }
726    if stored.fingerprint.tests != current.fingerprint.tests {
727        reasons.push("test files changed".into());
728    }
729    if stored.fingerprint.dependencies != current.fingerprint.dependencies {
730        reasons.push("dependencies or lockfile changed".into());
731    }
732    if stored.fingerprint.configuration != current.fingerprint.configuration {
733        reasons.push("test/build configuration changed".into());
734    }
735    if reasons.is_empty() && stored.fingerprint.execution != current.fingerprint.execution {
736        reasons.push("execution environment changed".into());
737    }
738    IntegrityComparison {
739        stale: !reasons.is_empty(),
740        reasons,
741    }
742}
743
744fn file_sha256(path: &Path) -> Result<([u8; 32], u64), RunStoreError> {
745    let metadata = regular_file(path)?;
746    let mut file = File::open(path)?;
747    let mut hash = Sha256::new();
748    let mut buffer = [0_u8; 128 * 1024];
749    loop {
750        let read = file.read(&mut buffer)?;
751        if read == 0 {
752            break;
753        }
754        hash.update(&buffer[..read]);
755    }
756    Ok((hash.finalize().into(), metadata.len()))
757}
758
759fn domain_hash(domain: &str, version: u32) -> [u8; 32] {
760    let mut hash = Sha256::new();
761    hash.update(domain.as_bytes());
762    hash.update([0]);
763    hash.update(version.to_le_bytes());
764    hash.update([0]);
765    hash.update(env!("SUPERCOV_ENGINE_SOURCE_SHA256").as_bytes());
766    hash.finalize().into()
767}
768
769pub fn query_index_identity(run: &StoredRun) -> Result<QueryIndexIdentity, RunStoreError> {
770    let (evidence_sha256, evidence_bytes) = file_sha256(&run.evidence_path)?;
771    Ok(QueryIndexIdentity {
772        evidence_sha256,
773        evidence_bytes,
774        analysis_sha256: domain_hash("supercov-analysis", RUST_ANALYSIS_ABI_VERSION),
775        producer_sha256: domain_hash(
776            "supercov-query-producer",
777            RUST_QUERY_PRODUCER_ABI_VERSION ^ QUERY_INDEX_SCHEMA_VERSION,
778        ),
779        archive_schema_version: run.metadata.raw_evidence.schema_version,
780    })
781}
782
783#[derive(Debug)]
784pub enum RunIndexError {
785    RunStore(RunStoreError),
786    QueryIndex(QueryIndexError),
787    CoverageIndex(CoverageIndexError),
788    Report(ReportError),
789    Metadata(serde_json::Error),
790    EvidenceChanged,
791}
792
793impl std::fmt::Display for RunIndexError {
794    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
795        match self {
796            Self::RunStore(error) => write!(formatter, "{error}"),
797            Self::QueryIndex(error) => write!(formatter, "{error}"),
798            Self::CoverageIndex(error) => write!(formatter, "{error}"),
799            Self::Report(ReportError::NoEvidence(_)) => {
800                write!(formatter, "no coverage evidence was published")
801            }
802            Self::Report(ReportError::NoTestMatches(filter)) => {
803                write!(formatter, "no test of the run matches {filter}")
804            }
805            Self::Report(error) => write!(formatter, "coverage analysis failed: {error:?}"),
806            Self::Metadata(error) => write!(formatter, "run integrity is invalid: {error}"),
807            Self::EvidenceChanged => write!(formatter, "evidence changed while indexing"),
808        }
809    }
810}
811
812impl std::error::Error for RunIndexError {}
813
814impl From<RunStoreError> for RunIndexError {
815    fn from(value: RunStoreError) -> Self {
816        Self::RunStore(value)
817    }
818}
819
820impl From<QueryIndexError> for RunIndexError {
821    fn from(value: QueryIndexError) -> Self {
822        Self::QueryIndex(value)
823    }
824}
825
826impl From<CoverageIndexError> for RunIndexError {
827    fn from(value: CoverageIndexError) -> Self {
828        Self::CoverageIndex(value)
829    }
830}
831
832impl From<ReportError> for RunIndexError {
833    fn from(value: ReportError) -> Self {
834        Self::Report(value)
835    }
836}
837
838impl From<serde_json::Error> for RunIndexError {
839    fn from(value: serde_json::Error) -> Self {
840        Self::Metadata(value)
841    }
842}
843
844fn open_validated_query_index(
845    path: &Path,
846    identity: &QueryIndexIdentity,
847) -> Result<QueryIndex, RunIndexError> {
848    let index = QueryIndex::open(path, identity)?;
849    index.verify_all()?;
850    CoverageIndex::new(&index)?;
851    Ok(index)
852}
853
854/// Open the index this process has just written. It was synced and renamed
855/// into place from the very bytes its page digests were computed over, so
856/// hashing the whole file again re-derives what the write already guarantees
857/// -- and on a 300-file run that was 726 MB, 29% of the query that follows a
858/// test run. Nothing unverified is trusted: every page is still checked against
859/// its digest the first time a query reads it. An index found on disk later is
860/// verified in full on open, as before, since it may have been torn or changed
861/// since it was written.
862fn open_written_query_index(
863    path: &Path,
864    identity: &QueryIndexIdentity,
865) -> Result<QueryIndex, RunIndexError> {
866    let index = QueryIndex::open(path, identity)?;
867    CoverageIndex::new(&index)?;
868    Ok(index)
869}
870
871/// Open an existing valid index without triggering analysis or publication.
872pub fn open_existing_query_index(run: &StoredRun) -> Result<Option<QueryIndex>, RunIndexError> {
873    match fs::symlink_metadata(&run.query_index_path) {
874        Ok(_) => {
875            open_validated_query_index(&run.query_index_path, &query_index_identity(run)?).map(Some)
876        }
877        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
878        Err(error) => Err(RunStoreError::Io(error).into()),
879    }
880}
881
882/// A run's coverage, analysed from its evidence archive as its query index is.
883pub fn analyze_stored_run(run: &StoredRun) -> Result<CoverageReport, RunIndexError> {
884    analyze_stored_run_filtered(run, None)
885}
886
887/// [`analyze_stored_run`] for only the tests `filter` picks: their coverage
888/// alone, read from what the run already recorded.
889pub fn analyze_stored_run_filtered(
890    run: &StoredRun,
891    filter: Option<&crate::coverage_report::TestFilter>,
892) -> Result<CoverageReport, RunIndexError> {
893    Ok(analyze_coverage_archive(&ArchiveReportRequest {
894        archive_path: run.evidence_path.clone(),
895        run_id: run.id.clone(),
896        generated_at: run.metadata.started_at.clone(),
897        integrity: Some(serde_json::to_value(&run.metadata.integrity)?),
898        test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
899        test_filter: filter.cloned(),
900    })?)
901}
902
903/// The filter as one key: its values normalized and sorted, so the same
904/// tests asked for in another order or case find the same index.
905fn filter_digest(filter: &crate::coverage_report::TestFilter) -> [u8; 32] {
906    let mut parts = [
907        filter
908            .tags
909            .iter()
910            .map(|tag| tag.trim_start_matches('@').to_lowercase())
911            .collect::<Vec<_>>(),
912        filter
913            .titles
914            .iter()
915            .map(|title| title.to_lowercase())
916            .collect(),
917        filter
918            .files
919            .iter()
920            .map(|file| {
921                file.trim_start_matches("./")
922                    .trim_end_matches('/')
923                    .to_owned()
924            })
925            .collect(),
926    ];
927    parts[0].extend(filter.kind.iter().map(|kind| format!("kind\0{kind}")));
928    parts[0].extend(
929        filter
930            .runner
931            .iter()
932            .map(|runner| format!("runner\0{runner}")),
933    );
934    let mut hash = Sha256::new();
935    hash.update(domain_hash(
936        "supercov-test-filter",
937        QUERY_INDEX_SCHEMA_VERSION,
938    ));
939    for part in &mut parts {
940        part.sort();
941        part.dedup();
942        for value in part.iter() {
943            hash.update(value.as_bytes());
944            hash.update([0]);
945        }
946        hash.update([1]);
947    }
948    hash.finalize().into()
949}
950
951/// The query index of only the tests `filter` picks, built from the run's
952/// evidence the first time it is asked for and kept beside the run's own
953/// index. Nothing of it is worked out while the tests run or the run is
954/// stored: only a query that names a filter pays for it, once.
955pub fn open_or_build_filtered_query_index(
956    run: &StoredRun,
957    filter: &crate::coverage_report::TestFilter,
958) -> Result<QueryIndex, RunIndexError> {
959    let digest = filter_digest(filter);
960    let mut identity = query_index_identity(run)?;
961    identity.analysis_sha256 = digest;
962    let name = digest
963        .iter()
964        .take(8)
965        .map(|byte| format!("{byte:02x}"))
966        .collect::<String>();
967    let path = run
968        .query_index_path
969        .with_file_name(format!("filtered-{name}.index"));
970    if let Ok(index) = open_validated_query_index(&path, &identity) {
971        touch(&path);
972        return Ok(index);
973    }
974    let report = analyze_stored_run_filtered(run, Some(filter))?;
975    let sections = coverage_index_sections(&report)?;
976    std::thread::spawn(move || drop(report));
977    let mut current = query_index_identity(run)?;
978    current.analysis_sha256 = digest;
979    if current != identity {
980        return Err(RunIndexError::EvidenceChanged);
981    }
982    write_query_index(&sections, &identity, &path)?;
983    std::thread::spawn(move || drop(sections));
984    prune_filtered_indexes(&path);
985    open_written_query_index(&path, &identity)
986}
987
988/// How many filtered indexes a run keeps. Each is about half the run's own
989/// index, so a run asked about many filters would otherwise grow without end.
990const KEPT_FILTERED_INDEXES: usize = 8;
991
992/// Mark a filtered index as just used, so pruning keeps the ones still asked.
993fn touch(path: &Path) {
994    if let Ok(file) = fs::File::options().append(true).open(path) {
995        let _ = file.set_modified(std::time::SystemTime::now());
996    }
997}
998
999/// Remove all but the most recently used filtered indexes beside `kept`.
1000fn prune_filtered_indexes(kept: &Path) {
1001    let Some(directory) = kept.parent() else {
1002        return;
1003    };
1004    let Ok(entries) = fs::read_dir(directory) else {
1005        return;
1006    };
1007    let mut filtered = entries
1008        .filter_map(Result::ok)
1009        .filter(|entry| {
1010            let name = entry.file_name();
1011            let name = name.to_string_lossy();
1012            name.starts_with("filtered-") && name.ends_with(".index")
1013        })
1014        .filter_map(|entry| Some((entry.metadata().ok()?.modified().ok()?, entry.path())))
1015        .collect::<Vec<_>>();
1016    filtered.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
1017    for (_, path) in filtered.into_iter().skip(KEPT_FILTERED_INDEXES) {
1018        if path != kept {
1019            let _ = fs::remove_file(path);
1020        }
1021    }
1022}
1023
1024/// Write a run's query index from coverage already analysed from its evidence,
1025/// so the first query opens it instead of analysing the archive again. The
1026/// identity is the evidence's digest, not its path, so an index written beside
1027/// staged evidence stays valid once the directory is renamed into place.
1028/// The statements that run over more than one line, with whether they ran:
1029/// what the HTML report marks on a statement's continuation lines. Reading
1030/// them back from the analysis meant analysing the evidence again, about
1031/// 12 s per run on supergateway, so publication writes them beside the index.
1032pub fn statement_spans(report: &CoverageReport) -> Vec<serde_json::Value> {
1033    report
1034        .view
1035        .points
1036        .iter()
1037        .filter(|p| p.meta.kind == crate::coverage_analysis::PointKind::Statement)
1038        .filter_map(|p| {
1039            let extra = p.meta.source.matches('\n').count();
1040            (extra > 0).then(|| {
1041                serde_json::json!({
1042                    "file": p.meta.file, "line": p.meta.line,
1043                    "end": p.meta.line + extra, "covered": p.covered,
1044                })
1045            })
1046        })
1047        .collect()
1048}
1049
1050/// The spans publication wrote, when it did.
1051pub fn read_statement_spans(run: &StoredRun) -> Option<Vec<serde_json::Value>> {
1052    let bytes = fs::read(run.directory.join(STATEMENT_SPANS_FILE)).ok()?;
1053    serde_json::from_slice(&bytes).ok()
1054}
1055
1056pub(crate) fn write_statement_spans(run: &StoredRun, report: &CoverageReport) -> io::Result<()> {
1057    let bytes = serde_json::to_vec(&statement_spans(report)).map_err(io::Error::other)?;
1058    fs::write(run.directory.join(STATEMENT_SPANS_FILE), bytes)
1059}
1060
1061/// The assessment input publication wrote, when it did.
1062pub fn read_assessment_input(
1063    run: &StoredRun,
1064) -> Option<crate::assertion_coverage::AssessmentInput> {
1065    let file = fs::File::open(run.directory.join(ASSESSMENT_INPUT_FILE)).ok()?;
1066    serde_json::from_reader(flate2::read::GzDecoder::new(io::BufReader::new(file))).ok()
1067}
1068
1069pub(crate) fn write_assessment_input(run: &StoredRun, report: &CoverageReport) -> io::Result<()> {
1070    use std::io::Write;
1071    let bytes = serde_json::to_vec(&crate::assertion_coverage::assessment_input(report))
1072        .map_err(io::Error::other)?;
1073    let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1074    encoder.write_all(&bytes)?;
1075    fs::write(run.directory.join(ASSESSMENT_INPUT_FILE), encoder.finish()?)
1076}
1077
1078pub(crate) fn write_query_index_from(
1079    run: &StoredRun,
1080    report: &CoverageReport,
1081) -> Result<(), RunIndexError> {
1082    let identity = query_index_identity(run)?;
1083    let sections = coverage_index_sections(report)?;
1084    write_query_index(&sections, &identity, &run.query_index_path)?;
1085    std::thread::spawn(move || drop(sections));
1086    Ok(())
1087}
1088
1089/// Open a valid disposable index or atomically reconstruct it from evidence.
1090///
1091/// `evidence.raw.gz` remains authoritative. Any stale, truncated, linked or
1092/// otherwise invalid index is ignored and replaced by a fully authenticated
1093/// new inode. A second evidence hash prevents publishing a mixed-generation
1094/// index if the supposedly immutable archive changes during analysis.
1095pub fn open_or_rebuild_query_index(run: &StoredRun) -> Result<QueryIndex, RunIndexError> {
1096    let identity = query_index_identity(run)?;
1097    if let Ok(index) = open_validated_query_index(&run.query_index_path, &identity) {
1098        return Ok(index);
1099    }
1100
1101    let report = analyze_stored_run(run)?;
1102    let sections = coverage_index_sections(&report)?;
1103    // The analysis and the sections are written; freeing them took seconds
1104    // the query waited on.
1105    std::thread::spawn(move || drop(report));
1106    if query_index_identity(run)? != identity {
1107        return Err(RunIndexError::EvidenceChanged);
1108    }
1109    write_query_index(&sections, &identity, &run.query_index_path)?;
1110    std::thread::spawn(move || drop(sections));
1111    let index = open_written_query_index(&run.query_index_path, &identity)?;
1112    if query_index_identity(run)? != identity {
1113        return Err(RunIndexError::EvidenceChanged);
1114    }
1115    Ok(index)
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use std::{
1121        fs,
1122        time::{SystemTime, UNIX_EPOCH},
1123    };
1124
1125    use crate::evidence_archive::{EvidenceArchiveEntry, read_archive, write_archive};
1126
1127    use super::*;
1128
1129    fn temporary_directory(label: &str) -> PathBuf {
1130        let nonce = SystemTime::now()
1131            .duration_since(UNIX_EPOCH)
1132            .unwrap()
1133            .as_nanos();
1134        let path = std::env::temp_dir().join(format!(
1135            "supercov-run-store-{label}-{}-{nonce}",
1136            std::process::id()
1137        ));
1138        fs::create_dir_all(&path).unwrap();
1139        path
1140    }
1141
1142    fn digest(character: char) -> String {
1143        std::iter::repeat_n(character, 64).collect()
1144    }
1145
1146    fn integrity() -> RunIntegrity {
1147        RunIntegrity {
1148            schema_version: 2,
1149            instrumenter_version: "2.0.0".into(),
1150            git: Some(GitIntegrity {
1151                revision: Some(std::iter::repeat_n('a', 40).collect()),
1152                branch: None,
1153                dirty: false,
1154            }),
1155            fingerprint: RunFingerprint {
1156                algorithm: "sha256".into(),
1157                source: digest('a'),
1158                tests: digest('b'),
1159                dependencies: digest('c'),
1160                configuration: digest('d'),
1161                instrumenter: digest('e'),
1162                execution: digest('f'),
1163                combined: digest('0'),
1164                source_files: 1,
1165                test_files: 1,
1166            },
1167            stale: None,
1168            stale_reasons: None,
1169        }
1170    }
1171
1172    fn create_run(root: &Path, id: &str) -> PathBuf {
1173        let directory = root.join(".supercov/runs").join(id);
1174        fs::create_dir_all(&directory).unwrap();
1175        let archive = write_archive(
1176            vec![
1177                EvidenceArchiveEntry {
1178                    path: "coverage-model.json".into(),
1179                    contents: br#"{"schemaVersion":1,"language":"fixture","variant":"fixture-v1","name":"Fixture model","completenessMeaning":"Fixture archive identity only.","measured":["fixture"],"notMeasured":[]}"#.to_vec(),
1180                },
1181                EvidenceArchiveEntry {
1182                    path: "frontend.json".into(),
1183                    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(),
1184                },
1185                EvidenceArchiveEntry {
1186                    path: "manifest.json".into(),
1187                    contents: b"{}".to_vec(),
1188                },
1189            ],
1190            &directory.join("evidence.raw.gz"),
1191        )
1192        .unwrap();
1193        let metadata = RunMetadata {
1194            id: id.into(),
1195            started_at: id.into(),
1196            duration_ms: 1.0,
1197            command: vec!["npm".into(), "test".into()],
1198            test_exit_code: Some(0),
1199            integrity: integrity(),
1200            raw_evidence: RawEvidenceMetadata {
1201                schema_version: archive.schema_version,
1202                format: archive.format.into(),
1203                file: archive.file.into(),
1204                files: archive.files,
1205                uncompressed_bytes: archive.uncompressed_bytes,
1206                compressed_bytes: archive.compressed_bytes,
1207            },
1208            isolated_build: Some(true),
1209            instrumented_build_cache: None,
1210            timings: None,
1211            merged: None,
1212            parents: None,
1213            source_roots: None,
1214            copy: None,
1215            merged_across: None,
1216            recorded_by: None,
1217            companions: None,
1218        };
1219        fs::write(
1220            directory.join("run.json"),
1221            serde_json::to_vec_pretty(&metadata).unwrap(),
1222        )
1223        .unwrap();
1224        directory
1225    }
1226
1227    fn create_indexable_run(root: &Path) -> StoredRun {
1228        create_analyzable_test_run(root, "test-run");
1229        discover_runs(root).unwrap().runs.remove(0)
1230    }
1231
1232    fn create_indexable_python_run(root: &Path) -> StoredRun {
1233        let directory = create_analyzable_test_run(root, "python-run");
1234        let evidence_path = directory.join("evidence.raw.gz");
1235        let mut entries = read_archive(&evidence_path).unwrap();
1236        for entry in &mut entries {
1237            if entry.path.ends_with("/mcdc.json") {
1238                let mut result: serde_json::Value =
1239                    serde_json::from_slice(&entry.contents).unwrap();
1240                result["provenance"]["runner"] = "pytest".into();
1241                result["provenance"]["kind"] = "unit".into();
1242                result["testFile"] = "tests/test_app.py".into();
1243                entry.contents = serde_json::to_vec(&result).unwrap();
1244            }
1245        }
1246        entries
1247            .iter_mut()
1248            .find(|entry| entry.path == "frontend.json")
1249            .unwrap()
1250            .contents = serde_json::to_vec(&serde_json::json!({
1251            "protocolVersion": 2,
1252            "frontendId": "python",
1253            "frontendVersion": "python-owned-v1",
1254            "language": "python",
1255            "structuralSource": "owned-probes",
1256            "runners": [{
1257                "runner": "pytest",
1258                "executionModel": "serial-in-process",
1259                "attribution": {
1260                    "run": "exact",
1261                    "worker": "unavailable",
1262                    "test": "exact",
1263                    "retry": "exact",
1264                    "phase": "exact",
1265                    "action": "exact",
1266                    "assertion": "exact"
1267                },
1268                "limitations": [{
1269                    "id": "test-fixture-worker-unavailable",
1270                    "scopes": ["worker"],
1271                    "reason": "The persisted-run fixture intentionally has no worker identity"
1272                }]
1273            }],
1274            "structuralLimitations": []
1275        }))
1276        .unwrap();
1277        entries
1278            .iter_mut()
1279            .find(|entry| entry.path == "coverage-model.json")
1280            .unwrap()
1281            .contents = serde_json::to_vec(&serde_json::json!({
1282            "schemaVersion": 1,
1283            "language": "python",
1284            "variant": "all",
1285            "name": "python-owned-control-flow",
1286            "completenessMeaning": "Every declared owned-probe obligation was observed.",
1287            "measured": ["owned statements", "owned decisions"],
1288            "notMeasured": []
1289        }))
1290        .unwrap();
1291        let archive = write_archive(entries, &evidence_path).unwrap();
1292        let metadata_path = directory.join("run.json");
1293        let mut metadata: RunMetadata =
1294            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1295        metadata.raw_evidence = RawEvidenceMetadata {
1296            schema_version: archive.schema_version,
1297            format: archive.format.into(),
1298            file: archive.file.into(),
1299            files: archive.files,
1300            uncompressed_bytes: archive.uncompressed_bytes,
1301            compressed_bytes: archive.compressed_bytes,
1302        };
1303        fs::write(metadata_path, serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
1304        discover_runs(root).unwrap().runs.remove(0)
1305    }
1306
1307    #[test]
1308    fn discovers_valid_runs_in_reverse_order_and_selects_exact_prefix_or_latest() {
1309        let root = temporary_directory("discovery");
1310        create_run(&root, "2026-08-24T00-00-00-000Z");
1311        create_run(&root, "2026-08-25T00-00-00-000Z");
1312        create_run(&root, "zz-custom-old-run");
1313        let custom_metadata = root.join(".supercov/runs/zz-custom-old-run/run.json");
1314        let mut custom: serde_json::Value =
1315            serde_json::from_slice(&fs::read(&custom_metadata).unwrap()).unwrap();
1316        custom["startedAt"] = "2026-08-23T00:00:00.000Z".into();
1317        fs::write(
1318            &custom_metadata,
1319            serde_json::to_vec_pretty(&custom).unwrap(),
1320        )
1321        .unwrap();
1322        let inventory = discover_runs(&root).unwrap();
1323        assert!(inventory.rejected.is_empty());
1324        assert_eq!(
1325            inventory
1326                .runs
1327                .iter()
1328                .map(|run| run.id.as_str())
1329                .collect::<Vec<_>>(),
1330            [
1331                "2026-08-25T00-00-00-000Z",
1332                "2026-08-24T00-00-00-000Z",
1333                "zz-custom-old-run"
1334            ]
1335        );
1336        assert_eq!(
1337            select_run(&inventory, None).unwrap().id,
1338            inventory.runs[0].id
1339        );
1340        assert_eq!(
1341            select_run(&inventory, Some("2026-08-24")).unwrap().id,
1342            "2026-08-24T00-00-00-000Z"
1343        );
1344        assert!(matches!(
1345            select_run(&inventory, Some("missing")),
1346            Err(RunStoreError::RunNotFound(_))
1347        ));
1348        fs::remove_dir_all(root).unwrap();
1349    }
1350
1351    #[test]
1352    fn reports_damaged_entries_without_hiding_valid_runs() {
1353        let root = temporary_directory("rejected");
1354        create_run(&root, "valid");
1355        let mismatched = create_run(&root, "mismatched");
1356        let mut metadata: serde_json::Value =
1357            serde_json::from_slice(&fs::read(mismatched.join("run.json")).unwrap()).unwrap();
1358        metadata["id"] = "different".into();
1359        fs::write(
1360            mismatched.join("run.json"),
1361            serde_json::to_vec(&metadata).unwrap(),
1362        )
1363        .unwrap();
1364        let corrupt = create_run(&root, "wrong-length");
1365        fs::write(corrupt.join("evidence.raw.gz"), b"truncated").unwrap();
1366        let schema_mismatch = create_run(&root, "schema-mismatch");
1367        let metadata_path = schema_mismatch.join("run.json");
1368        let mut metadata: serde_json::Value =
1369            serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1370        metadata["rawEvidence"]["schemaVersion"] = 2.into();
1371        fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1372
1373        let inventory = discover_runs(&root).unwrap();
1374        assert_eq!(inventory.runs.len(), 1);
1375        assert_eq!(inventory.runs[0].id, "valid");
1376        assert_eq!(inventory.rejected.len(), 3);
1377        assert!(inventory.rejected[0].reason.contains("metadata ID"));
1378        assert!(
1379            inventory
1380                .rejected
1381                .iter()
1382                .any(|run| run.reason.contains("raw evidence metadata"))
1383        );
1384        assert!(
1385            inventory
1386                .rejected
1387                .iter()
1388                .any(|run| run.reason.contains("raw evidence length"))
1389        );
1390        fs::remove_dir_all(root).unwrap();
1391    }
1392
1393    #[cfg(unix)]
1394    #[test]
1395    fn refuses_linked_run_directories_and_files() {
1396        use std::os::unix::fs::symlink;
1397
1398        let root = temporary_directory("links");
1399        let target = create_run(&root, "target");
1400        symlink(&target, root.join(".supercov/runs/linked-run")).unwrap();
1401        let linked_metadata = create_run(&root, "linked-metadata");
1402        fs::remove_file(linked_metadata.join("run.json")).unwrap();
1403        symlink(target.join("run.json"), linked_metadata.join("run.json")).unwrap();
1404        let linked_evidence = create_run(&root, "linked-evidence");
1405        fs::remove_file(linked_evidence.join("evidence.raw.gz")).unwrap();
1406        symlink(
1407            target.join("evidence.raw.gz"),
1408            linked_evidence.join("evidence.raw.gz"),
1409        )
1410        .unwrap();
1411
1412        let inventory = discover_runs(&root).unwrap();
1413        assert_eq!(inventory.runs.len(), 1);
1414        assert_eq!(inventory.runs[0].id, "target");
1415        assert_eq!(inventory.rejected.len(), 3);
1416        assert!(
1417            inventory
1418                .rejected
1419                .iter()
1420                .all(|rejected| rejected.reason.contains("unsafe run-store path"))
1421        );
1422        fs::remove_dir_all(root).unwrap();
1423    }
1424
1425    #[test]
1426    fn compares_integrity_in_stable_contract_order_and_binds_identity_to_evidence() {
1427        let root = temporary_directory("identity");
1428        create_run(&root, "run");
1429        let inventory = discover_runs(&root).unwrap();
1430        let run = &inventory.runs[0];
1431        let first = query_index_identity(run).unwrap();
1432        fs::write(&run.evidence_path, b"different bytes").unwrap();
1433        let second = query_index_identity(run).unwrap();
1434        assert_ne!(first.evidence_sha256, second.evidence_sha256);
1435        assert_ne!(first.evidence_bytes, second.evidence_bytes);
1436        assert_eq!(first.analysis_sha256, second.analysis_sha256);
1437        assert_eq!(env!("SUPERCOV_ENGINE_SOURCE_SHA256").len(), 64);
1438
1439        let mut current = integrity();
1440        current.schema_version += 1;
1441        current.instrumenter_version = "javascript-v2".into();
1442        current.fingerprint.source = digest('2');
1443        current.fingerprint.tests = digest('3');
1444        current.fingerprint.dependencies = digest('4');
1445        current.fingerprint.configuration = digest('5');
1446        assert_eq!(
1447            compare_run_integrity(Some(&integrity()), &current).reasons,
1448            [
1449                "coverage schema changed",
1450                "instrumenter contract changed",
1451                "instrumented source changed",
1452                "test files changed",
1453                "dependencies or lockfile changed",
1454                "test/build configuration changed",
1455            ]
1456        );
1457
1458        // Supercov's own digest moves on nearly every release. The checkout has
1459        // not changed and the recorded evidence is still a true record of what
1460        // ran, so it is not a reason to discard the run. Merging and the build
1461        // caches still consult this digest directly, where it does matter.
1462        let mut rebuilt = integrity();
1463        rebuilt.fingerprint.instrumenter = digest('1');
1464        assert!(!compare_run_integrity(Some(&integrity()), &rebuilt).stale);
1465        fs::remove_dir_all(root).unwrap();
1466    }
1467
1468    #[test]
1469    fn lazily_builds_reuses_and_repairs_a_fully_authenticated_typed_index() {
1470        let root = temporary_directory("lazy-index");
1471        let run = create_indexable_run(&root);
1472        assert!(!run.query_index_path.exists());
1473
1474        {
1475            let index = open_or_rebuild_query_index(&run).unwrap();
1476            index.verify_all().unwrap();
1477            CoverageIndex::new(&index).unwrap();
1478        }
1479        let canonical = fs::read(&run.query_index_path).unwrap();
1480        assert!(canonical.len() > crate::query_index::QUERY_INDEX_HEADER_SIZE);
1481        {
1482            let index = open_or_rebuild_query_index(&run).unwrap();
1483            index.verify_all().unwrap();
1484        }
1485        assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1486
1487        let mut corrupt = canonical.clone();
1488        let offset = crate::query_index::QUERY_INDEX_HEADER_SIZE + 8;
1489        corrupt[offset] ^= 0xff;
1490        fs::write(&run.query_index_path, corrupt).unwrap();
1491        {
1492            let index = open_or_rebuild_query_index(&run).unwrap();
1493            index.verify_all().unwrap();
1494        }
1495        assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
1496        fs::remove_dir_all(root).unwrap();
1497    }
1498
1499    #[test]
1500    fn an_index_opened_without_the_full_check_still_refuses_a_corrupt_page() {
1501        // The index a process has just written is opened without hashing every
1502        // page first, since the write already guarantees them. What keeps that
1503        // safe is that no page is trusted before it is checked: reading one
1504        // that does not match its digest must fail, not return its bytes.
1505        let root = temporary_directory("written-index");
1506        let run = create_indexable_python_run(&root);
1507        let identity = query_index_identity(&run).unwrap();
1508        drop(open_or_rebuild_query_index(&run).unwrap());
1509        let lines = QueryIndex::open(&run.query_index_path, &identity)
1510            .unwrap()
1511            .descriptor(crate::coverage_index::SECTION_LINES)
1512            .unwrap();
1513        assert!(lines.length > 0, "the fixture has line records to corrupt");
1514
1515        let mut bytes = fs::read(&run.query_index_path).unwrap();
1516        bytes[lines.offset as usize] ^= 0xff;
1517        fs::write(&run.query_index_path, bytes).unwrap();
1518
1519        let index = open_written_query_index(&run.query_index_path, &identity)
1520            .expect("opening does not hash every page");
1521        assert!(
1522            matches!(
1523                index.bytes(crate::coverage_index::SECTION_LINES, 0, 1),
1524                Err(crate::query_index::QueryIndexError::CorruptPage { .. })
1525            ),
1526            "the corrupt page is refused when it is read"
1527        );
1528        assert!(
1529            index.verify_all().is_err(),
1530            "and a full check still finds it"
1531        );
1532        fs::remove_dir_all(root).unwrap();
1533    }
1534
1535    #[test]
1536    fn indexes_the_declared_language_model() {
1537        let root = temporary_directory("v3-index");
1538        let run = create_indexable_python_run(&root);
1539        assert_eq!(
1540            run.metadata.raw_evidence.schema_version,
1541            EVIDENCE_ARCHIVE_SCHEMA_VERSION
1542        );
1543        assert_eq!(
1544            query_index_identity(&run).unwrap().archive_schema_version,
1545            EVIDENCE_ARCHIVE_SCHEMA_VERSION
1546        );
1547        let index = open_or_rebuild_query_index(&run).unwrap();
1548        index.verify_all().unwrap();
1549        let coverage_index = CoverageIndex::new(&index).unwrap();
1550        assert_eq!(
1551            coverage_index.model().unwrap().name,
1552            "python-owned-control-flow"
1553        );
1554        let summary = coverage_index
1555            .summary(crate::coverage_index::CoverageViewId::All)
1556            .unwrap();
1557        assert_eq!(summary.lines.percentage, 100.0);
1558        fs::remove_dir_all(root).unwrap();
1559    }
1560
1561    #[cfg(unix)]
1562    #[test]
1563    fn atomically_replaces_a_linked_disposable_index_without_touching_its_target() {
1564        use std::os::unix::fs::symlink;
1565
1566        let root = temporary_directory("linked-index");
1567        let run = create_indexable_run(&root);
1568        let outside = root.join("outside");
1569        fs::write(&outside, b"user data").unwrap();
1570        symlink(&outside, &run.query_index_path).unwrap();
1571
1572        let index = open_or_rebuild_query_index(&run).unwrap();
1573        index.verify_all().unwrap();
1574        assert_eq!(fs::read(outside).unwrap(), b"user data");
1575        assert!(
1576            fs::symlink_metadata(&run.query_index_path)
1577                .unwrap()
1578                .file_type()
1579                .is_file()
1580        );
1581        fs::remove_dir_all(root).unwrap();
1582    }
1583}