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