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
61#[derive(Debug)]
62pub enum IntegrityError {
63    Io { path: PathBuf, source: io::Error },
64    UnsafeFile(PathBuf),
65    NonUtf8Path(PathBuf),
66    OutsideRoot { root: PathBuf, path: PathBuf },
67    InvalidEngineDigest(&'static str),
68}
69
70impl std::fmt::Display for IntegrityError {
71    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
74            Self::UnsafeFile(path) => {
75                write!(
76                    formatter,
77                    "fingerprint input is not a regular file: {}",
78                    path.display()
79                )
80            }
81            Self::NonUtf8Path(path) => {
82                write!(
83                    formatter,
84                    "fingerprint path is not valid UTF-8: {}",
85                    path.display()
86                )
87            }
88            Self::OutsideRoot { root, path } => write!(
89                formatter,
90                "fingerprint input {} is outside {}",
91                path.display(),
92                root.display()
93            ),
94            Self::InvalidEngineDigest(field) => write!(formatter, "invalid {field} SHA-256"),
95        }
96    }
97}
98
99impl std::error::Error for IntegrityError {}
100
101fn io_error(path: &Path, source: io::Error) -> IntegrityError {
102    IntegrityError::Io {
103        path: path.to_owned(),
104        source,
105    }
106}
107
108fn valid_sha256(value: &str) -> bool {
109    value.len() == 64
110        && value
111            .bytes()
112            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
113}
114
115fn local_path(root: &Path, path: &Path) -> Result<String, IntegrityError> {
116    let path = path
117        .strip_prefix(root)
118        .map_err(|_| IntegrityError::OutsideRoot {
119            root: root.to_owned(),
120            path: path.to_owned(),
121        })?;
122    path.components()
123        .map(|component| {
124            component
125                .as_os_str()
126                .to_str()
127                .map(str::to_owned)
128                .ok_or_else(|| IntegrityError::NonUtf8Path(path.to_owned()))
129        })
130        .collect::<Result<Vec<_>, _>>()
131        .map(|parts| parts.join("/"))
132}
133
134fn digest_files(
135    root: &Path,
136    paths: impl IntoIterator<Item = PathBuf>,
137) -> Result<String, IntegrityError> {
138    let paths = paths.into_iter().collect::<BTreeSet<_>>();
139    let mut labeled = paths
140        .into_iter()
141        .map(|path| local_path(root, &path).map(|label| (label, path)))
142        .collect::<Result<Vec<_>, _>>()?;
143    labeled.sort_by(|left, right| left.0.cmp(&right.0));
144    let mut hash = Sha256::new();
145    let mut buffer = [0_u8; 128 * 1024];
146    for (label, path) in labeled {
147        let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
148        if !metadata.file_type().is_file() {
149            return Err(IntegrityError::UnsafeFile(path));
150        }
151        hash.update(label.as_bytes());
152        hash.update([0]);
153        let mut file = fs::File::open(&path).map_err(|source| io_error(&path, source))?;
154        loop {
155            let read = file
156                .read(&mut buffer)
157                .map_err(|source| io_error(&path, source))?;
158            if read == 0 {
159                break;
160            }
161            hash.update(&buffer[..read]);
162        }
163        hash.update([0]);
164    }
165    Ok(format!("{:x}", hash.finalize()))
166}
167
168fn domain_hash(domain: &str, fields: &[(&str, &[u8])]) -> String {
169    let mut hash = Sha256::new();
170    hash.update(domain.as_bytes());
171    hash.update([0]);
172    for (name, value) in fields {
173        hash.update((*name).len().to_le_bytes());
174        hash.update(name.as_bytes());
175        hash.update(value.len().to_le_bytes());
176        hash.update(value);
177    }
178    format!("{:x}", hash.finalize())
179}
180
181fn source_file(path: &Path) -> bool {
182    let lower = path
183        .file_name()
184        .and_then(|name| name.to_str())
185        .unwrap_or("")
186        .to_ascii_lowercase();
187    [
188        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
189        ".mtsx",
190    ]
191    .iter()
192    .any(|extension| lower.ends_with(extension))
193}
194
195fn skipped_directory(name: &str) -> bool {
196    [
197        ".cache",
198        ".git",
199        ".mcdc-pool",
200        ".next",
201        ".nuxt",
202        ".output",
203        ".supercov",
204        "build",
205        "coverage",
206        "dist",
207        "node_modules",
208        "out",
209        "playwright-report",
210        "results",
211        "test-results",
212        "vendor",
213    ]
214    .contains(&name)
215}
216
217fn owned_workspace_store(path: &Path) -> bool {
218    path.file_name().is_some_and(|name| name == "supercov")
219        && path.join(".supercov-workspace-store").is_file()
220}
221
222fn walk_files(
223    directory: &Path,
224    predicate: &impl Fn(&Path) -> bool,
225    output: &mut Vec<PathBuf>,
226) -> Result<(), IntegrityError> {
227    let metadata = match fs::symlink_metadata(directory) {
228        Ok(metadata) => metadata,
229        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
230        Err(source) => return Err(io_error(directory, source)),
231    };
232    if !metadata.file_type().is_dir() {
233        return Err(IntegrityError::UnsafeFile(directory.to_owned()));
234    }
235    let mut entries = fs::read_dir(directory)
236        .map_err(|source| io_error(directory, source))?
237        .collect::<Result<Vec<_>, _>>()
238        .map_err(|source| io_error(directory, source))?;
239    entries.sort_by_key(fs::DirEntry::file_name);
240    for entry in entries {
241        let path = entry.path();
242        let file_type = entry
243            .file_type()
244            .map_err(|source| io_error(&path, source))?;
245        if file_type.is_symlink() {
246            continue;
247        }
248        if file_type.is_dir() {
249            let name = entry.file_name();
250            if !name.to_str().is_some_and(skipped_directory) && !owned_workspace_store(&path) {
251                walk_files(&path, predicate, output)?;
252            }
253        } else if file_type.is_file() && predicate(&path) {
254            output.push(path);
255        }
256    }
257    Ok(())
258}
259
260fn test_file(root: &Path, path: &Path) -> bool {
261    if !source_file(path) {
262        return false;
263    }
264    let local = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
265    local
266        .to_ascii_lowercase()
267        .split(['/', '\\', '_', '.', '-'])
268        .any(|part| matches!(part, "test" | "spec"))
269}
270
271fn test_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
272    let mut files = Vec::new();
273    for directory in ["test", "tests", "__tests__"] {
274        walk_files(&root.join(directory), &source_file, &mut files)?;
275    }
276    walk_files(root, &|path| test_file(root, path), &mut files)?;
277    files.sort();
278    files.dedup();
279    Ok(files)
280}
281
282fn dependency_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
283    let mut files = Vec::new();
284    walk_files(
285        root,
286        &|path| path.file_name().is_some_and(|name| name == "package.json"),
287        &mut files,
288    )?;
289    for name in [
290        "package-lock.json",
291        "npm-shrinkwrap.json",
292        "pnpm-lock.yaml",
293        "yarn.lock",
294        "bun.lock",
295        "bun.lockb",
296    ] {
297        let path = root.join(name);
298        if path.is_file() {
299            files.push(path);
300        }
301    }
302    files.sort();
303    files.dedup();
304    Ok(files)
305}
306
307fn configuration_file(path: &Path) -> bool {
308    let name = path
309        .file_name()
310        .and_then(|name| name.to_str())
311        .unwrap_or("")
312        .to_ascii_lowercase();
313    name == ".npmrc"
314        || name == "supercov.waivers.json"
315        || (name.starts_with("tsconfig") && name.ends_with(".json"))
316        || name.contains(".config.")
317        || name.starts_with(".babelrc.")
318        || name.starts_with(".eslint")
319        || name.starts_with(".prettier")
320}
321
322fn configuration_files(
323    root: &Path,
324    project: &CoverageProject,
325) -> Result<Vec<PathBuf>, IntegrityError> {
326    let mut files = Vec::new();
327    walk_files(root, &configuration_file, &mut files)?;
328    files.extend(
329        [
330            project.playwright_config.as_ref(),
331            project.vitest_config.as_ref(),
332            project.jest_config.as_ref(),
333        ]
334        .into_iter()
335        .flatten()
336        .cloned(),
337    );
338    files.sort();
339    files.dedup();
340    Ok(files)
341}
342
343fn git_integrity(root: &Path) -> Option<GitIntegrity> {
344    let revision = Command::new("git")
345        .args(["rev-parse", "HEAD"])
346        .current_dir(root)
347        .output()
348        .ok();
349    let status = Command::new("git")
350        .args(["status", "--porcelain=v1"])
351        .current_dir(root)
352        .output()
353        .ok();
354    if !revision
355        .as_ref()
356        .is_some_and(|output| output.status.success())
357        && !status
358            .as_ref()
359            .is_some_and(|output| output.status.success())
360    {
361        return None;
362    }
363    Some(GitIntegrity {
364        revision: revision
365            .filter(|output| output.status.success())
366            .and_then(|output| String::from_utf8(output.stdout).ok())
367            .map(|revision| revision.trim().to_owned()),
368        dirty: !status
369            .as_ref()
370            .is_some_and(|output| output.status.success() && output.stdout.is_empty()),
371    })
372}
373
374pub fn create_run_integrity(
375    root: &Path,
376    project: &CoverageProject,
377    frontend: &FrontendIntegrityInputs,
378) -> Result<RunIntegrity, IntegrityError> {
379    if !valid_sha256(&frontend.engine_instrumenter_sha256) {
380        return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
381    }
382    if !valid_sha256(&frontend.engine_execution_sha256) {
383        return Err(IntegrityError::InvalidEngineDigest("execution engine"));
384    }
385    let source_paths = project
386        .source_files
387        .iter()
388        .map(|path| root.join(path))
389        .collect::<Vec<_>>();
390    let tests = test_files(root)?;
391    let dependencies = dependency_files(root)?;
392    let configuration = configuration_files(root, project)?;
393    let source = digest_files(root, source_paths)?;
394    let tests_digest = digest_files(root, tests.iter().cloned())?;
395    let dependency_digest = digest_files(root, dependencies)?;
396    let configuration_digest = digest_files(root, configuration)?;
397    let frontend_instrumenter =
398        digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
399    let frontend_execution =
400        digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
401    let instrumenter = domain_hash(
402        "supercov-run-instrumenter-v1",
403        &[
404            ("language", frontend.language.as_bytes()),
405            ("version", frontend.version.as_bytes()),
406            ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
407            ("shim", frontend_instrumenter.as_bytes()),
408        ],
409    );
410    let build_environment = frontend_map_bytes(&project.build_environment);
411    let execution = domain_hash(
412        "supercov-run-execution-v1",
413        &[
414            ("language", frontend.language.as_bytes()),
415            ("version", frontend.version.as_bytes()),
416            ("source", source.as_bytes()),
417            ("dependencies", dependency_digest.as_bytes()),
418            ("configuration", configuration_digest.as_bytes()),
419            ("buildEnvironment", &build_environment),
420            ("engine", frontend.engine_execution_sha256.as_bytes()),
421            ("shim", frontend_execution.as_bytes()),
422        ],
423    );
424    let combined = domain_hash(
425        "supercov-run-combined-v1",
426        &[
427            ("language", frontend.language.as_bytes()),
428            ("version", frontend.version.as_bytes()),
429            ("source", source.as_bytes()),
430            ("tests", tests_digest.as_bytes()),
431            ("dependencies", dependency_digest.as_bytes()),
432            ("configuration", configuration_digest.as_bytes()),
433            ("instrumenter", instrumenter.as_bytes()),
434        ],
435    );
436    Ok(RunIntegrity {
437        schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
438        instrumenter_version: frontend.version.clone(),
439        git: git_integrity(root),
440        fingerprint: RunFingerprint {
441            algorithm: "sha256".into(),
442            source,
443            tests: tests_digest,
444            dependencies: dependency_digest,
445            configuration: configuration_digest,
446            instrumenter,
447            execution,
448            combined,
449            source_files: project.source_files.len(),
450            test_files: tests.len(),
451        },
452        stale: None,
453        stale_reasons: None,
454    })
455}
456
457fn frontend_map_bytes(values: &std::collections::BTreeMap<String, String>) -> Vec<u8> {
458    let mut bytes = Vec::new();
459    for (key, value) in values {
460        bytes.extend_from_slice(&key.len().to_le_bytes());
461        bytes.extend_from_slice(key.as_bytes());
462        bytes.extend_from_slice(&value.len().to_le_bytes());
463        bytes.extend_from_slice(value.as_bytes());
464    }
465    bytes
466}
467
468#[cfg(test)]
469mod tests {
470    use std::{
471        collections::BTreeMap,
472        fs,
473        sync::atomic::{AtomicU64, Ordering},
474        time::{SystemTime, UNIX_EPOCH},
475    };
476
477    use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
478
479    use super::*;
480
481    static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
482
483    fn directory(label: &str) -> PathBuf {
484        let nonce = SystemTime::now()
485            .duration_since(UNIX_EPOCH)
486            .unwrap()
487            .as_nanos();
488        let root = std::env::temp_dir().join(format!(
489            "supercov-integrity-{label}-{}-{nonce}-{}",
490            std::process::id(),
491            TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
492        ));
493        fs::create_dir_all(&root).unwrap();
494        root
495    }
496
497    fn write(root: &Path, path: &str, contents: &str) {
498        let path = root.join(path);
499        fs::create_dir_all(path.parent().unwrap()).unwrap();
500        fs::write(path, contents).unwrap();
501    }
502
503    fn frontend(root: &Path) -> FrontendIntegrityInputs {
504        FrontendIntegrityInputs {
505            language: "javascript".into(),
506            version: "javascript-v1".into(),
507            root: root.to_owned(),
508            instrumenter_files: vec![root.join("instrumenter.js")],
509            execution_files: vec![root.join("runtime.js")],
510            engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
511            engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
512        }
513    }
514
515    fn fixture() -> (PathBuf, PathBuf) {
516        let root = directory("project");
517        let shim = directory("shim");
518        write(
519            &root,
520            "package.json",
521            r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
522        );
523        write(&root, "package-lock.json", "lock");
524        write(&root, "src/index.ts", "export const ready = true");
525        write(&root, "tests/index.test.ts", "test('ready', () => {})");
526        write(&root, "vite.config.ts", "export default {}");
527        write(&root, ".cache/test262/fake.test.js", "ignored");
528        write(&root, "supercov/.supercov-workspace-store", "owned");
529        write(
530            &root,
531            "supercov/workspace/copy/tests/copied.test.ts",
532            "ignored copied test",
533        );
534        write(&shim, "instrumenter.js", "instrument");
535        write(&shim, "runtime.js", "runtime");
536        (root, shim)
537    }
538
539    fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
540        let project = discover_coverage_project(root, environment, &[]).unwrap();
541        create_run_integrity(root, &project, &frontend(shim)).unwrap()
542    }
543
544    #[test]
545    fn fingerprints_every_independent_input_domain_deterministically() {
546        let (root, shim) = fixture();
547        let first = integrity(&root, &shim, &BTreeMap::new());
548        let second = integrity(&root, &shim, &BTreeMap::new());
549        assert_eq!(first, second);
550        assert_eq!(first.fingerprint.source_files, 1);
551        assert_eq!(first.fingerprint.test_files, 1);
552        for digest in [
553            &first.fingerprint.source,
554            &first.fingerprint.tests,
555            &first.fingerprint.dependencies,
556            &first.fingerprint.configuration,
557            &first.fingerprint.instrumenter,
558            &first.fingerprint.execution,
559            &first.fingerprint.combined,
560        ] {
561            assert!(valid_sha256(digest));
562        }
563
564        write(&root, "src/index.ts", "export const ready = false");
565        let source = integrity(&root, &shim, &BTreeMap::new());
566        assert_ne!(source.fingerprint.source, first.fingerprint.source);
567        assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
568        assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
569
570        write(&root, "src/index.ts", "export const ready = true");
571        write(&root, "tests/index.test.ts", "test('changed', () => {})");
572        let tests = integrity(&root, &shim, &BTreeMap::new());
573        assert_eq!(tests.fingerprint.source, first.fingerprint.source);
574        assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
575        assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
576
577        write(&root, "tests/index.test.ts", "test('ready', () => {})");
578        write(&root, "package-lock.json", "changed lock");
579        let dependencies = integrity(&root, &shim, &BTreeMap::new());
580        assert_ne!(
581            dependencies.fingerprint.dependencies,
582            first.fingerprint.dependencies
583        );
584        assert_ne!(
585            dependencies.fingerprint.execution,
586            first.fingerprint.execution
587        );
588
589        write(&root, "package-lock.json", "lock");
590        write(&root, "vite.config.ts", "export default { changed: true }");
591        let configuration = integrity(&root, &shim, &BTreeMap::new());
592        assert_ne!(
593            configuration.fingerprint.configuration,
594            first.fingerprint.configuration
595        );
596
597        write(&root, "vite.config.ts", "export default {}");
598        write(&shim, "instrumenter.js", "changed instrumenter");
599        let instrumenter = integrity(&root, &shim, &BTreeMap::new());
600        assert_ne!(
601            instrumenter.fingerprint.instrumenter,
602            first.fingerprint.instrumenter
603        );
604        assert_ne!(
605            instrumenter.fingerprint.combined,
606            first.fingerprint.combined
607        );
608        fs::remove_dir_all(root).unwrap();
609        fs::remove_dir_all(shim).unwrap();
610    }
611
612    #[test]
613    fn fingerprints_nested_workspace_manifests_and_execution_environment() {
614        let (root, shim) = fixture();
615        write(
616            &root,
617            "packages/ui/package.json",
618            r#"{"dependencies":{"react":"1"}}"#,
619        );
620        write(&root, "packages/ui/src/index.ts", "export const ui = true");
621        let first = integrity(&root, &shim, &BTreeMap::new());
622        write(
623            &root,
624            "packages/ui/package.json",
625            r#"{"dependencies":{"react":"2"}}"#,
626        );
627        let dependency = integrity(&root, &shim, &BTreeMap::new());
628        assert_ne!(
629            first.fingerprint.dependencies,
630            dependency.fingerprint.dependencies
631        );
632
633        let mut environment = BTreeMap::new();
634        environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
635        let project = discover_coverage_project(&root, &environment, &[]).unwrap();
636        let mut project_with_build_environment = project.clone();
637        project_with_build_environment
638            .build_environment
639            .insert("MODE".into(), "test".into());
640        let changed =
641            create_run_integrity(&root, &project_with_build_environment, &frontend(&shim)).unwrap();
642        let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
643        assert_ne!(
644            baseline.fingerprint.execution,
645            changed.fingerprint.execution
646        );
647        assert_eq!(baseline.fingerprint.combined, changed.fingerprint.combined);
648        assert_eq!(
649            compare_run_integrity(Some(&baseline), &changed).reasons,
650            ["execution environment changed"]
651        );
652        fs::remove_dir_all(root).unwrap();
653        fs::remove_dir_all(shim).unwrap();
654    }
655
656    #[cfg(unix)]
657    #[test]
658    fn rejects_linked_frontend_identity_files() {
659        use std::os::unix::fs::symlink;
660
661        let (root, shim) = fixture();
662        let outside = shim.join("outside.js");
663        fs::write(&outside, "outside").unwrap();
664        fs::remove_file(shim.join("instrumenter.js")).unwrap();
665        symlink(&outside, shim.join("instrumenter.js")).unwrap();
666        let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
667        assert!(matches!(
668            create_run_integrity(&root, &project, &frontend(&shim)),
669            Err(IntegrityError::UnsafeFile(_))
670        ));
671        fs::remove_dir_all(root).unwrap();
672        fs::remove_dir_all(shim).unwrap();
673    }
674}