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