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    query_index::{QueryIndex, QueryIndexError, write_query_index},
22};
23
24const MAX_RUN_METADATA_BYTES: u64 = 1024 * 1024;
25pub const RUST_ANALYSIS_ABI_VERSION: u32 = 1;
26pub const RUST_QUERY_PRODUCER_ABI_VERSION: u32 = 1;
27pub const RUST_QUERY_INDEX_FILE: &str = "query-index.v1.bin";
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct RunFingerprint {
32    pub algorithm: String,
33    pub source: String,
34    pub tests: String,
35    pub dependencies: String,
36    pub configuration: String,
37    pub instrumenter: String,
38    pub execution: String,
39    pub combined: String,
40    pub source_files: usize,
41    pub test_files: usize,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct GitIntegrity {
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub revision: Option<String>,
49    pub dirty: bool,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase", deny_unknown_fields)]
54pub struct RunIntegrity {
55    pub schema_version: u32,
56    pub instrumenter_version: String,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub git: Option<GitIntegrity>,
59    pub fingerprint: RunFingerprint,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub stale: Option<bool>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub stale_reasons: Option<Vec<String>>,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct RunTimings {
69    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
70    pub initialization_ms: f64,
71    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
72    pub workspace_preparation_ms: f64,
73    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
74    pub adapter_setup_ms: f64,
75    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
76    pub instrumented_build_ms: f64,
77    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
78    pub test_command_ms: f64,
79    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
80    pub evidence_publication_ms: f64,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase", deny_unknown_fields)]
85pub struct InstrumentedBuildCache {
86    pub key: String,
87    pub reused: bool,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase", deny_unknown_fields)]
92pub struct RawEvidenceMetadata {
93    pub schema_version: u32,
94    pub format: String,
95    pub file: String,
96    pub files: usize,
97    pub uncompressed_bytes: u64,
98    pub compressed_bytes: u64,
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct RunMetadata {
104    pub id: String,
105    pub started_at: String,
106    #[serde(serialize_with = "crate::coverage_analysis::serialize_javascript_number")]
107    pub duration_ms: f64,
108    pub command: Vec<String>,
109    pub test_exit_code: Option<i32>,
110    pub integrity: RunIntegrity,
111    pub raw_evidence: RawEvidenceMetadata,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub isolated_build: Option<bool>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub instrumented_build_cache: Option<InstrumentedBuildCache>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub timings: Option<RunTimings>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub merged: Option<bool>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub parents: Option<Vec<String>>,
122}
123
124#[derive(Debug, Clone, PartialEq)]
125pub struct StoredRun {
126    pub id: String,
127    pub directory: PathBuf,
128    pub evidence_path: PathBuf,
129    pub metadata_path: PathBuf,
130    pub query_index_path: PathBuf,
131    pub metadata: RunMetadata,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
135#[serde(rename_all = "camelCase")]
136pub struct RejectedRun {
137    pub entry: String,
138    pub reason: String,
139}
140
141#[derive(Debug, Clone, PartialEq)]
142pub struct RunInventory {
143    pub runs: Vec<StoredRun>,
144    pub rejected: Vec<RejectedRun>,
145}
146
147#[derive(Debug)]
148pub enum RunStoreError {
149    Io(io::Error),
150    UnsafeStore(PathBuf),
151    NoRuns,
152    RunNotFound(String),
153    InvalidRun(&'static str),
154}
155
156impl From<io::Error> for RunStoreError {
157    fn from(value: io::Error) -> Self {
158        Self::Io(value)
159    }
160}
161
162impl std::fmt::Display for RunStoreError {
163    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self {
165            Self::Io(error) => write!(formatter, "{error}"),
166            Self::UnsafeStore(path) => {
167                write!(formatter, "unsafe run-store path: {}", path.display())
168            }
169            Self::NoRuns => write!(formatter, "no local coverage runs"),
170            Self::RunNotFound(selector) => write!(formatter, "coverage run not found: {selector}"),
171            Self::InvalidRun(reason) => write!(formatter, "invalid coverage run: {reason}"),
172        }
173    }
174}
175
176impl std::error::Error for RunStoreError {}
177
178fn regular_file(path: &Path) -> Result<fs::Metadata, RunStoreError> {
179    let metadata = fs::symlink_metadata(path)?;
180    if !metadata.file_type().is_file() {
181        return Err(RunStoreError::UnsafeStore(path.to_owned()));
182    }
183    Ok(metadata)
184}
185
186pub(crate) fn valid_run_id(value: &str) -> bool {
187    !value.is_empty()
188        && value != "."
189        && value != ".."
190        && !value
191            .chars()
192            .any(|character| matches!(character, '/' | '\\' | '\0') || character.is_control())
193}
194
195fn valid_hex_digest(value: &str, length: usize) -> bool {
196    value.len() == length
197        && value
198            .bytes()
199            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
200}
201
202fn valid_sha256(value: &str) -> bool {
203    valid_hex_digest(value, 64)
204}
205
206fn validate_integrity(integrity: &RunIntegrity) -> Result<(), RunStoreError> {
207    let fingerprint = &integrity.fingerprint;
208    if fingerprint.algorithm != "sha256"
209        || ![
210            &fingerprint.source,
211            &fingerprint.tests,
212            &fingerprint.dependencies,
213            &fingerprint.configuration,
214            &fingerprint.instrumenter,
215            &fingerprint.execution,
216            &fingerprint.combined,
217        ]
218        .into_iter()
219        .all(|digest| valid_sha256(digest))
220    {
221        return Err(RunStoreError::InvalidRun("integrity fingerprint"));
222    }
223    if let Some(git) = &integrity.git
224        && git.revision.as_ref().is_some_and(|revision| {
225            !valid_hex_digest(revision, 40) && !valid_hex_digest(revision, 64)
226        })
227    {
228        return Err(RunStoreError::InvalidRun("git revision"));
229    }
230    Ok(())
231}
232
233fn read_metadata(path: &Path) -> Result<RunMetadata, RunStoreError> {
234    let metadata = regular_file(path)?;
235    if metadata.len() > MAX_RUN_METADATA_BYTES {
236        return Err(RunStoreError::InvalidRun("run metadata is too large"));
237    }
238    let capacity =
239        usize::try_from(metadata.len()).map_err(|_| RunStoreError::InvalidRun("metadata size"))?;
240    let mut bytes = Vec::with_capacity(capacity);
241    File::open(path)?
242        .take(MAX_RUN_METADATA_BYTES + 1)
243        .read_to_end(&mut bytes)?;
244    let metadata: RunMetadata = serde_json::from_slice(&bytes)
245        .map_err(|_| RunStoreError::InvalidRun("run metadata JSON"))?;
246    validate_integrity(&metadata.integrity)?;
247    Ok(metadata)
248}
249
250fn load_run(directory: &Path, entry: &str) -> Result<StoredRun, RunStoreError> {
251    if !valid_run_id(entry) {
252        return Err(RunStoreError::InvalidRun("run directory name"));
253    }
254    let directory_metadata = fs::symlink_metadata(directory)?;
255    if !directory_metadata.file_type().is_dir() {
256        return Err(RunStoreError::UnsafeStore(directory.to_owned()));
257    }
258    let metadata_path = directory.join("run.json");
259    let metadata = read_metadata(&metadata_path)?;
260    if metadata.id != entry {
261        return Err(RunStoreError::InvalidRun(
262            "metadata ID differs from directory",
263        ));
264    }
265    if metadata.raw_evidence.schema_version != EVIDENCE_ARCHIVE_SCHEMA_VERSION
266        || metadata.raw_evidence.format != "framed+gzip"
267        || metadata.raw_evidence.file != "evidence.raw.gz"
268        || metadata.raw_evidence.files == 0
269    {
270        return Err(RunStoreError::InvalidRun("raw evidence metadata"));
271    }
272    let evidence_path = directory.join("evidence.raw.gz");
273    let evidence_metadata = regular_file(&evidence_path)?;
274    if evidence_metadata.len() != metadata.raw_evidence.compressed_bytes {
275        return Err(RunStoreError::InvalidRun("raw evidence length"));
276    }
277    Ok(StoredRun {
278        id: entry.into(),
279        directory: directory.to_owned(),
280        evidence_path,
281        metadata_path,
282        query_index_path: directory.join(RUST_QUERY_INDEX_FILE),
283        metadata,
284    })
285}
286
287pub fn discover_runs(project_root: &Path) -> Result<RunInventory, RunStoreError> {
288    let store = project_root.join(".supercov").join("runs");
289    let store_metadata = match fs::symlink_metadata(&store) {
290        Ok(metadata) => metadata,
291        Err(error) if error.kind() == io::ErrorKind::NotFound => {
292            return Ok(RunInventory {
293                runs: Vec::new(),
294                rejected: Vec::new(),
295            });
296        }
297        Err(error) => return Err(error.into()),
298    };
299    if !store_metadata.file_type().is_dir() {
300        return Err(RunStoreError::UnsafeStore(store));
301    }
302    let mut runs = Vec::new();
303    let mut rejected = Vec::new();
304    for entry in fs::read_dir(&store)? {
305        let entry = match entry {
306            Ok(entry) => entry,
307            Err(error) => {
308                rejected.push(RejectedRun {
309                    entry: "<unreadable>".into(),
310                    reason: error.to_string(),
311                });
312                continue;
313            }
314        };
315        let name = entry.file_name().to_string_lossy().into_owned();
316        match load_run(&entry.path(), &name) {
317            Ok(run) => runs.push(run),
318            Err(error) => rejected.push(RejectedRun {
319                entry: name,
320                reason: error.to_string(),
321            }),
322        }
323    }
324    runs.sort_by(|left, right| right.id.cmp(&left.id));
325    rejected.sort_by(|left, right| left.entry.cmp(&right.entry));
326    Ok(RunInventory { runs, rejected })
327}
328
329pub fn select_run<'a>(
330    inventory: &'a RunInventory,
331    selector: Option<&str>,
332) -> Result<&'a StoredRun, RunStoreError> {
333    if inventory.runs.is_empty() {
334        return Err(RunStoreError::NoRuns);
335    }
336    if selector.is_none() || selector == Some("latest") {
337        return Ok(&inventory.runs[0]);
338    }
339    let selector = selector.expect("checked selector");
340    inventory
341        .runs
342        .iter()
343        .find(|run| run.id == selector)
344        .or_else(|| {
345            inventory
346                .runs
347                .iter()
348                .find(|run| run.id.starts_with(selector))
349        })
350        .ok_or_else(|| RunStoreError::RunNotFound(selector.into()))
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct IntegrityComparison {
355    pub stale: bool,
356    pub reasons: Vec<String>,
357}
358
359pub fn compare_run_integrity(
360    stored: Option<&RunIntegrity>,
361    current: &RunIntegrity,
362) -> IntegrityComparison {
363    let Some(stored) = stored else {
364        return IntegrityComparison {
365            stale: true,
366            reasons: vec!["run predates integrity fingerprints".into()],
367        };
368    };
369    let mut reasons = Vec::new();
370    if stored.schema_version != current.schema_version {
371        reasons.push("coverage schema changed".into());
372    }
373    if stored.fingerprint.instrumenter != current.fingerprint.instrumenter {
374        reasons.push("instrumenter changed".into());
375    }
376    if stored.fingerprint.source != current.fingerprint.source {
377        reasons.push("instrumented source changed".into());
378    }
379    if stored.fingerprint.tests != current.fingerprint.tests {
380        reasons.push("test files changed".into());
381    }
382    if stored.fingerprint.dependencies != current.fingerprint.dependencies {
383        reasons.push("dependencies or lockfile changed".into());
384    }
385    if stored.fingerprint.configuration != current.fingerprint.configuration {
386        reasons.push("test/build configuration changed".into());
387    }
388    if reasons.is_empty() && stored.fingerprint.execution != current.fingerprint.execution {
389        reasons.push("execution environment changed".into());
390    }
391    IntegrityComparison {
392        stale: !reasons.is_empty(),
393        reasons,
394    }
395}
396
397fn file_sha256(path: &Path) -> Result<([u8; 32], u64), RunStoreError> {
398    let metadata = regular_file(path)?;
399    let mut file = File::open(path)?;
400    let mut hash = Sha256::new();
401    let mut buffer = [0_u8; 128 * 1024];
402    loop {
403        let read = file.read(&mut buffer)?;
404        if read == 0 {
405            break;
406        }
407        hash.update(&buffer[..read]);
408    }
409    Ok((hash.finalize().into(), metadata.len()))
410}
411
412fn domain_hash(domain: &str, version: u32) -> [u8; 32] {
413    let mut hash = Sha256::new();
414    hash.update(domain.as_bytes());
415    hash.update([0]);
416    hash.update(version.to_le_bytes());
417    hash.update([0]);
418    hash.update(env!("SUPERCOV_ENGINE_SOURCE_SHA256").as_bytes());
419    hash.finalize().into()
420}
421
422pub fn query_index_identity(run: &StoredRun) -> Result<QueryIndexIdentity, RunStoreError> {
423    let (evidence_sha256, evidence_bytes) = file_sha256(&run.evidence_path)?;
424    Ok(QueryIndexIdentity {
425        evidence_sha256,
426        evidence_bytes,
427        analysis_sha256: domain_hash("supercov-analysis", RUST_ANALYSIS_ABI_VERSION),
428        producer_sha256: domain_hash(
429            "supercov-query-producer",
430            RUST_QUERY_PRODUCER_ABI_VERSION ^ QUERY_INDEX_SCHEMA_VERSION,
431        ),
432        archive_schema_version: EVIDENCE_ARCHIVE_SCHEMA_VERSION,
433    })
434}
435
436#[derive(Debug)]
437pub enum RunIndexError {
438    RunStore(RunStoreError),
439    QueryIndex(QueryIndexError),
440    CoverageIndex(CoverageIndexError),
441    Report(ReportError),
442    Metadata(serde_json::Error),
443    EvidenceChanged,
444}
445
446impl std::fmt::Display for RunIndexError {
447    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        match self {
449            Self::RunStore(error) => write!(formatter, "{error}"),
450            Self::QueryIndex(error) => write!(formatter, "{error}"),
451            Self::CoverageIndex(error) => write!(formatter, "{error}"),
452            Self::Report(error) => write!(formatter, "coverage analysis failed: {error:?}"),
453            Self::Metadata(error) => write!(formatter, "run integrity is invalid: {error}"),
454            Self::EvidenceChanged => write!(formatter, "evidence changed while indexing"),
455        }
456    }
457}
458
459impl std::error::Error for RunIndexError {}
460
461impl From<RunStoreError> for RunIndexError {
462    fn from(value: RunStoreError) -> Self {
463        Self::RunStore(value)
464    }
465}
466
467impl From<QueryIndexError> for RunIndexError {
468    fn from(value: QueryIndexError) -> Self {
469        Self::QueryIndex(value)
470    }
471}
472
473impl From<CoverageIndexError> for RunIndexError {
474    fn from(value: CoverageIndexError) -> Self {
475        Self::CoverageIndex(value)
476    }
477}
478
479impl From<ReportError> for RunIndexError {
480    fn from(value: ReportError) -> Self {
481        Self::Report(value)
482    }
483}
484
485impl From<serde_json::Error> for RunIndexError {
486    fn from(value: serde_json::Error) -> Self {
487        Self::Metadata(value)
488    }
489}
490
491fn open_validated_query_index(
492    path: &Path,
493    identity: &QueryIndexIdentity,
494) -> Result<QueryIndex, RunIndexError> {
495    let index = QueryIndex::open(path, identity)?;
496    index.verify_all()?;
497    CoverageIndex::new(&index)?;
498    Ok(index)
499}
500
501/// Open an existing valid index without triggering analysis or publication.
502pub fn open_existing_query_index(run: &StoredRun) -> Result<Option<QueryIndex>, RunIndexError> {
503    match fs::symlink_metadata(&run.query_index_path) {
504        Ok(_) => {
505            open_validated_query_index(&run.query_index_path, &query_index_identity(run)?).map(Some)
506        }
507        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
508        Err(error) => Err(RunStoreError::Io(error).into()),
509    }
510}
511
512/// Open a valid disposable index or atomically reconstruct it from evidence.
513///
514/// `evidence.raw.gz` remains authoritative. Any stale, truncated, linked or
515/// otherwise invalid index is ignored and replaced by a fully authenticated
516/// new inode. A second evidence hash prevents publishing a mixed-generation
517/// index if the supposedly immutable archive changes during analysis.
518pub fn open_or_rebuild_query_index(run: &StoredRun) -> Result<QueryIndex, RunIndexError> {
519    let identity = query_index_identity(run)?;
520    if let Ok(index) = open_validated_query_index(&run.query_index_path, &identity) {
521        return Ok(index);
522    }
523
524    let report = analyze_coverage_archive(&ArchiveReportRequest {
525        archive_path: run.evidence_path.clone(),
526        run_id: run.id.clone(),
527        generated_at: run.metadata.started_at.clone(),
528        integrity: Some(serde_json::to_value(&run.metadata.integrity)?),
529        test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
530    })?;
531    let sections = coverage_index_sections(&report)?;
532    if query_index_identity(run)? != identity {
533        return Err(RunIndexError::EvidenceChanged);
534    }
535    write_query_index(&sections, &identity, &run.query_index_path)?;
536    let index = open_validated_query_index(&run.query_index_path, &identity)?;
537    if query_index_identity(run)? != identity {
538        return Err(RunIndexError::EvidenceChanged);
539    }
540    Ok(index)
541}
542
543#[cfg(test)]
544mod tests {
545    use std::{
546        fs,
547        time::{SystemTime, UNIX_EPOCH},
548    };
549
550    use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
551
552    use super::*;
553
554    fn temporary_directory(label: &str) -> PathBuf {
555        let nonce = SystemTime::now()
556            .duration_since(UNIX_EPOCH)
557            .unwrap()
558            .as_nanos();
559        let path = std::env::temp_dir().join(format!(
560            "supercov-run-store-{label}-{}-{nonce}",
561            std::process::id()
562        ));
563        fs::create_dir_all(&path).unwrap();
564        path
565    }
566
567    fn digest(character: char) -> String {
568        std::iter::repeat_n(character, 64).collect()
569    }
570
571    fn integrity() -> RunIntegrity {
572        RunIntegrity {
573            schema_version: 2,
574            instrumenter_version: "2.0.0".into(),
575            git: Some(GitIntegrity {
576                revision: Some(std::iter::repeat_n('a', 40).collect()),
577                dirty: false,
578            }),
579            fingerprint: RunFingerprint {
580                algorithm: "sha256".into(),
581                source: digest('a'),
582                tests: digest('b'),
583                dependencies: digest('c'),
584                configuration: digest('d'),
585                instrumenter: digest('e'),
586                execution: digest('f'),
587                combined: digest('0'),
588                source_files: 1,
589                test_files: 1,
590            },
591            stale: None,
592            stale_reasons: None,
593        }
594    }
595
596    fn create_run(root: &Path, id: &str) -> PathBuf {
597        let directory = root.join(".supercov/runs").join(id);
598        fs::create_dir_all(&directory).unwrap();
599        let archive = write_archive(
600            vec![EvidenceArchiveEntry {
601                path: "manifest.json".into(),
602                contents: b"{}".to_vec(),
603            }],
604            &directory.join("evidence.raw.gz"),
605        )
606        .unwrap();
607        let metadata = RunMetadata {
608            id: id.into(),
609            started_at: id.into(),
610            duration_ms: 1.0,
611            command: vec!["npm".into(), "test".into()],
612            test_exit_code: Some(0),
613            integrity: integrity(),
614            raw_evidence: RawEvidenceMetadata {
615                schema_version: archive.schema_version,
616                format: archive.format.into(),
617                file: archive.file.into(),
618                files: archive.files,
619                uncompressed_bytes: archive.uncompressed_bytes,
620                compressed_bytes: archive.compressed_bytes,
621            },
622            isolated_build: Some(true),
623            instrumented_build_cache: None,
624            timings: None,
625            merged: None,
626            parents: None,
627        };
628        fs::write(
629            directory.join("run.json"),
630            serde_json::to_vec_pretty(&metadata).unwrap(),
631        )
632        .unwrap();
633        directory
634    }
635
636    fn copy_real_fixture_run(root: &Path) -> StoredRun {
637        let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
638        let fixture = workspace.join("tests/fixtures/generic-webpack");
639        let source_inventory = discover_runs(&fixture).unwrap();
640        let source = select_run(&source_inventory, Some("latest")).unwrap();
641        let destination = root.join(".supercov/runs").join(&source.id);
642        fs::create_dir_all(&destination).unwrap();
643        fs::copy(&source.metadata_path, destination.join("run.json")).unwrap();
644        fs::copy(&source.evidence_path, destination.join("evidence.raw.gz")).unwrap();
645        discover_runs(root).unwrap().runs.remove(0)
646    }
647
648    #[test]
649    fn discovers_valid_runs_in_reverse_order_and_selects_exact_prefix_or_latest() {
650        let root = temporary_directory("discovery");
651        create_run(&root, "2026-08-24T00-00-00-000Z");
652        create_run(&root, "2026-08-25T00-00-00-000Z");
653        let inventory = discover_runs(&root).unwrap();
654        assert!(inventory.rejected.is_empty());
655        assert_eq!(
656            inventory
657                .runs
658                .iter()
659                .map(|run| run.id.as_str())
660                .collect::<Vec<_>>(),
661            ["2026-08-25T00-00-00-000Z", "2026-08-24T00-00-00-000Z"]
662        );
663        assert_eq!(
664            select_run(&inventory, None).unwrap().id,
665            inventory.runs[0].id
666        );
667        assert_eq!(
668            select_run(&inventory, Some("2026-08-24")).unwrap().id,
669            "2026-08-24T00-00-00-000Z"
670        );
671        assert!(matches!(
672            select_run(&inventory, Some("missing")),
673            Err(RunStoreError::RunNotFound(_))
674        ));
675        fs::remove_dir_all(root).unwrap();
676    }
677
678    #[test]
679    fn reports_damaged_entries_without_hiding_valid_runs() {
680        let root = temporary_directory("rejected");
681        create_run(&root, "valid");
682        let mismatched = create_run(&root, "mismatched");
683        let mut metadata: serde_json::Value =
684            serde_json::from_slice(&fs::read(mismatched.join("run.json")).unwrap()).unwrap();
685        metadata["id"] = "different".into();
686        fs::write(
687            mismatched.join("run.json"),
688            serde_json::to_vec(&metadata).unwrap(),
689        )
690        .unwrap();
691        let corrupt = create_run(&root, "wrong-length");
692        fs::write(corrupt.join("evidence.raw.gz"), b"truncated").unwrap();
693
694        let inventory = discover_runs(&root).unwrap();
695        assert_eq!(inventory.runs.len(), 1);
696        assert_eq!(inventory.runs[0].id, "valid");
697        assert_eq!(inventory.rejected.len(), 2);
698        assert!(inventory.rejected[0].reason.contains("metadata ID"));
699        assert!(inventory.rejected[1].reason.contains("raw evidence length"));
700        fs::remove_dir_all(root).unwrap();
701    }
702
703    #[cfg(unix)]
704    #[test]
705    fn refuses_linked_run_directories_and_files() {
706        use std::os::unix::fs::symlink;
707
708        let root = temporary_directory("links");
709        let target = create_run(&root, "target");
710        symlink(&target, root.join(".supercov/runs/linked-run")).unwrap();
711        let linked_metadata = create_run(&root, "linked-metadata");
712        fs::remove_file(linked_metadata.join("run.json")).unwrap();
713        symlink(target.join("run.json"), linked_metadata.join("run.json")).unwrap();
714        let linked_evidence = create_run(&root, "linked-evidence");
715        fs::remove_file(linked_evidence.join("evidence.raw.gz")).unwrap();
716        symlink(
717            target.join("evidence.raw.gz"),
718            linked_evidence.join("evidence.raw.gz"),
719        )
720        .unwrap();
721
722        let inventory = discover_runs(&root).unwrap();
723        assert_eq!(inventory.runs.len(), 1);
724        assert_eq!(inventory.runs[0].id, "target");
725        assert_eq!(inventory.rejected.len(), 3);
726        assert!(
727            inventory
728                .rejected
729                .iter()
730                .all(|rejected| rejected.reason.contains("unsafe run-store path"))
731        );
732        fs::remove_dir_all(root).unwrap();
733    }
734
735    #[test]
736    fn compares_integrity_in_stable_contract_order_and_binds_identity_to_evidence() {
737        let root = temporary_directory("identity");
738        create_run(&root, "run");
739        let inventory = discover_runs(&root).unwrap();
740        let run = &inventory.runs[0];
741        let first = query_index_identity(run).unwrap();
742        fs::write(&run.evidence_path, b"different bytes").unwrap();
743        let second = query_index_identity(run).unwrap();
744        assert_ne!(first.evidence_sha256, second.evidence_sha256);
745        assert_ne!(first.evidence_bytes, second.evidence_bytes);
746        assert_eq!(first.analysis_sha256, second.analysis_sha256);
747        assert_eq!(env!("SUPERCOV_ENGINE_SOURCE_SHA256").len(), 64);
748
749        let mut current = integrity();
750        current.schema_version += 1;
751        current.fingerprint.instrumenter = digest('1');
752        current.fingerprint.source = digest('2');
753        current.fingerprint.tests = digest('3');
754        current.fingerprint.dependencies = digest('4');
755        current.fingerprint.configuration = digest('5');
756        assert_eq!(
757            compare_run_integrity(Some(&integrity()), &current).reasons,
758            [
759                "coverage schema changed",
760                "instrumenter changed",
761                "instrumented source changed",
762                "test files changed",
763                "dependencies or lockfile changed",
764                "test/build configuration changed",
765            ]
766        );
767        fs::remove_dir_all(root).unwrap();
768    }
769
770    #[test]
771    fn lazily_builds_reuses_and_repairs_a_fully_authenticated_typed_index() {
772        let root = temporary_directory("lazy-index");
773        let run = copy_real_fixture_run(&root);
774        assert!(!run.query_index_path.exists());
775
776        {
777            let index = open_or_rebuild_query_index(&run).unwrap();
778            index.verify_all().unwrap();
779            CoverageIndex::new(&index).unwrap();
780        }
781        let canonical = fs::read(&run.query_index_path).unwrap();
782        assert!(canonical.len() > crate::query_index::QUERY_INDEX_HEADER_SIZE);
783        {
784            let index = open_or_rebuild_query_index(&run).unwrap();
785            index.verify_all().unwrap();
786        }
787        assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
788
789        let mut corrupt = canonical.clone();
790        let offset = crate::query_index::QUERY_INDEX_HEADER_SIZE + 8;
791        corrupt[offset] ^= 0xff;
792        fs::write(&run.query_index_path, corrupt).unwrap();
793        {
794            let index = open_or_rebuild_query_index(&run).unwrap();
795            index.verify_all().unwrap();
796        }
797        assert_eq!(fs::read(&run.query_index_path).unwrap(), canonical);
798        fs::remove_dir_all(root).unwrap();
799    }
800
801    #[test]
802    fn accepts_every_persisted_run_in_the_tier_one_fixture_families() {
803        let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
804        for family in [
805            "generic-playwright",
806            "generic-node",
807            "generic-esbuild",
808            "generic-webpack",
809            "generic-swc",
810        ] {
811            let inventory = discover_runs(&workspace.join("tests/fixtures").join(family)).unwrap();
812            assert!(!inventory.runs.is_empty(), "{family} has no persisted runs");
813            assert_eq!(inventory.rejected, [], "{family} has rejected runs");
814        }
815    }
816
817    #[cfg(unix)]
818    #[test]
819    fn atomically_replaces_a_linked_disposable_index_without_touching_its_target() {
820        use std::os::unix::fs::symlink;
821
822        let root = temporary_directory("linked-index");
823        let run = copy_real_fixture_run(&root);
824        let outside = root.join("outside");
825        fs::write(&outside, b"user data").unwrap();
826        symlink(&outside, &run.query_index_path).unwrap();
827
828        let index = open_or_rebuild_query_index(&run).unwrap();
829        index.verify_all().unwrap();
830        assert_eq!(fs::read(outside).unwrap(), b"user data");
831        assert!(
832            fs::symlink_metadata(&run.query_index_path)
833                .unwrap()
834                .file_type()
835                .is_file()
836        );
837        fs::remove_dir_all(root).unwrap();
838    }
839}