Skip to main content

supercov_engine/
run_store.rs

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