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
214/// Fields that name the project rather than its dependencies. None of them can
215/// change how installed code behaves, so none of them belong in a fingerprint
216/// whose only job is to say whether the execution context moved.
217const RELEASE_METADATA: &[&str] = &[
218    "author",
219    "authors",
220    "bugs",
221    "categories",
222    "classifiers",
223    "contributors",
224    "description",
225    "documentation",
226    "funding",
227    "homepage",
228    "keywords",
229    "license",
230    "license-file",
231    "maintainers",
232    "man",
233    "readme",
234    "repository",
235    "urls",
236    "version",
237];
238
239#[derive(Clone, Copy, PartialEq, Eq)]
240enum ManifestKind {
241    PackageJson,
242    PackageLock,
243    CargoToml,
244    PyprojectToml,
245}
246
247fn manifest_kind(path: &Path) -> Option<ManifestKind> {
248    match path.file_name()?.to_str()? {
249        "package.json" => Some(ManifestKind::PackageJson),
250        "package-lock.json" | "npm-shrinkwrap.json" => Some(ManifestKind::PackageLock),
251        "Cargo.toml" => Some(ManifestKind::CargoToml),
252        "pyproject.toml" => Some(ManifestKind::PyprojectToml),
253        _ => None,
254    }
255}
256
257/// The part of a manifest that decides behaviour, in a canonical encoding.
258///
259/// Only release metadata is dropped, and only from the tables that describe
260/// this project. Every other field survives, including one a future npm or
261/// cargo invents, so an unrecognised key is conservative by default. A lockfile
262/// keeps every dependency's version and loses only the project's own, which it
263/// mirrors from `package.json`.
264fn behavioural_manifest(kind: ManifestKind, raw: &[u8]) -> Option<Vec<u8>> {
265    let mut value: serde_json::Value = match kind {
266        ManifestKind::PackageJson | ManifestKind::PackageLock => {
267            serde_json::from_slice(raw).ok()?
268        }
269        ManifestKind::CargoToml | ManifestKind::PyprojectToml => {
270            let text = std::str::from_utf8(raw).ok()?;
271            serde_json::to_value(toml::from_str::<toml::Value>(text).ok()?).ok()?
272        }
273    };
274    match kind {
275        ManifestKind::PackageJson => strip_metadata(&mut value, &[]),
276        // A lockfile repeats this project's own version at its root and again
277        // in `packages[""]`. Every other entry is a real dependency whose
278        // version must still be hashed.
279        ManifestKind::PackageLock => {
280            strip_metadata(&mut value, &[]);
281            strip_metadata(&mut value, &["packages", ""]);
282        }
283        ManifestKind::CargoToml => {
284            strip_metadata(&mut value, &["package"]);
285            strip_metadata(&mut value, &["workspace", "package"]);
286        }
287        ManifestKind::PyprojectToml => {
288            strip_metadata(&mut value, &["project"]);
289            strip_metadata(&mut value, &["tool", "poetry"]);
290        }
291    }
292    let mut bytes = Vec::new();
293    canonical(&value, &mut bytes);
294    Some(bytes)
295}
296
297fn strip_metadata(value: &mut serde_json::Value, path: &[&str]) {
298    let mut table = value;
299    for key in path {
300        match table.get_mut(*key) {
301            Some(next) => table = next,
302            None => return,
303        }
304    }
305    let Some(table) = table.as_object_mut() else {
306        return;
307    };
308    for key in RELEASE_METADATA {
309        table.remove(*key);
310    }
311}
312
313/// A length-prefixed, key-sorted encoding, so the digest does not move when a
314/// formatter reorders keys or rewrites whitespace.
315fn canonical(value: &serde_json::Value, out: &mut Vec<u8>) {
316    match value {
317        serde_json::Value::Null => out.push(0),
318        serde_json::Value::Bool(flag) => out.extend([1, u8::from(*flag)]),
319        serde_json::Value::Number(number) => tagged(out, 2, number.to_string().as_bytes()),
320        serde_json::Value::String(text) => tagged(out, 3, text.as_bytes()),
321        serde_json::Value::Array(items) => {
322            tagged(out, 4, &(items.len() as u64).to_le_bytes());
323            for item in items {
324                canonical(item, out);
325            }
326        }
327        serde_json::Value::Object(table) => {
328            let mut keys = table.keys().collect::<Vec<_>>();
329            keys.sort();
330            tagged(out, 5, &(keys.len() as u64).to_le_bytes());
331            for key in keys {
332                tagged(out, 6, key.as_bytes());
333                canonical(&table[key], out);
334            }
335        }
336    }
337}
338
339fn tagged(out: &mut Vec<u8>, tag: u8, bytes: &[u8]) {
340    out.push(tag);
341    out.extend((bytes.len() as u64).to_le_bytes());
342    out.extend(bytes);
343}
344
345fn digest_files(
346    root: &Path,
347    paths: impl IntoIterator<Item = PathBuf>,
348) -> Result<String, IntegrityError> {
349    digest_paths(root, paths, false)
350}
351
352/// Dependency manifests, hashed by what they say rather than by their bytes.
353///
354/// A manifest carries two unrelated things: what the project depends on, and
355/// how the project describes itself. Hashing both means every release
356/// invalidates every claim in the map, because a manifest is where the version
357/// number lives. Across supergateway's last sixty commits thirty percent
358/// touched a manifest, and seven of those eighteen changed nothing but a
359/// version string.
360fn digest_manifests(
361    root: &Path,
362    paths: impl IntoIterator<Item = PathBuf>,
363) -> Result<String, IntegrityError> {
364    digest_paths(root, paths, true)
365}
366
367fn digest_paths(
368    root: &Path,
369    paths: impl IntoIterator<Item = PathBuf>,
370    manifests: bool,
371) -> Result<String, IntegrityError> {
372    let paths = paths.into_iter().collect::<BTreeSet<_>>();
373    let mut labeled = paths
374        .into_iter()
375        .map(|path| local_path(root, &path).map(|label| (label, path)))
376        .collect::<Result<Vec<_>, _>>()?;
377    labeled.sort_by(|left, right| left.0.cmp(&right.0));
378    let mut hash = Sha256::new();
379    let mut buffer = [0_u8; 128 * 1024];
380    for (label, path) in labeled {
381        let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
382        if !metadata.file_type().is_file() {
383            return Err(IntegrityError::UnsafeFile(path));
384        }
385        hash.update(label.as_bytes());
386        hash.update([0]);
387        // A manifest we can parse is hashed by meaning; anything else, and any
388        // manifest we fail to parse, is hashed whole. Falling back to the bytes
389        // keeps an unfamiliar or malformed file conservative.
390        let meaning = manifests
391            .then(|| manifest_kind(&path))
392            .flatten()
393            .and_then(|kind| behavioural_manifest(kind, &fs::read(&path).ok()?));
394        if let Some(bytes) = meaning {
395            hash.update(&bytes);
396        } else {
397            let mut file = fs::File::open(&path).map_err(|source| io_error(&path, source))?;
398            loop {
399                let read = file
400                    .read(&mut buffer)
401                    .map_err(|source| io_error(&path, source))?;
402                if read == 0 {
403                    break;
404                }
405                hash.update(&buffer[..read]);
406            }
407        }
408        hash.update([0]);
409    }
410    Ok(format!("{:x}", hash.finalize()))
411}
412
413fn domain_hash(domain: &str, fields: &[(&str, &[u8])]) -> String {
414    let mut hash = Sha256::new();
415    hash.update(domain.as_bytes());
416    hash.update([0]);
417    for (name, value) in fields {
418        hash.update((*name).len().to_le_bytes());
419        hash.update(name.as_bytes());
420        hash.update(value.len().to_le_bytes());
421        hash.update(value);
422    }
423    format!("{:x}", hash.finalize())
424}
425
426fn source_file(path: &Path) -> bool {
427    let lower = path
428        .file_name()
429        .and_then(|name| name.to_str())
430        .unwrap_or("")
431        .to_ascii_lowercase();
432    [
433        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
434        ".mtsx",
435    ]
436    .iter()
437    .any(|extension| lower.ends_with(extension))
438}
439
440fn skipped_directory(name: &str) -> bool {
441    [
442        ".cache",
443        ".git",
444        ".mcdc-pool",
445        ".next",
446        ".nuxt",
447        ".output",
448        ".supercov",
449        "build",
450        "coverage",
451        "dist",
452        "node_modules",
453        "out",
454        "playwright-report",
455        "results",
456        "test-results",
457        "vendor",
458    ]
459    .contains(&name)
460}
461
462fn owned_workspace_store(path: &Path) -> bool {
463    crate::workspace::owned_workspace_path(path)
464}
465
466fn walk_files(
467    directory: &Path,
468    predicate: &impl Fn(&Path) -> bool,
469    output: &mut Vec<PathBuf>,
470) -> Result<(), IntegrityError> {
471    let metadata = match fs::symlink_metadata(directory) {
472        Ok(metadata) => metadata,
473        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
474        Err(source) => return Err(io_error(directory, source)),
475    };
476    if !metadata.file_type().is_dir() {
477        return Err(IntegrityError::UnsafeFile(directory.to_owned()));
478    }
479    let mut entries = fs::read_dir(directory)
480        .map_err(|source| io_error(directory, source))?
481        .collect::<Result<Vec<_>, _>>()
482        .map_err(|source| io_error(directory, source))?;
483    entries.sort_by_key(fs::DirEntry::file_name);
484    for entry in entries {
485        let path = entry.path();
486        let file_type = entry
487            .file_type()
488            .map_err(|source| io_error(&path, source))?;
489        if file_type.is_symlink() {
490            continue;
491        }
492        if file_type.is_dir() {
493            let name = entry.file_name();
494            if !name
495                .to_str()
496                .is_some_and(|name| name.starts_with('.') || skipped_directory(name))
497                && !path.join(".git").exists()
498                && !owned_workspace_store(&path)
499            {
500                walk_files(&path, predicate, output)?;
501            }
502        } else if file_type.is_file() && predicate(&path) {
503            output.push(path);
504        }
505    }
506    Ok(())
507}
508
509fn test_file(root: &Path, path: &Path) -> bool {
510    if !source_file(path) {
511        return false;
512    }
513    let local = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
514    local
515        .to_ascii_lowercase()
516        .split(['/', '\\', '_', '.', '-'])
517        .any(|part| matches!(part, "test" | "spec"))
518}
519
520fn test_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
521    let mut files = Vec::new();
522    for directory in ["test", "tests", "__tests__"] {
523        walk_files(&root.join(directory), &source_file, &mut files)?;
524    }
525    walk_files(root, &|path| test_file(root, path), &mut files)?;
526    files.sort();
527    files.dedup();
528    Ok(files)
529}
530
531fn dependency_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
532    let mut files = Vec::new();
533    walk_files(
534        root,
535        &|path| path.file_name().is_some_and(|name| name == "package.json"),
536        &mut files,
537    )?;
538    for name in [
539        "package-lock.json",
540        "npm-shrinkwrap.json",
541        "pnpm-lock.yaml",
542        "yarn.lock",
543        "bun.lock",
544        "bun.lockb",
545    ] {
546        let path = root.join(name);
547        if path.is_file() {
548            files.push(path);
549        }
550    }
551    files.sort();
552    files.dedup();
553    Ok(files)
554}
555
556fn configuration_file(path: &Path) -> bool {
557    let name = path
558        .file_name()
559        .and_then(|name| name.to_str())
560        .unwrap_or("")
561        .to_ascii_lowercase();
562    if formatting_only(&name) {
563        return false;
564    }
565    name == ".npmrc"
566        || (name.starts_with("tsconfig") && name.ends_with(".json"))
567        || name.contains(".config.")
568        || name.starts_with(".babelrc.")
569}
570
571/// Linters and formatters do not change what the code does when it runs, so
572/// editing their settings is not a change of execution context. Counting it as
573/// one meant a Prettier tweak invalidated every flow in the map. Transpiler
574/// configuration is a different matter and stays: Babel and tsconfig decide
575/// what actually executes.
576fn formatting_only(name: &str) -> bool {
577    let stem = name.strip_prefix('.').unwrap_or(name);
578    stem.starts_with("eslint") || stem.starts_with("prettier")
579}
580
581/// Files whose content already reaches the run fingerprint, for any language
582/// Supercov supports.
583///
584/// When one of these changes the whole map is marked dirty, so a flow that also
585/// names one in `watch` buys nothing. Worse, it teaches the author a model of
586/// the tool that is not true: that per-flow watching is what catches dependency
587/// drift.
588const TRACKED_MANIFESTS: &[&str] = &[
589    "Cargo.lock",
590    "Cargo.toml",
591    "Gemfile",
592    "Gemfile.lock",
593    "Pipfile",
594    "Pipfile.lock",
595    "bun.lock",
596    "bun.lockb",
597    "npm-shrinkwrap.json",
598    "package-lock.json",
599    "package.json",
600    "pdm.lock",
601    "pnpm-lock.yaml",
602    "poetry.lock",
603    "pyproject.toml",
604    "setup.cfg",
605    "setup.py",
606    "uv.lock",
607    "yarn.lock",
608    ".ruby-version",
609    ".tool-versions",
610];
611
612/// A dependency manifest or lockfile, for any language Supercov supports.
613///
614/// These already have a dedicated signal: the run's dependency fingerprint,
615/// which reads what a manifest declares rather than its bytes. Reporting their
616/// raw bytes a second time would say a release changed something when it
617/// changed nothing.
618pub fn tracked_manifest(path: &str) -> bool {
619    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
620    TRACKED_MANIFESTS.contains(&name)
621        || name.ends_with(".gemspec")
622        || (name.starts_with("requirements") && name.ends_with(".txt"))
623}
624
625pub fn globally_tracked(path: &str) -> bool {
626    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
627    tracked_manifest(name) || configuration_file(Path::new(name))
628}
629
630fn configuration_files(
631    root: &Path,
632    project: &CoverageProject,
633) -> Result<Vec<PathBuf>, IntegrityError> {
634    let mut files = Vec::new();
635    walk_files(root, &configuration_file, &mut files)?;
636    files.extend(
637        [
638            project.playwright_config.as_ref(),
639            project.vitest_config.as_ref(),
640            project.jest_config.as_ref(),
641        ]
642        .into_iter()
643        .flatten()
644        .cloned(),
645    );
646    files.sort();
647    files.dedup();
648    Ok(files)
649}
650
651fn git_integrity(root: &Path) -> Option<GitIntegrity> {
652    let revision = Command::new("git")
653        .args(["rev-parse", "HEAD"])
654        .current_dir(root)
655        .output()
656        .ok();
657    let status = Command::new("git")
658        .args(["status", "--porcelain=v1"])
659        .current_dir(root)
660        .output()
661        .ok();
662    if !revision
663        .as_ref()
664        .is_some_and(|output| output.status.success())
665        && !status
666            .as_ref()
667            .is_some_and(|output| output.status.success())
668    {
669        return None;
670    }
671    Some(GitIntegrity {
672        revision: revision
673            .filter(|output| output.status.success())
674            .and_then(|output| String::from_utf8(output.stdout).ok())
675            .map(|revision| revision.trim().to_owned()),
676        dirty: !status
677            .as_ref()
678            .is_some_and(|output| output.status.success() && output.stdout.is_empty()),
679    })
680}
681
682pub fn create_run_integrity(
683    root: &Path,
684    project: &CoverageProject,
685    frontend: &FrontendIntegrityInputs,
686) -> Result<RunIntegrity, IntegrityError> {
687    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
688        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
689    }
690    if !valid_sha256(&frontend.engine_execution_sha256) {
691        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
692    }
693    let tests = test_files(root)?;
694    let dependencies = dependency_files(root)?;
695    let configuration = configuration_files(root, project)?;
696    // Scope entries outside the instrumented set still execute in the run,
697    // and ones that carry assertions or capability imports are rewritten and
698    // cached. Everything the frontend may cache must feed the fingerprint,
699    // or an edit to such a file would be overwritten by a stale cached copy.
700    // Entries that another domain already digests stay out of the source
701    // domain so each stale reason keeps naming exactly one kind of change.
702    //
703    // Generated outputs stay out too. A theme extension's hashed bundles are
704    // rebuilt by the wrapped command and synced back into the project, with a
705    // new name every build, so digesting them marked every run stale with
706    // "instrumented source changed" the moment it finished -- while nothing
707    // instrumented had changed at all.
708    let covered_elsewhere = tests
709        .iter()
710        .chain(dependencies.iter())
711        .chain(configuration.iter())
712        .collect::<std::collections::BTreeSet<_>>();
713    let source_paths = project
714        .source_files
715        .iter()
716        .map(|path| root.join(path))
717        .chain(
718            project
719                .source_scope
720                .entries
721                .iter()
722                .filter(|entry| !entry.is_generated_output())
723                .map(|entry| root.join(&entry.file))
724                .filter(|path| !covered_elsewhere.contains(path)),
725        )
726        .collect::<Vec<_>>();
727    let source = digest_files(root, source_paths)?;
728    let tests_digest = digest_files(root, tests.iter().cloned())?;
729    let dependency_digest = digest_manifests(root, dependencies)?;
730    let configuration_digest = digest_files(
731        root,
732        configuration
733            .into_iter()
734            .chain(crate::typescript_imports::config_paths(
735                root,
736                &project.source_files,
737            )),
738    )?;
739    let frontend_instrumenter =
740        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
741    let frontend_execution =
742        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
743    let instrumenter = domain_hash(
744        "supercov-run-instrumenter-v1",
745        &[
746            ("language", frontend.language.as_bytes()),
747            ("version", frontend.version.as_bytes()),
748            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
749            ("shim", frontend_instrumenter.as_bytes()),
750            // The runtime shim decides what evidence looks like, so it is part
751            // of who instrumented the run rather than of the run's setup.
752            (
753                "executionEngine",
754                frontend.engine_execution_sha256.as_bytes(),
755            ),
756            ("executionShim", frontend_execution.as_bytes()),
757        ],
758    );
759    let build_environment = frontend_map_bytes(&project.build_environment);
760    // `execution` describes the run's setup, not who instrumented it. Supercov's
761    // own source used to be folded in here as well, so upgrading Supercov made
762    // every stored run stale for a checkout that had not changed. Its identity
763    // still lives in `instrumenter`, which the build caches and run merging
764    // consult directly.
765    let execution = domain_hash(
766        "supercov-run-execution-v1",
767        &[
768            ("language", frontend.language.as_bytes()),
769            ("version", frontend.version.as_bytes()),
770            ("source", source.as_bytes()),
771            ("dependencies", dependency_digest.as_bytes()),
772            ("configuration", configuration_digest.as_bytes()),
773            ("buildEnvironment", &build_environment),
774        ],
775    );
776    let combined = domain_hash(
777        "supercov-run-combined-v1",
778        &[
779            ("language", frontend.language.as_bytes()),
780            ("version", frontend.version.as_bytes()),
781            ("source", source.as_bytes()),
782            ("tests", tests_digest.as_bytes()),
783            ("dependencies", dependency_digest.as_bytes()),
784            ("configuration", configuration_digest.as_bytes()),
785            ("instrumenter", instrumenter.as_bytes()),
786        ],
787    );
788    Ok(RunIntegrity {
789        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
790        instrumenter_version: frontend.version.clone(),
791        git: git_integrity(root),
792        fingerprint: RunFingerprint {
793            algorithm: "sha256".into(),
794            source,
795            tests: tests_digest,
796            dependencies: dependency_digest,
797            configuration: configuration_digest,
798            instrumenter,
799            execution,
800            combined,
801            source_files: project.source_files.len(),
802            test_files: tests.len(),
803        },
804        stale: None,
805        stale_reasons: None,
806    })
807}
808
809/// Language-neutral integrity construction for frontends whose discovery does
810/// not use the JavaScript `CoverageProject` compatibility structure.
811pub fn create_explicit_run_integrity(
812    root: &Path,
813    inputs: &ExplicitIntegrityInputs,
814    frontend: &FrontendIntegrityInputs,
815) -> Result<RunIntegrity, IntegrityError> {
816    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
817        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
818    }
819    if !valid_sha256(&frontend.engine_execution_sha256) {
820        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
821    }
822    let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
823    let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
824    let dependencies = digest_manifests(
825        root,
826        inputs.dependency_files.iter().map(|path| root.join(path)),
827    )?;
828    let configuration = digest_files(
829        root,
830        inputs
831            .configuration_files
832            .iter()
833            .map(|path| root.join(path)),
834    )?;
835    let frontend_instrumenter =
836        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
837    let frontend_execution =
838        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
839    let instrumenter = domain_hash(
840        "supercov-run-instrumenter-v1",
841        &[
842            ("language", frontend.language.as_bytes()),
843            ("version", frontend.version.as_bytes()),
844            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
845            ("shim", frontend_instrumenter.as_bytes()),
846            // The runtime shim decides what evidence looks like, so it is part
847            // of who instrumented the run rather than of the run's setup.
848            (
849                "executionEngine",
850                frontend.engine_execution_sha256.as_bytes(),
851            ),
852            ("executionShim", frontend_execution.as_bytes()),
853        ],
854    );
855    // `execution` describes the run's setup, not who instrumented it. Supercov's
856    // own source used to be folded in here as well, so upgrading Supercov made
857    // every stored run stale for a checkout that had not changed. Its identity
858    // still lives in `instrumenter`, which the build caches and run merging
859    // consult directly.
860    let execution = domain_hash(
861        "supercov-run-execution-v1",
862        &[
863            ("language", frontend.language.as_bytes()),
864            ("version", frontend.version.as_bytes()),
865            ("source", source.as_bytes()),
866            ("dependencies", dependencies.as_bytes()),
867            ("configuration", configuration.as_bytes()),
868            ("executionConfiguration", &inputs.execution_configuration),
869        ],
870    );
871    let combined = domain_hash(
872        "supercov-run-combined-v1",
873        &[
874            ("language", frontend.language.as_bytes()),
875            ("version", frontend.version.as_bytes()),
876            ("source", source.as_bytes()),
877            ("tests", tests.as_bytes()),
878            ("dependencies", dependencies.as_bytes()),
879            ("configuration", configuration.as_bytes()),
880            ("instrumenter", instrumenter.as_bytes()),
881            ("execution", execution.as_bytes()),
882        ],
883    );
884    Ok(RunIntegrity {
885        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
886        instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
887        git: git_integrity(root),
888        fingerprint: RunFingerprint {
889            // The frozen store contract names the digest primitive here. The
890            // domain-separation version belongs to the producer implementation,
891            // not this wire field.
892            algorithm: "sha256".into(),
893            source,
894            tests,
895            dependencies,
896            configuration,
897            instrumenter,
898            execution,
899            combined,
900            source_files: inputs.source_files.len(),
901            test_files: inputs.test_files.len(),
902        },
903        stale: None,
904        stale_reasons: None,
905    })
906}
907
908fn frontend_map_bytes(values: &std::collections::BTreeMap<String, String>) -> Vec<u8> {
909    let mut bytes = Vec::new();
910    for (key, value) in values {
911        bytes.extend_from_slice(&key.len().to_le_bytes());
912        bytes.extend_from_slice(key.as_bytes());
913        bytes.extend_from_slice(&value.len().to_le_bytes());
914        bytes.extend_from_slice(value.as_bytes());
915    }
916    bytes
917}
918
919#[cfg(test)]
920mod tests {
921    use std::{
922        collections::BTreeMap,
923        fs,
924        sync::atomic::{AtomicU64, Ordering},
925        time::{SystemTime, UNIX_EPOCH},
926    };
927
928    use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
929
930    use super::*;
931
932    static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
933
934    fn directory(label: &str) -> PathBuf {
935        let nonce = SystemTime::now()
936            .duration_since(UNIX_EPOCH)
937            .unwrap()
938            .as_nanos();
939        let root = std::env::temp_dir().join(format!(
940            "supercov-integrity-{label}-{}-{nonce}-{}",
941            std::process::id(),
942            TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
943        ));
944        fs::create_dir_all(&root).unwrap();
945        root
946    }
947
948    const PACKAGE: &str = r#"{"name":"g","version":"1.0.0","dependencies":{"a":"^1.2.3"}}"#;
949    const LOCK: &str = r#"{"name":"g","version":"1.0.0","packages":{"":{"name":"g","version":"1.0.0"},"node_modules/a":{"version":"1.2.3"}}}"#;
950    const CARGO: &str =
951        "[package]\nname = \"g\"\nversion = \"1.0.0\"\n\n[dependencies]\na = \"1.2.3\"\n";
952    const PYPROJECT: &str =
953        "[project]\nname = \"g\"\nversion = \"1.0.0\"\ndependencies = [\"a==1.2.3\"]\n";
954
955    #[test]
956    fn a_release_bump_leaves_a_manifest_digest_alone() {
957        // A version number says what the project calls itself, not what it
958        // depends on, and it lives in the same file as the dependencies. Hashing
959        // it meant every release invalidated every claim in the map.
960        for (kind, before, after) in [
961            (
962                ManifestKind::PackageJson,
963                PACKAGE.to_owned(),
964                PACKAGE.replace("1.0.0", "2.0.0"),
965            ),
966            (
967                ManifestKind::PackageLock,
968                LOCK.to_owned(),
969                LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
970            ),
971            (
972                ManifestKind::CargoToml,
973                CARGO.to_owned(),
974                CARGO.replace("1.0.0", "2.0.0"),
975            ),
976            (
977                ManifestKind::PyprojectToml,
978                PYPROJECT.to_owned(),
979                PYPROJECT.replace("1.0.0", "2.0.0"),
980            ),
981        ] {
982            let stable = behavioural_manifest(kind, before.as_bytes());
983            assert!(stable.is_some());
984            assert_eq!(stable, behavioural_manifest(kind, after.as_bytes()));
985        }
986    }
987
988    #[test]
989    fn a_dependency_change_still_moves_a_manifest_digest() {
990        // Dropping metadata must not drop the signal. A lockfile keeps every
991        // dependency's version and loses only the project's own.
992        for (kind, before, after) in [
993            (
994                ManifestKind::PackageJson,
995                PACKAGE.to_owned(),
996                PACKAGE.replace("^1.2.3", "^2.0.0"),
997            ),
998            (
999                ManifestKind::PackageLock,
1000                LOCK.to_owned(),
1001                LOCK.replace(
1002                    "\"node_modules/a\":{\"version\":\"1.2.3\"}",
1003                    "\"node_modules/a\":{\"version\":\"9.9.9\"}",
1004                ),
1005            ),
1006            (
1007                ManifestKind::CargoToml,
1008                CARGO.to_owned(),
1009                CARGO.replace("a = \"1.2.3\"", "a = \"9.9.9\""),
1010            ),
1011            (
1012                ManifestKind::PyprojectToml,
1013                PYPROJECT.to_owned(),
1014                PYPROJECT.replace("a==1.2.3", "a==9.9.9"),
1015            ),
1016        ] {
1017            assert_ne!(
1018                behavioural_manifest(kind, before.as_bytes()),
1019                behavioural_manifest(kind, after.as_bytes())
1020            );
1021        }
1022    }
1023
1024    #[test]
1025    fn the_dependency_fingerprint_survives_a_release_but_not_an_upgrade() {
1026        // The whole point, at the seam the fingerprint actually uses: cutting a
1027        // release must cost nothing, and changing a dependency must still cost
1028        // a recheck.
1029        let root = directory("manifest-fingerprint");
1030        let paths = || [root.join("package.json"), root.join("package-lock.json")];
1031        write(&root, "package.json", PACKAGE);
1032        write(&root, "package-lock.json", LOCK);
1033        let before = digest_manifests(&root, paths()).unwrap();
1034
1035        write(&root, "package.json", &PACKAGE.replace("1.0.0", "2.0.0"));
1036        write(
1037            &root,
1038            "package-lock.json",
1039            &LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1040        );
1041        assert_eq!(
1042            before,
1043            digest_manifests(&root, paths()).unwrap(),
1044            "a release must not move the dependency fingerprint"
1045        );
1046
1047        write(&root, "package.json", &PACKAGE.replace("^1.2.3", "^2.0.0"));
1048        assert_ne!(
1049            before,
1050            digest_manifests(&root, paths()).unwrap(),
1051            "an upgrade must still move it"
1052        );
1053        fs::remove_dir_all(root).unwrap();
1054    }
1055
1056    #[test]
1057    fn reformatting_a_manifest_leaves_its_digest_alone() {
1058        // Key order and whitespace are not meaning, and a formatter rewriting
1059        // either should not cost the author a re-acknowledgement.
1060        let reordered = r#"{"dependencies":{"a":"^1.2.3"},  "version":"1.0.0",
1061            "name":"g"}"#;
1062        assert_eq!(
1063            behavioural_manifest(ManifestKind::PackageJson, PACKAGE.as_bytes()),
1064            behavioural_manifest(ManifestKind::PackageJson, reordered.as_bytes())
1065        );
1066    }
1067
1068    #[test]
1069    fn an_unreadable_manifest_falls_back_to_its_bytes() {
1070        // A file we cannot parse is hashed whole, so a format we do not
1071        // understand stays conservative instead of silently hashing nothing.
1072        assert!(behavioural_manifest(ManifestKind::PackageJson, b"{ not json").is_none());
1073        assert!(behavioural_manifest(ManifestKind::CargoToml, b"[[[").is_none());
1074        let root = directory("manifest-fallback");
1075        write(&root, "package.json", "{ not json");
1076        let first = digest_manifests(&root, [root.join("package.json")]).unwrap();
1077        write(&root, "package.json", "{ still not json");
1078        assert_ne!(
1079            first,
1080            digest_manifests(&root, [root.join("package.json")]).unwrap()
1081        );
1082        fs::remove_dir_all(root).unwrap();
1083    }
1084
1085    #[test]
1086    fn linter_and_formatter_settings_are_not_execution_context() {
1087        // Neither tool changes what runs, so neither belongs in a fingerprint
1088        // that answers whether the execution context moved. Transpiler config
1089        // is a different matter and stays.
1090        for inert in [
1091            ".prettierrc",
1092            ".prettierrc.json",
1093            "prettier.config.js",
1094            ".eslintrc",
1095            ".eslintrc.json",
1096            "eslint.config.mjs",
1097        ] {
1098            assert!(!configuration_file(Path::new(inert)), "{inert}");
1099        }
1100        for real in ["tsconfig.json", ".babelrc.js", "vite.config.ts", ".npmrc"] {
1101            assert!(configuration_file(Path::new(real)), "{real}");
1102        }
1103    }
1104
1105    #[test]
1106    fn globally_tracked_names_what_the_fingerprint_already_covers() {
1107        for tracked in [
1108            "package-lock.json",
1109            "package.json",
1110            "Cargo.toml",
1111            "Gemfile.lock",
1112            "requirements-dev.txt",
1113            "supercov.gemspec",
1114            "tsconfig.json",
1115            "nested/pyproject.toml",
1116        ] {
1117            assert!(globally_tracked(tracked), "{tracked}");
1118        }
1119        for own in [
1120            "src/index.ts",
1121            "tests/helpers/gateway-process.ts",
1122            "README.md",
1123        ] {
1124            assert!(!globally_tracked(own), "{own}");
1125        }
1126    }
1127
1128    fn write(root: &Path, path: &str, contents: &str) {
1129        let path = root.join(path);
1130        fs::create_dir_all(path.parent().unwrap()).unwrap();
1131        fs::write(path, contents).unwrap();
1132    }
1133
1134    fn frontend(root: &Path) -> FrontendIntegrityInputs {
1135        FrontendIntegrityInputs {
1136            language: "javascript".into(),
1137            version: "javascript-v1".into(),
1138            root: root.to_owned(),
1139            instrumenter_files: vec![root.join("instrumenter.js")],
1140            execution_files: vec![root.join("runtime.mjs")],
1141            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
1142            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
1143        }
1144    }
1145
1146    fn fixture() -> (PathBuf, PathBuf) {
1147        let root = directory("project");
1148        let shim = directory("shim");
1149        write(
1150            &root,
1151            "package.json",
1152            r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
1153        );
1154        write(&root, "package-lock.json", "lock");
1155        write(&root, "src/index.ts", "export const ready = true");
1156        write(&root, "tests/index.test.ts", "test('ready', () => {})");
1157        write(&root, "vite.config.ts", "export default {}");
1158        write(&root, ".cache/test262/fake.test.js", "ignored");
1159        write(
1160            &root,
1161            "supercov/.supercov-workspace-store",
1162            "Supercov instrumented workspace. Safe to delete.\n",
1163        );
1164        write(
1165            &root,
1166            "supercov/workspace/copy/tests/copied.test.ts",
1167            "ignored copied test",
1168        );
1169        write(&shim, "instrumenter.js", "instrument");
1170        write(&shim, "runtime.mjs", "runtime");
1171        (root, shim)
1172    }
1173
1174    fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
1175        let project = discover_coverage_project(root, environment, &[]).unwrap();
1176        create_run_integrity(root, &project, &frontend(shim)).unwrap()
1177    }
1178
1179    #[test]
1180    fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
1181        // A theme extension's Vite build lands hashed bundles in `assets/`
1182        // and the run syncs them back into the project. They are excluded
1183        // from instrumentation, so a rebuild must not read as a source change.
1184        let (root, shim) = fixture();
1185        write(
1186            &root,
1187            "package.json",
1188            r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
1189        );
1190        write(&root, "app_extensions/upsells/package.json", "{}");
1191        write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
1192        write(
1193            &root,
1194            "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1195            "bundle one",
1196        );
1197        let first = integrity(&root, &shim, &BTreeMap::new());
1198
1199        fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
1200        write(
1201            &root,
1202            "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
1203            "bundle two",
1204        );
1205        let rebuilt = integrity(&root, &shim, &BTreeMap::new());
1206        assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
1207        assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
1208
1209        write(
1210            &root,
1211            "app_extensions/upsells/frontend/embed.ts",
1212            "edited source",
1213        );
1214        let edited = integrity(&root, &shim, &BTreeMap::new());
1215        assert_ne!(edited.fingerprint.source, first.fingerprint.source);
1216        fs::remove_dir_all(root).unwrap();
1217        fs::remove_dir_all(shim).unwrap();
1218    }
1219
1220    #[test]
1221    fn fingerprints_every_independent_input_domain_deterministically() {
1222        let (root, shim) = fixture();
1223        let first = integrity(&root, &shim, &BTreeMap::new());
1224        let second = integrity(&root, &shim, &BTreeMap::new());
1225        assert_eq!(first, second);
1226        assert_eq!(first.fingerprint.source_files, 1);
1227        assert_eq!(first.fingerprint.test_files, 1);
1228        for digest in [
1229            &first.fingerprint.source,
1230            &first.fingerprint.tests,
1231            &first.fingerprint.dependencies,
1232            &first.fingerprint.configuration,
1233            &first.fingerprint.instrumenter,
1234            &first.fingerprint.execution,
1235            &first.fingerprint.combined,
1236        ] {
1237            assert!(valid_sha256(digest));
1238        }
1239
1240        write(&root, "src/index.ts", "export const ready = false");
1241        let source = integrity(&root, &shim, &BTreeMap::new());
1242        assert_ne!(source.fingerprint.source, first.fingerprint.source);
1243        assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
1244        assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
1245
1246        write(&root, "src/index.ts", "export const ready = true");
1247        write(&root, "tests/index.test.ts", "test('changed', () => {})");
1248        let tests = integrity(&root, &shim, &BTreeMap::new());
1249        assert_eq!(tests.fingerprint.source, first.fingerprint.source);
1250        assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
1251        assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
1252
1253        write(&root, "tests/index.test.ts", "test('ready', () => {})");
1254        write(&root, "package-lock.json", "changed lock");
1255        let dependencies = integrity(&root, &shim, &BTreeMap::new());
1256        assert_ne!(
1257            dependencies.fingerprint.dependencies,
1258            first.fingerprint.dependencies
1259        );
1260        assert_ne!(
1261            dependencies.fingerprint.execution,
1262            first.fingerprint.execution
1263        );
1264
1265        write(&root, "package-lock.json", "lock");
1266        write(&root, "vite.config.ts", "export default { changed: true }");
1267        let configuration = integrity(&root, &shim, &BTreeMap::new());
1268        assert_ne!(
1269            configuration.fingerprint.configuration,
1270            first.fingerprint.configuration
1271        );
1272
1273        write(&root, "vite.config.ts", "export default {}");
1274        write(&shim, "instrumenter.js", "changed instrumenter");
1275        let instrumenter = integrity(&root, &shim, &BTreeMap::new());
1276        assert_ne!(
1277            instrumenter.fingerprint.instrumenter,
1278            first.fingerprint.instrumenter
1279        );
1280        assert_ne!(
1281            instrumenter.fingerprint.combined,
1282            first.fingerprint.combined
1283        );
1284        fs::remove_dir_all(root).unwrap();
1285        fs::remove_dir_all(shim).unwrap();
1286    }
1287
1288    #[test]
1289    fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
1290        let (root, shim) = fixture();
1291        write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
1292        write(
1293            &root,
1294            "packages/ui/tests/ui.test.ts",
1295            "import assert from 'node:assert/strict'; assert.equal(1, 1);",
1296        );
1297        let before = integrity(&root, &shim, &BTreeMap::new());
1298        for base in [".claude/worktrees/other", "nested-fork"] {
1299            write(
1300                &root,
1301                &format!("{base}/.git"),
1302                "gitdir: /unrelated/repository",
1303            );
1304            write(
1305                &root,
1306                &format!("{base}/tests/other.test.ts"),
1307                "assert.equal(2, 2);",
1308            );
1309            write(&root, &format!("{base}/package.json"), "{}");
1310            write(&root, &format!("{base}/tsconfig.json"), "{}");
1311        }
1312        let after = integrity(&root, &shim, &BTreeMap::new());
1313        assert_eq!(before.fingerprint, after.fingerprint);
1314        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1315        let paths = javascript_assertion_paths(&root, &project).unwrap();
1316        let inputs = crate::assertion_inputs::capture(&root, "javascript", paths).unwrap();
1317        assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
1318        assert!(
1319            !inputs
1320                .files
1321                .keys()
1322                .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
1323        );
1324        fs::remove_dir_all(root).unwrap();
1325        fs::remove_dir_all(shim).unwrap();
1326    }
1327
1328    #[test]
1329    fn a_new_supercov_moves_its_own_identity_and_nothing_else() {
1330        // An upgrade must still stop a merge and bust the build caches, because
1331        // evidence from two different instrumenters is not comparable. It must
1332        // not touch the run's setup, which is what decides whether a stored run
1333        // still matches the checkout.
1334        let (root, shim) = fixture();
1335        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1336        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1337        let mut newer = frontend(&shim);
1338        newer.engine_instrumenter_sha256 = "b".repeat(64);
1339        newer.engine_execution_sha256 = "c".repeat(64);
1340        let upgraded = create_run_integrity(&root, &project, &newer).unwrap();
1341
1342        assert_ne!(
1343            baseline.fingerprint.instrumenter, upgraded.fingerprint.instrumenter,
1344            "a merge and the build caches still have to see this"
1345        );
1346        assert_eq!(
1347            baseline.fingerprint.execution, upgraded.fingerprint.execution,
1348            "the run's setup did not change"
1349        );
1350        assert!(
1351            !compare_run_integrity(Some(&baseline), &upgraded).stale,
1352            "upgrading Supercov must not discard a recorded run"
1353        );
1354        fs::remove_dir_all(root).unwrap();
1355        fs::remove_dir_all(shim).unwrap();
1356    }
1357
1358    #[test]
1359    fn fingerprints_nested_workspace_manifests_and_execution_environment() {
1360        let (root, shim) = fixture();
1361        write(
1362            &root,
1363            "packages/ui/package.json",
1364            r#"{"dependencies":{"react":"1"}}"#,
1365        );
1366        write(&root, "packages/ui/src/index.ts", "export const ui = true");
1367        let first = integrity(&root, &shim, &BTreeMap::new());
1368        write(
1369            &root,
1370            "packages/ui/package.json",
1371            r#"{"dependencies":{"react":"2"}}"#,
1372        );
1373        let dependency = integrity(&root, &shim, &BTreeMap::new());
1374        assert_ne!(
1375            first.fingerprint.dependencies,
1376            dependency.fingerprint.dependencies
1377        );
1378
1379        let mut environment = BTreeMap::new();
1380        environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
1381        let project = discover_coverage_project(&root, &environment, &[]).unwrap();
1382        let mut project_with_build_environment = project.clone();
1383        project_with_build_environment
1384            .build_environment
1385            .insert("MODE".into(), "test".into());
1386        let changed =
1387            create_run_integrity(&root, &project_with_build_environment, &frontend(&shim)).unwrap();
1388        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1389        assert_ne!(
1390            baseline.fingerprint.execution,
1391            changed.fingerprint.execution
1392        );
1393        assert_eq!(baseline.fingerprint.combined, changed.fingerprint.combined);
1394        assert_eq!(
1395            compare_run_integrity(Some(&baseline), &changed).reasons,
1396            ["execution environment changed"]
1397        );
1398        fs::remove_dir_all(root).unwrap();
1399        fs::remove_dir_all(shim).unwrap();
1400    }
1401
1402    #[test]
1403    fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
1404        let root = directory("rust-project");
1405        write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
1406        write(
1407            &root,
1408            "Cargo.toml",
1409            "[package]\nname='fixture'\nversion='0.0.0'\n",
1410        );
1411        let inputs = ExplicitIntegrityInputs {
1412            source_files: vec!["src/lib.rs".into()],
1413            test_files: vec!["src/lib.rs".into()],
1414            dependency_files: vec!["Cargo.toml".into()],
1415            configuration_files: Vec::new(),
1416            execution_configuration: b"cargo\0test".to_vec(),
1417        };
1418        let result = create_explicit_run_integrity(
1419            &root,
1420            &inputs,
1421            &FrontendIntegrityInputs::embedded_rust(),
1422        )
1423        .unwrap();
1424        assert_eq!(result.fingerprint.algorithm, "sha256");
1425        fs::remove_dir_all(root).unwrap();
1426    }
1427
1428    #[cfg(unix)]
1429    #[test]
1430    fn rejects_linked_frontend_identity_files() {
1431        use std::os::unix::fs::symlink;
1432
1433        let (root, shim) = fixture();
1434        let outside = shim.join("outside.js");
1435        fs::write(&outside, "outside").unwrap();
1436        fs::remove_file(shim.join("instrumenter.js")).unwrap();
1437        symlink(&outside, shim.join("instrumenter.js")).unwrap();
1438        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1439        assert!(matches!(
1440            create_run_integrity(&root, &project, &frontend(&shim)),
1441            Err(IntegrityError::UnsafeFile(_))
1442        ));
1443        fs::remove_dir_all(root).unwrap();
1444        fs::remove_dir_all(shim).unwrap();
1445    }
1446}