1use 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
501pub 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
512pub 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(§ions, &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()), ¤t).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}