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