1use 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}