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