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