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