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