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