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