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