Skip to main content

supercov_engine/
integrity.rs

1//! Language-neutral run integrity fingerprints.
2//!
3//! A frontend contributes only its transformation/runtime shim identity. The
4//! Rust engine owns source, test, dependency, configuration and execution
5//! fingerprints for every language.
6
7use std::{
8    collections::BTreeSet,
9    fs,
10    io::{self, Read},
11    path::{Path, PathBuf},
12    process::Command,
13};
14
15use sha2::{Digest, Sha256};
16
17use crate::{
18    project_discovery::CoverageProject,
19    run_store::{GitIntegrity, RunFingerprint, RunIntegrity},
20};
21
22pub const RUN_INTEGRITY_SCHEMA_VERSION: u32 = 2;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct FrontendIntegrityInputs {
26    pub language: String,
27    pub version: String,
28    pub root: PathBuf,
29    pub instrumenter_files: Vec<PathBuf>,
30    pub execution_files: Vec<PathBuf>,
31    pub engine_instrumenter_sha256: String,
32    pub engine_execution_sha256: String,
33}
34
35impl FrontendIntegrityInputs {
36    pub fn javascript(root: PathBuf, runtime_files: Vec<PathBuf>) -> Self {
37        Self {
38            language: "javascript".into(),
39            version: "javascript-v1".into(),
40            root,
41            instrumenter_files: runtime_files.clone(),
42            execution_files: runtime_files,
43            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
44            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
45        }
46    }
47
48    pub fn embedded_javascript() -> Self {
49        Self {
50            language: "javascript".into(),
51            version: "javascript-v1".into(),
52            root: PathBuf::from("."),
53            instrumenter_files: Vec::new(),
54            execution_files: Vec::new(),
55            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
56            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
57        }
58    }
59
60    pub fn embedded_rust() -> Self {
61        Self {
62            language: "rust".into(),
63            version: "rust-owned-v1".into(),
64            root: PathBuf::from("."),
65            instrumenter_files: Vec::new(),
66            execution_files: Vec::new(),
67            engine_instrumenter_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
68            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
69        }
70    }
71
72    pub fn embedded_python() -> Self {
73        Self {
74            language: "python".into(),
75            version: "python-monitoring-v1".into(),
76            root: PathBuf::from("."),
77            instrumenter_files: Vec::new(),
78            execution_files: Vec::new(),
79            engine_instrumenter_sha256: env!("SUPERCOV_PYTHON_FRONTEND_SOURCE_SHA256").into(),
80            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
81        }
82    }
83
84    pub fn embedded_ruby() -> Self {
85        Self {
86            language: "ruby".into(),
87            version: "ruby-coverage-v1".into(),
88            root: PathBuf::from("."),
89            instrumenter_files: Vec::new(),
90            execution_files: Vec::new(),
91            engine_instrumenter_sha256: env!("SUPERCOV_RUBY_FRONTEND_SOURCE_SHA256").into(),
92            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
93        }
94    }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct ExplicitIntegrityInputs {
99    pub source_files: Vec<PathBuf>,
100    pub test_files: Vec<PathBuf>,
101    pub dependency_files: Vec<PathBuf>,
102    pub configuration_files: Vec<PathBuf>,
103    pub execution_configuration: Vec<u8>,
104}
105
106impl ExplicitIntegrityInputs {
107    pub(crate) fn assertion_paths(&self) -> Vec<PathBuf> {
108        self.source_files
109            .iter()
110            .chain(&self.test_files)
111            .chain(&self.dependency_files)
112            .chain(&self.configuration_files)
113            .cloned()
114            .collect()
115    }
116}
117
118pub(crate) fn javascript_assertion_paths(
119    root: &Path,
120    project: &CoverageProject,
121) -> Result<Vec<PathBuf>, IntegrityError> {
122    let mut paths = test_files(root)?;
123    paths.extend(dependency_files(root)?);
124    paths.extend(configuration_files(root, project)?);
125    paths.extend(project.source_files.iter().map(|p| root.join(p)));
126    paths.extend(
127        project
128            .source_scope
129            .entries
130            .iter()
131            .filter(|e| !e.is_generated_output())
132            .map(|e| root.join(&e.file)),
133    );
134    Ok(paths)
135}
136
137#[derive(Debug)]
138pub enum IntegrityError {
139    Io { path: PathBuf, source: io::Error },
140    UnsafeFile(PathBuf),
141    NonUtf8Path(PathBuf),
142    OutsideRoot { root: PathBuf, path: PathBuf },
143    InvalidEngineDigest(&'static str),
144}
145
146impl std::fmt::Display for IntegrityError {
147    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
150            Self::UnsafeFile(path) => {
151                write!(
152                    formatter,
153                    "fingerprint input is not a regular file: {}",
154                    path.display()
155                )
156            }
157            Self::NonUtf8Path(path) => {
158                write!(
159                    formatter,
160                    "fingerprint path is not valid UTF-8: {}",
161                    path.display()
162                )
163            }
164            Self::OutsideRoot { root, path } => write!(
165                formatter,
166                "fingerprint input {} is outside {}",
167                path.display(),
168                root.display()
169            ),
170            Self::InvalidEngineDigest(field) => write!(formatter, "invalid {field} SHA-256"),
171        }
172    }
173}
174
175impl std::error::Error for IntegrityError {}
176
177fn io_error(path: &Path, source: io::Error) -> IntegrityError {
178    IntegrityError::Io {
179        path: path.to_owned(),
180        source,
181    }
182}
183
184fn valid_sha256(value: &str) -> bool {
185    value.len() == 64
186        && value
187            .bytes()
188            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
189}
190
191fn local_path(root: &Path, path: &Path) -> Result<String, IntegrityError> {
192    let path = path
193        .strip_prefix(root)
194        .map_err(|_| IntegrityError::OutsideRoot {
195            root: root.to_owned(),
196            path: path.to_owned(),
197        })?;
198    path.components()
199        .map(|component| {
200            component
201                .as_os_str()
202                .to_str()
203                .map(str::to_owned)
204                .ok_or_else(|| IntegrityError::NonUtf8Path(path.to_owned()))
205        })
206        .collect::<Result<Vec<_>, _>>()
207        .map(|parts| parts.join("/"))
208}
209
210fn digest_files(
211    root: &Path,
212    paths: impl IntoIterator<Item = PathBuf>,
213) -> Result<String, IntegrityError> {
214    let paths = paths.into_iter().collect::<BTreeSet<_>>();
215    let mut labeled = paths
216        .into_iter()
217        .map(|path| local_path(root, &path).map(|label| (label, path)))
218        .collect::<Result<Vec<_>, _>>()?;
219    labeled.sort_by(|left, right| left.0.cmp(&right.0));
220    let mut hash = Sha256::new();
221    let mut buffer = [0_u8; 128 * 1024];
222    for (label, path) in labeled {
223        let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
224        if !metadata.file_type().is_file() {
225            return Err(IntegrityError::UnsafeFile(path));
226        }
227        hash.update(label.as_bytes());
228        hash.update([0]);
229        let mut file = fs::File::open(&path).map_err(|source| io_error(&path, source))?;
230        loop {
231            let read = file
232                .read(&mut buffer)
233                .map_err(|source| io_error(&path, source))?;
234            if read == 0 {
235                break;
236            }
237            hash.update(&buffer[..read]);
238        }
239        hash.update([0]);
240    }
241    Ok(format!("{:x}", hash.finalize()))
242}
243
244fn domain_hash(domain: &str, fields: &[(&str, &[u8])]) -> String {
245    let mut hash = Sha256::new();
246    hash.update(domain.as_bytes());
247    hash.update([0]);
248    for (name, value) in fields {
249        hash.update((*name).len().to_le_bytes());
250        hash.update(name.as_bytes());
251        hash.update(value.len().to_le_bytes());
252        hash.update(value);
253    }
254    format!("{:x}", hash.finalize())
255}
256
257fn source_file(path: &Path) -> bool {
258    let lower = path
259        .file_name()
260        .and_then(|name| name.to_str())
261        .unwrap_or("")
262        .to_ascii_lowercase();
263    [
264        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
265        ".mtsx",
266    ]
267    .iter()
268    .any(|extension| lower.ends_with(extension))
269}
270
271fn skipped_directory(name: &str) -> bool {
272    [
273        ".cache",
274        ".git",
275        ".mcdc-pool",
276        ".next",
277        ".nuxt",
278        ".output",
279        ".supercov",
280        "build",
281        "coverage",
282        "dist",
283        "node_modules",
284        "out",
285        "playwright-report",
286        "results",
287        "test-results",
288        "vendor",
289    ]
290    .contains(&name)
291}
292
293fn owned_workspace_store(path: &Path) -> bool {
294    crate::workspace::owned_workspace_path(path)
295}
296
297fn walk_files(
298    directory: &Path,
299    predicate: &impl Fn(&Path) -> bool,
300    output: &mut Vec<PathBuf>,
301) -> Result<(), IntegrityError> {
302    let metadata = match fs::symlink_metadata(directory) {
303        Ok(metadata) => metadata,
304        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
305        Err(source) => return Err(io_error(directory, source)),
306    };
307    if !metadata.file_type().is_dir() {
308        return Err(IntegrityError::UnsafeFile(directory.to_owned()));
309    }
310    let mut entries = fs::read_dir(directory)
311        .map_err(|source| io_error(directory, source))?
312        .collect::<Result<Vec<_>, _>>()
313        .map_err(|source| io_error(directory, source))?;
314    entries.sort_by_key(fs::DirEntry::file_name);
315    for entry in entries {
316        let path = entry.path();
317        let file_type = entry
318            .file_type()
319            .map_err(|source| io_error(&path, source))?;
320        if file_type.is_symlink() {
321            continue;
322        }
323        if file_type.is_dir() {
324            let name = entry.file_name();
325            if !name
326                .to_str()
327                .is_some_and(|name| name.starts_with('.') || skipped_directory(name))
328                && !path.join(".git").exists()
329                && !owned_workspace_store(&path)
330            {
331                walk_files(&path, predicate, output)?;
332            }
333        } else if file_type.is_file() && predicate(&path) {
334            output.push(path);
335        }
336    }
337    Ok(())
338}
339
340fn test_file(root: &Path, path: &Path) -> bool {
341    if !source_file(path) {
342        return false;
343    }
344    let local = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
345    local
346        .to_ascii_lowercase()
347        .split(['/', '\\', '_', '.', '-'])
348        .any(|part| matches!(part, "test" | "spec"))
349}
350
351fn test_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
352    let mut files = Vec::new();
353    for directory in ["test", "tests", "__tests__"] {
354        walk_files(&root.join(directory), &source_file, &mut files)?;
355    }
356    walk_files(root, &|path| test_file(root, path), &mut files)?;
357    files.sort();
358    files.dedup();
359    Ok(files)
360}
361
362fn dependency_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
363    let mut files = Vec::new();
364    walk_files(
365        root,
366        &|path| path.file_name().is_some_and(|name| name == "package.json"),
367        &mut files,
368    )?;
369    for name in [
370        "package-lock.json",
371        "npm-shrinkwrap.json",
372        "pnpm-lock.yaml",
373        "yarn.lock",
374        "bun.lock",
375        "bun.lockb",
376    ] {
377        let path = root.join(name);
378        if path.is_file() {
379            files.push(path);
380        }
381    }
382    files.sort();
383    files.dedup();
384    Ok(files)
385}
386
387fn configuration_file(path: &Path) -> bool {
388    let name = path
389        .file_name()
390        .and_then(|name| name.to_str())
391        .unwrap_or("")
392        .to_ascii_lowercase();
393    name == ".npmrc"
394        || (name.starts_with("tsconfig") && name.ends_with(".json"))
395        || name.contains(".config.")
396        || name.starts_with(".babelrc.")
397        || name.starts_with(".eslint")
398        || name.starts_with(".prettier")
399}
400
401fn configuration_files(
402    root: &Path,
403    project: &CoverageProject,
404) -> Result<Vec<PathBuf>, IntegrityError> {
405    let mut files = Vec::new();
406    walk_files(root, &configuration_file, &mut files)?;
407    files.extend(
408        [
409            project.playwright_config.as_ref(),
410            project.vitest_config.as_ref(),
411            project.jest_config.as_ref(),
412        ]
413        .into_iter()
414        .flatten()
415        .cloned(),
416    );
417    files.sort();
418    files.dedup();
419    Ok(files)
420}
421
422fn git_integrity(root: &Path) -> Option<GitIntegrity> {
423    let revision = Command::new("git")
424        .args(["rev-parse", "HEAD"])
425        .current_dir(root)
426        .output()
427        .ok();
428    let status = Command::new("git")
429        .args(["status", "--porcelain=v1"])
430        .current_dir(root)
431        .output()
432        .ok();
433    if !revision
434        .as_ref()
435        .is_some_and(|output| output.status.success())
436        && !status
437            .as_ref()
438            .is_some_and(|output| output.status.success())
439    {
440        return None;
441    }
442    Some(GitIntegrity {
443        revision: revision
444            .filter(|output| output.status.success())
445            .and_then(|output| String::from_utf8(output.stdout).ok())
446            .map(|revision| revision.trim().to_owned()),
447        dirty: !status
448            .as_ref()
449            .is_some_and(|output| output.status.success() && output.stdout.is_empty()),
450    })
451}
452
453pub fn create_run_integrity(
454    root: &Path,
455    project: &CoverageProject,
456    frontend: &FrontendIntegrityInputs,
457) -> Result<RunIntegrity, IntegrityError> {
458    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
459        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
460    }
461    if !valid_sha256(&frontend.engine_execution_sha256) {
462        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
463    }
464    let tests = test_files(root)?;
465    let dependencies = dependency_files(root)?;
466    let configuration = configuration_files(root, project)?;
467    // Scope entries outside the instrumented set still execute in the run,
468    // and ones that carry assertions or capability imports are rewritten and
469    // cached. Everything the frontend may cache must feed the fingerprint,
470    // or an edit to such a file would be overwritten by a stale cached copy.
471    // Entries that another domain already digests stay out of the source
472    // domain so each stale reason keeps naming exactly one kind of change.
473    //
474    // Generated outputs stay out too. A theme extension's hashed bundles are
475    // rebuilt by the wrapped command and synced back into the project, with a
476    // new name every build, so digesting them marked every run stale with
477    // "instrumented source changed" the moment it finished -- while nothing
478    // instrumented had changed at all.
479    let covered_elsewhere = tests
480        .iter()
481        .chain(dependencies.iter())
482        .chain(configuration.iter())
483        .collect::<std::collections::BTreeSet<_>>();
484    let source_paths = project
485        .source_files
486        .iter()
487        .map(|path| root.join(path))
488        .chain(
489            project
490                .source_scope
491                .entries
492                .iter()
493                .filter(|entry| !entry.is_generated_output())
494                .map(|entry| root.join(&entry.file))
495                .filter(|path| !covered_elsewhere.contains(path)),
496        )
497        .collect::<Vec<_>>();
498    let source = digest_files(root, source_paths)?;
499    let tests_digest = digest_files(root, tests.iter().cloned())?;
500    let dependency_digest = digest_files(root, dependencies)?;
501    let configuration_digest = digest_files(root, configuration)?;
502    let frontend_instrumenter =
503        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
504    let frontend_execution =
505        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
506    let instrumenter = domain_hash(
507        "supercov-run-instrumenter-v1",
508        &[
509            ("language", frontend.language.as_bytes()),
510            ("version", frontend.version.as_bytes()),
511            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
512            ("shim", frontend_instrumenter.as_bytes()),
513        ],
514    );
515    let build_environment = frontend_map_bytes(&project.build_environment);
516    let execution = domain_hash(
517        "supercov-run-execution-v1",
518        &[
519            ("language", frontend.language.as_bytes()),
520            ("version", frontend.version.as_bytes()),
521            ("source", source.as_bytes()),
522            ("dependencies", dependency_digest.as_bytes()),
523            ("configuration", configuration_digest.as_bytes()),
524            ("buildEnvironment", &build_environment),
525            ("engine", frontend.engine_execution_sha256.as_bytes()),
526            ("shim", frontend_execution.as_bytes()),
527        ],
528    );
529    let combined = domain_hash(
530        "supercov-run-combined-v1",
531        &[
532            ("language", frontend.language.as_bytes()),
533            ("version", frontend.version.as_bytes()),
534            ("source", source.as_bytes()),
535            ("tests", tests_digest.as_bytes()),
536            ("dependencies", dependency_digest.as_bytes()),
537            ("configuration", configuration_digest.as_bytes()),
538            ("instrumenter", instrumenter.as_bytes()),
539        ],
540    );
541    Ok(RunIntegrity {
542        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
543        instrumenter_version: frontend.version.clone(),
544        git: git_integrity(root),
545        fingerprint: RunFingerprint {
546            algorithm: "sha256".into(),
547            source,
548            tests: tests_digest,
549            dependencies: dependency_digest,
550            configuration: configuration_digest,
551            instrumenter,
552            execution,
553            combined,
554            source_files: project.source_files.len(),
555            test_files: tests.len(),
556        },
557        stale: None,
558        stale_reasons: None,
559    })
560}
561
562/// Language-neutral integrity construction for frontends whose discovery does
563/// not use the JavaScript `CoverageProject` compatibility structure.
564pub fn create_explicit_run_integrity(
565    root: &Path,
566    inputs: &ExplicitIntegrityInputs,
567    frontend: &FrontendIntegrityInputs,
568) -> Result<RunIntegrity, IntegrityError> {
569    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
570        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
571    }
572    if !valid_sha256(&frontend.engine_execution_sha256) {
573        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
574    }
575    let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
576    let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
577    let dependencies = digest_files(
578        root,
579        inputs.dependency_files.iter().map(|path| root.join(path)),
580    )?;
581    let configuration = digest_files(
582        root,
583        inputs
584            .configuration_files
585            .iter()
586            .map(|path| root.join(path)),
587    )?;
588    let frontend_instrumenter =
589        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
590    let frontend_execution =
591        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
592    let instrumenter = domain_hash(
593        "supercov-run-instrumenter-v1",
594        &[
595            ("language", frontend.language.as_bytes()),
596            ("version", frontend.version.as_bytes()),
597            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
598            ("shim", frontend_instrumenter.as_bytes()),
599        ],
600    );
601    let execution = domain_hash(
602        "supercov-run-execution-v1",
603        &[
604            ("language", frontend.language.as_bytes()),
605            ("version", frontend.version.as_bytes()),
606            ("source", source.as_bytes()),
607            ("dependencies", dependencies.as_bytes()),
608            ("configuration", configuration.as_bytes()),
609            ("executionConfiguration", &inputs.execution_configuration),
610            ("engine", frontend.engine_execution_sha256.as_bytes()),
611            ("shim", frontend_execution.as_bytes()),
612        ],
613    );
614    let combined = domain_hash(
615        "supercov-run-combined-v1",
616        &[
617            ("language", frontend.language.as_bytes()),
618            ("version", frontend.version.as_bytes()),
619            ("source", source.as_bytes()),
620            ("tests", tests.as_bytes()),
621            ("dependencies", dependencies.as_bytes()),
622            ("configuration", configuration.as_bytes()),
623            ("instrumenter", instrumenter.as_bytes()),
624            ("execution", execution.as_bytes()),
625        ],
626    );
627    Ok(RunIntegrity {
628        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
629        instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
630        git: git_integrity(root),
631        fingerprint: RunFingerprint {
632            // The frozen store contract names the digest primitive here. The
633            // domain-separation version belongs to the producer implementation,
634            // not this wire field.
635            algorithm: "sha256".into(),
636            source,
637            tests,
638            dependencies,
639            configuration,
640            instrumenter,
641            execution,
642            combined,
643            source_files: inputs.source_files.len(),
644            test_files: inputs.test_files.len(),
645        },
646        stale: None,
647        stale_reasons: None,
648    })
649}
650
651fn frontend_map_bytes(values: &std::collections::BTreeMap<String, String>) -> Vec<u8> {
652    let mut bytes = Vec::new();
653    for (key, value) in values {
654        bytes.extend_from_slice(&key.len().to_le_bytes());
655        bytes.extend_from_slice(key.as_bytes());
656        bytes.extend_from_slice(&value.len().to_le_bytes());
657        bytes.extend_from_slice(value.as_bytes());
658    }
659    bytes
660}
661
662#[cfg(test)]
663mod tests {
664    use std::{
665        collections::BTreeMap,
666        fs,
667        sync::atomic::{AtomicU64, Ordering},
668        time::{SystemTime, UNIX_EPOCH},
669    };
670
671    use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
672
673    use super::*;
674
675    static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
676
677    fn directory(label: &str) -> PathBuf {
678        let nonce = SystemTime::now()
679            .duration_since(UNIX_EPOCH)
680            .unwrap()
681            .as_nanos();
682        let root = std::env::temp_dir().join(format!(
683            "supercov-integrity-{label}-{}-{nonce}-{}",
684            std::process::id(),
685            TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
686        ));
687        fs::create_dir_all(&root).unwrap();
688        root
689    }
690
691    fn write(root: &Path, path: &str, contents: &str) {
692        let path = root.join(path);
693        fs::create_dir_all(path.parent().unwrap()).unwrap();
694        fs::write(path, contents).unwrap();
695    }
696
697    fn frontend(root: &Path) -> FrontendIntegrityInputs {
698        FrontendIntegrityInputs {
699            language: "javascript".into(),
700            version: "javascript-v1".into(),
701            root: root.to_owned(),
702            instrumenter_files: vec![root.join("instrumenter.js")],
703            execution_files: vec![root.join("runtime.mjs")],
704            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
705            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
706        }
707    }
708
709    fn fixture() -> (PathBuf, PathBuf) {
710        let root = directory("project");
711        let shim = directory("shim");
712        write(
713            &root,
714            "package.json",
715            r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
716        );
717        write(&root, "package-lock.json", "lock");
718        write(&root, "src/index.ts", "export const ready = true");
719        write(&root, "tests/index.test.ts", "test('ready', () => {})");
720        write(&root, "vite.config.ts", "export default {}");
721        write(&root, ".cache/test262/fake.test.js", "ignored");
722        write(
723            &root,
724            "supercov/.supercov-workspace-store",
725            "Supercov instrumented workspace. Safe to delete.\n",
726        );
727        write(
728            &root,
729            "supercov/workspace/copy/tests/copied.test.ts",
730            "ignored copied test",
731        );
732        write(&shim, "instrumenter.js", "instrument");
733        write(&shim, "runtime.mjs", "runtime");
734        (root, shim)
735    }
736
737    fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
738        let project = discover_coverage_project(root, environment, &[]).unwrap();
739        create_run_integrity(root, &project, &frontend(shim)).unwrap()
740    }
741
742    #[test]
743    fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
744        // A theme extension's Vite build lands hashed bundles in `assets/`
745        // and the run syncs them back into the project. They are excluded
746        // from instrumentation, so a rebuild must not read as a source change.
747        let (root, shim) = fixture();
748        write(
749            &root,
750            "package.json",
751            r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
752        );
753        write(&root, "app_extensions/upsells/package.json", "{}");
754        write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
755        write(
756            &root,
757            "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
758            "bundle one",
759        );
760        let first = integrity(&root, &shim, &BTreeMap::new());
761
762        fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
763        write(
764            &root,
765            "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
766            "bundle two",
767        );
768        let rebuilt = integrity(&root, &shim, &BTreeMap::new());
769        assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
770        assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
771
772        write(
773            &root,
774            "app_extensions/upsells/frontend/embed.ts",
775            "edited source",
776        );
777        let edited = integrity(&root, &shim, &BTreeMap::new());
778        assert_ne!(edited.fingerprint.source, first.fingerprint.source);
779        fs::remove_dir_all(root).unwrap();
780        fs::remove_dir_all(shim).unwrap();
781    }
782
783    #[test]
784    fn fingerprints_every_independent_input_domain_deterministically() {
785        let (root, shim) = fixture();
786        let first = integrity(&root, &shim, &BTreeMap::new());
787        let second = integrity(&root, &shim, &BTreeMap::new());
788        assert_eq!(first, second);
789        assert_eq!(first.fingerprint.source_files, 1);
790        assert_eq!(first.fingerprint.test_files, 1);
791        for digest in [
792            &first.fingerprint.source,
793            &first.fingerprint.tests,
794            &first.fingerprint.dependencies,
795            &first.fingerprint.configuration,
796            &first.fingerprint.instrumenter,
797            &first.fingerprint.execution,
798            &first.fingerprint.combined,
799        ] {
800            assert!(valid_sha256(digest));
801        }
802
803        write(&root, "src/index.ts", "export const ready = false");
804        let source = integrity(&root, &shim, &BTreeMap::new());
805        assert_ne!(source.fingerprint.source, first.fingerprint.source);
806        assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
807        assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
808
809        write(&root, "src/index.ts", "export const ready = true");
810        write(&root, "tests/index.test.ts", "test('changed', () => {})");
811        let tests = integrity(&root, &shim, &BTreeMap::new());
812        assert_eq!(tests.fingerprint.source, first.fingerprint.source);
813        assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
814        assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
815
816        write(&root, "tests/index.test.ts", "test('ready', () => {})");
817        write(&root, "package-lock.json", "changed lock");
818        let dependencies = integrity(&root, &shim, &BTreeMap::new());
819        assert_ne!(
820            dependencies.fingerprint.dependencies,
821            first.fingerprint.dependencies
822        );
823        assert_ne!(
824            dependencies.fingerprint.execution,
825            first.fingerprint.execution
826        );
827
828        write(&root, "package-lock.json", "lock");
829        write(&root, "vite.config.ts", "export default { changed: true }");
830        let configuration = integrity(&root, &shim, &BTreeMap::new());
831        assert_ne!(
832            configuration.fingerprint.configuration,
833            first.fingerprint.configuration
834        );
835
836        write(&root, "vite.config.ts", "export default {}");
837        write(&shim, "instrumenter.js", "changed instrumenter");
838        let instrumenter = integrity(&root, &shim, &BTreeMap::new());
839        assert_ne!(
840            instrumenter.fingerprint.instrumenter,
841            first.fingerprint.instrumenter
842        );
843        assert_ne!(
844            instrumenter.fingerprint.combined,
845            first.fingerprint.combined
846        );
847        fs::remove_dir_all(root).unwrap();
848        fs::remove_dir_all(shim).unwrap();
849    }
850
851    #[test]
852    fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
853        let (root, shim) = fixture();
854        write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
855        write(
856            &root,
857            "packages/ui/tests/ui.test.ts",
858            "import assert from 'node:assert/strict'; assert.equal(1, 1);",
859        );
860        let before = integrity(&root, &shim, &BTreeMap::new());
861        for base in [".claude/worktrees/other", "nested-fork"] {
862            write(
863                &root,
864                &format!("{base}/.git"),
865                "gitdir: /unrelated/repository",
866            );
867            write(
868                &root,
869                &format!("{base}/tests/other.test.ts"),
870                "assert.equal(2, 2);",
871            );
872            write(&root, &format!("{base}/package.json"), "{}");
873            write(&root, &format!("{base}/tsconfig.json"), "{}");
874        }
875        let after = integrity(&root, &shim, &BTreeMap::new());
876        assert_eq!(before.fingerprint, after.fingerprint);
877        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
878        let paths = javascript_assertion_paths(&root, &project).unwrap();
879        let inputs = crate::assertion_inputs::capture(&root, "javascript", paths).unwrap();
880        assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
881        assert!(
882            !inputs
883                .files
884                .keys()
885                .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
886        );
887        fs::remove_dir_all(root).unwrap();
888        fs::remove_dir_all(shim).unwrap();
889    }
890
891    #[test]
892    fn fingerprints_nested_workspace_manifests_and_execution_environment() {
893        let (root, shim) = fixture();
894        write(
895            &root,
896            "packages/ui/package.json",
897            r#"{"dependencies":{"react":"1"}}"#,
898        );
899        write(&root, "packages/ui/src/index.ts", "export const ui = true");
900        let first = integrity(&root, &shim, &BTreeMap::new());
901        write(
902            &root,
903            "packages/ui/package.json",
904            r#"{"dependencies":{"react":"2"}}"#,
905        );
906        let dependency = integrity(&root, &shim, &BTreeMap::new());
907        assert_ne!(
908            first.fingerprint.dependencies,
909            dependency.fingerprint.dependencies
910        );
911
912        let mut environment = BTreeMap::new();
913        environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
914        let project = discover_coverage_project(&root, &environment, &[]).unwrap();
915        let mut project_with_build_environment = project.clone();
916        project_with_build_environment
917            .build_environment
918            .insert("MODE".into(), "test".into());
919        let changed =
920            create_run_integrity(&root, &project_with_build_environment, &frontend(&shim)).unwrap();
921        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
922        assert_ne!(
923            baseline.fingerprint.execution,
924            changed.fingerprint.execution
925        );
926        assert_eq!(baseline.fingerprint.combined, changed.fingerprint.combined);
927        assert_eq!(
928            compare_run_integrity(Some(&baseline), &changed).reasons,
929            ["execution environment changed"]
930        );
931        fs::remove_dir_all(root).unwrap();
932        fs::remove_dir_all(shim).unwrap();
933    }
934
935    #[test]
936    fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
937        let root = directory("rust-project");
938        write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
939        write(
940            &root,
941            "Cargo.toml",
942            "[package]\nname='fixture'\nversion='0.0.0'\n",
943        );
944        let inputs = ExplicitIntegrityInputs {
945            source_files: vec!["src/lib.rs".into()],
946            test_files: vec!["src/lib.rs".into()],
947            dependency_files: vec!["Cargo.toml".into()],
948            configuration_files: Vec::new(),
949            execution_configuration: b"cargo\0test".to_vec(),
950        };
951        let result = create_explicit_run_integrity(
952            &root,
953            &inputs,
954            &FrontendIntegrityInputs::embedded_rust(),
955        )
956        .unwrap();
957        assert_eq!(result.fingerprint.algorithm, "sha256");
958        fs::remove_dir_all(root).unwrap();
959    }
960
961    #[cfg(unix)]
962    #[test]
963    fn rejects_linked_frontend_identity_files() {
964        use std::os::unix::fs::symlink;
965
966        let (root, shim) = fixture();
967        let outside = shim.join("outside.js");
968        fs::write(&outside, "outside").unwrap();
969        fs::remove_file(shim.join("instrumenter.js")).unwrap();
970        symlink(&outside, shim.join("instrumenter.js")).unwrap();
971        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
972        assert!(matches!(
973            create_run_integrity(&root, &project, &frontend(&shim)),
974            Err(IntegrityError::UnsafeFile(_))
975        ));
976        fs::remove_dir_all(root).unwrap();
977        fs::remove_dir_all(shim).unwrap();
978    }
979}