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 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_go() -> Self {
85 Self {
86 language: "go".into(),
87 version: "go-owned-v1".into(),
88 root: PathBuf::from("."),
89 instrumenter_files: Vec::new(),
90 execution_files: Vec::new(),
91 engine_instrumenter_sha256: env!("SUPERCOV_GO_FRONTEND_SOURCE_SHA256").into(),
92 engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
93 }
94 }
95
96 pub fn embedded_jvm() -> Self {
97 Self {
98 language: "jvm".into(),
99 version: "jvm-owned-v1".into(),
100 root: PathBuf::from("."),
101 instrumenter_files: Vec::new(),
102 execution_files: Vec::new(),
103 engine_instrumenter_sha256: env!("SUPERCOV_JVM_FRONTEND_SOURCE_SHA256").into(),
104 engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
105 }
106 }
107
108 pub fn embedded_ruby() -> Self {
109 Self {
110 language: "ruby".into(),
111 version: "ruby-coverage-v1".into(),
112 root: PathBuf::from("."),
113 instrumenter_files: Vec::new(),
114 execution_files: Vec::new(),
115 engine_instrumenter_sha256: env!("SUPERCOV_RUBY_FRONTEND_SOURCE_SHA256").into(),
116 engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct ExplicitIntegrityInputs {
123 pub source_files: Vec<PathBuf>,
124 pub test_files: Vec<PathBuf>,
125 pub dependency_files: Vec<PathBuf>,
126 pub configuration_files: Vec<PathBuf>,
127 pub execution_configuration: Vec<u8>,
128}
129
130impl ExplicitIntegrityInputs {
131 pub(crate) fn assertion_paths(&self) -> Vec<PathBuf> {
132 self.source_files
133 .iter()
134 .chain(&self.test_files)
135 .chain(&self.dependency_files)
136 .chain(&self.configuration_files)
137 .cloned()
138 .collect()
139 }
140}
141
142pub(crate) fn javascript_assertion_paths(
143 root: &Path,
144 project: &CoverageProject,
145) -> Result<Vec<PathBuf>, IntegrityError> {
146 let mut paths = test_files(root)?;
147 paths.extend(dependency_files(root)?);
148 paths.extend(configuration_files(root, project)?);
149 paths.extend(crate::typescript_imports::config_paths(
150 root,
151 &project.source_files,
152 ));
153 paths.extend(project.source_files.iter().map(|p| root.join(p)));
154 paths.extend(
155 project
156 .source_scope
157 .entries
158 .iter()
159 .filter(|e| !e.is_generated_output())
160 .map(|e| root.join(&e.file)),
161 );
162 Ok(paths)
163}
164
165#[derive(Debug)]
166pub enum IntegrityError {
167 Io { path: PathBuf, source: io::Error },
168 UnsafeFile(PathBuf),
169 NonUtf8Path(PathBuf),
170 OutsideRoot { root: PathBuf, path: PathBuf },
171 InvalidEngineDigest(&'static str),
172}
173
174impl std::fmt::Display for IntegrityError {
175 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 match self {
177 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
178 Self::UnsafeFile(path) => {
179 write!(
180 formatter,
181 "fingerprint input is not a regular file: {}",
182 path.display()
183 )
184 }
185 Self::NonUtf8Path(path) => {
186 write!(
187 formatter,
188 "fingerprint path is not valid UTF-8: {}",
189 path.display()
190 )
191 }
192 Self::OutsideRoot { root, path } => write!(
193 formatter,
194 "fingerprint input {} is outside {}",
195 path.display(),
196 root.display()
197 ),
198 Self::InvalidEngineDigest(field) => write!(formatter, "invalid {field} SHA-256"),
199 }
200 }
201}
202
203impl std::error::Error for IntegrityError {}
204
205fn io_error(path: &Path, source: io::Error) -> IntegrityError {
206 IntegrityError::Io {
207 path: path.to_owned(),
208 source,
209 }
210}
211
212fn valid_sha256(value: &str) -> bool {
213 value.len() == 64
214 && value
215 .bytes()
216 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
217}
218
219fn local_path(root: &Path, path: &Path) -> Result<String, IntegrityError> {
220 let path = path
221 .strip_prefix(root)
222 .map_err(|_| IntegrityError::OutsideRoot {
223 root: root.to_owned(),
224 path: path.to_owned(),
225 })?;
226 path.components()
227 .map(|component| {
228 component
229 .as_os_str()
230 .to_str()
231 .map(str::to_owned)
232 .ok_or_else(|| IntegrityError::NonUtf8Path(path.to_owned()))
233 })
234 .collect::<Result<Vec<_>, _>>()
235 .map(|parts| parts.join("/"))
236}
237
238const RELEASE_METADATA: &[&str] = &[
242 "author",
243 "authors",
244 "bugs",
245 "categories",
246 "classifiers",
247 "contributors",
248 "description",
249 "documentation",
250 "funding",
251 "homepage",
252 "keywords",
253 "license",
254 "license-file",
255 "maintainers",
256 "man",
257 "readme",
258 "repository",
259 "urls",
260 "version",
261];
262
263#[derive(Clone, Copy, PartialEq, Eq)]
264enum ManifestKind {
265 PackageJson,
266 PackageLock,
267 CargoToml,
268 PyprojectToml,
269}
270
271fn manifest_kind(path: &Path) -> Option<ManifestKind> {
272 match path.file_name()?.to_str()? {
273 "package.json" => Some(ManifestKind::PackageJson),
274 "package-lock.json" | "npm-shrinkwrap.json" => Some(ManifestKind::PackageLock),
275 "Cargo.toml" => Some(ManifestKind::CargoToml),
276 "pyproject.toml" => Some(ManifestKind::PyprojectToml),
277 _ => None,
278 }
279}
280
281fn behavioural_manifest(kind: ManifestKind, raw: &[u8]) -> Option<Vec<u8>> {
289 let mut value: serde_json::Value = match kind {
290 ManifestKind::PackageJson | ManifestKind::PackageLock => {
291 serde_json::from_slice(raw).ok()?
292 }
293 ManifestKind::CargoToml | ManifestKind::PyprojectToml => {
294 let text = std::str::from_utf8(raw).ok()?;
295 serde_json::to_value(toml::from_str::<toml::Value>(text).ok()?).ok()?
296 }
297 };
298 match kind {
299 ManifestKind::PackageJson => strip_metadata(&mut value, &[]),
300 ManifestKind::PackageLock => {
304 strip_metadata(&mut value, &[]);
305 strip_metadata(&mut value, &["packages", ""]);
306 }
307 ManifestKind::CargoToml => {
308 strip_metadata(&mut value, &["package"]);
309 strip_metadata(&mut value, &["workspace", "package"]);
310 }
311 ManifestKind::PyprojectToml => {
312 strip_metadata(&mut value, &["project"]);
313 strip_metadata(&mut value, &["tool", "poetry"]);
314 }
315 }
316 let mut bytes = Vec::new();
317 canonical(&value, &mut bytes);
318 Some(bytes)
319}
320
321fn strip_metadata(value: &mut serde_json::Value, path: &[&str]) {
322 let mut table = value;
323 for key in path {
324 match table.get_mut(*key) {
325 Some(next) => table = next,
326 None => return,
327 }
328 }
329 let Some(table) = table.as_object_mut() else {
330 return;
331 };
332 for key in RELEASE_METADATA {
333 table.remove(*key);
334 }
335}
336
337fn canonical(value: &serde_json::Value, out: &mut Vec<u8>) {
340 match value {
341 serde_json::Value::Null => out.push(0),
342 serde_json::Value::Bool(flag) => out.extend([1, u8::from(*flag)]),
343 serde_json::Value::Number(number) => tagged(out, 2, number.to_string().as_bytes()),
344 serde_json::Value::String(text) => tagged(out, 3, text.as_bytes()),
345 serde_json::Value::Array(items) => {
346 tagged(out, 4, &(items.len() as u64).to_le_bytes());
347 for item in items {
348 canonical(item, out);
349 }
350 }
351 serde_json::Value::Object(table) => {
352 let mut keys = table.keys().collect::<Vec<_>>();
353 keys.sort();
354 tagged(out, 5, &(keys.len() as u64).to_le_bytes());
355 for key in keys {
356 tagged(out, 6, key.as_bytes());
357 canonical(&table[key], out);
358 }
359 }
360 }
361}
362
363fn tagged(out: &mut Vec<u8>, tag: u8, bytes: &[u8]) {
364 out.push(tag);
365 out.extend((bytes.len() as u64).to_le_bytes());
366 out.extend(bytes);
367}
368
369fn digest_files(
370 root: &Path,
371 paths: impl IntoIterator<Item = PathBuf>,
372) -> Result<String, IntegrityError> {
373 digest_paths(root, paths, false)
374}
375
376fn digest_manifests(
385 root: &Path,
386 paths: impl IntoIterator<Item = PathBuf>,
387) -> Result<String, IntegrityError> {
388 digest_paths(root, paths, true)
389}
390
391fn digest_paths(
392 root: &Path,
393 paths: impl IntoIterator<Item = PathBuf>,
394 manifests: bool,
395) -> Result<String, IntegrityError> {
396 let paths = paths.into_iter().collect::<BTreeSet<_>>();
397 let mut labeled = paths
398 .into_iter()
399 .map(|path| local_path(root, &path).map(|label| (label, path)))
400 .collect::<Result<Vec<_>, _>>()?;
401 labeled.sort_by(|left, right| left.0.cmp(&right.0));
402 let mut hash = Sha256::new();
403 let mut buffer = [0_u8; 128 * 1024];
404 for (label, path) in labeled {
405 let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
406 if !metadata.file_type().is_file() {
407 return Err(IntegrityError::UnsafeFile(path));
408 }
409 hash.update(label.as_bytes());
410 hash.update([0]);
411 let meaning = manifests
415 .then(|| manifest_kind(&path))
416 .flatten()
417 .and_then(|kind| behavioural_manifest(kind, &fs::read(&path).ok()?));
418 if let Some(bytes) = meaning {
419 hash.update(&bytes);
420 } else {
421 let mut file = fs::File::open(&path).map_err(|source| io_error(&path, source))?;
422 loop {
423 let read = file
424 .read(&mut buffer)
425 .map_err(|source| io_error(&path, source))?;
426 if read == 0 {
427 break;
428 }
429 hash.update(&buffer[..read]);
430 }
431 }
432 hash.update([0]);
433 }
434 Ok(format!("{:x}", hash.finalize()))
435}
436
437fn domain_hash(domain: &str, fields: &[(&str, &[u8])]) -> String {
438 let mut hash = Sha256::new();
439 hash.update(domain.as_bytes());
440 hash.update([0]);
441 for (name, value) in fields {
442 hash.update((*name).len().to_le_bytes());
443 hash.update(name.as_bytes());
444 hash.update(value.len().to_le_bytes());
445 hash.update(value);
446 }
447 format!("{:x}", hash.finalize())
448}
449
450fn source_file(path: &Path) -> bool {
451 let lower = path
452 .file_name()
453 .and_then(|name| name.to_str())
454 .unwrap_or("")
455 .to_ascii_lowercase();
456 [
457 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
458 ".mtsx",
459 ]
460 .iter()
461 .any(|extension| lower.ends_with(extension))
462}
463
464fn skipped_directory(name: &str) -> bool {
465 [
466 ".cache",
467 ".git",
468 ".mcdc-pool",
469 ".next",
470 ".nuxt",
471 ".output",
472 ".supercov",
473 "build",
474 "coverage",
475 "dist",
476 "node_modules",
477 "out",
478 "playwright-report",
479 "results",
480 "test-results",
481 "vendor",
482 ]
483 .contains(&name)
484}
485
486fn owned_workspace_store(path: &Path) -> bool {
487 crate::workspace::owned_workspace_path(path)
488}
489
490fn walk_files(
491 directory: &Path,
492 predicate: &impl Fn(&Path) -> bool,
493 output: &mut Vec<PathBuf>,
494) -> Result<(), IntegrityError> {
495 let metadata = match fs::symlink_metadata(directory) {
496 Ok(metadata) => metadata,
497 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
498 Err(source) => return Err(io_error(directory, source)),
499 };
500 if !metadata.file_type().is_dir() {
501 return Err(IntegrityError::UnsafeFile(directory.to_owned()));
502 }
503 let mut entries = fs::read_dir(directory)
504 .map_err(|source| io_error(directory, source))?
505 .collect::<Result<Vec<_>, _>>()
506 .map_err(|source| io_error(directory, source))?;
507 entries.sort_by_key(fs::DirEntry::file_name);
508 for entry in entries {
509 let path = entry.path();
510 let file_type = entry
511 .file_type()
512 .map_err(|source| io_error(&path, source))?;
513 if file_type.is_symlink() {
514 continue;
515 }
516 if file_type.is_dir() {
517 let name = entry.file_name();
518 if !name
519 .to_str()
520 .is_some_and(|name| name.starts_with('.') || skipped_directory(name))
521 && !path.join(".git").exists()
522 && !owned_workspace_store(&path)
523 {
524 walk_files(&path, predicate, output)?;
525 }
526 } else if file_type.is_file() && predicate(&path) {
527 output.push(path);
528 }
529 }
530 Ok(())
531}
532
533fn test_file(root: &Path, path: &Path) -> bool {
534 if !source_file(path) {
535 return false;
536 }
537 let local = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
538 local
539 .to_ascii_lowercase()
540 .split(['/', '\\', '_', '.', '-'])
541 .any(|part| matches!(part, "test" | "spec"))
542}
543
544fn test_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
545 let mut files = Vec::new();
546 for directory in ["test", "tests", "__tests__"] {
547 walk_files(&root.join(directory), &source_file, &mut files)?;
548 }
549 walk_files(root, &|path| test_file(root, path), &mut files)?;
550 files.sort();
551 files.dedup();
552 Ok(files)
553}
554
555fn dependency_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
556 let mut files = Vec::new();
557 walk_files(
558 root,
559 &|path| path.file_name().is_some_and(|name| name == "package.json"),
560 &mut files,
561 )?;
562 for name in [
563 "package-lock.json",
564 "npm-shrinkwrap.json",
565 "pnpm-lock.yaml",
566 "yarn.lock",
567 "bun.lock",
568 "bun.lockb",
569 ] {
570 let path = root.join(name);
571 if path.is_file() {
572 files.push(path);
573 }
574 }
575 files.sort();
576 files.dedup();
577 Ok(files)
578}
579
580fn configuration_file(path: &Path) -> bool {
581 let name = path
582 .file_name()
583 .and_then(|name| name.to_str())
584 .unwrap_or("")
585 .to_ascii_lowercase();
586 if formatting_only(&name) {
587 return false;
588 }
589 name == ".npmrc"
590 || (name.starts_with("tsconfig") && name.ends_with(".json"))
591 || name.contains(".config.")
592 || name.starts_with(".babelrc.")
593}
594
595fn formatting_only(name: &str) -> bool {
601 let stem = name.strip_prefix('.').unwrap_or(name);
602 stem.starts_with("eslint") || stem.starts_with("prettier")
603}
604
605const TRACKED_MANIFESTS: &[&str] = &[
613 "Cargo.lock",
614 "Cargo.toml",
615 "Gemfile",
616 "Gemfile.lock",
617 "Pipfile",
618 "Pipfile.lock",
619 "bun.lock",
620 "bun.lockb",
621 "npm-shrinkwrap.json",
622 "package-lock.json",
623 "package.json",
624 "pdm.lock",
625 "pnpm-lock.yaml",
626 "poetry.lock",
627 "pyproject.toml",
628 "setup.cfg",
629 "setup.py",
630 "uv.lock",
631 "yarn.lock",
632 ".ruby-version",
633 ".tool-versions",
634];
635
636pub fn tracked_manifest(path: &str) -> bool {
643 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
644 TRACKED_MANIFESTS.contains(&name)
645 || name.ends_with(".gemspec")
646 || (name.starts_with("requirements") && name.ends_with(".txt"))
647}
648
649pub fn globally_tracked(path: &str) -> bool {
650 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
651 tracked_manifest(name) || configuration_file(Path::new(name))
652}
653
654fn configuration_files(
655 root: &Path,
656 project: &CoverageProject,
657) -> Result<Vec<PathBuf>, IntegrityError> {
658 let mut files = Vec::new();
659 walk_files(root, &configuration_file, &mut files)?;
660 files.extend(
661 [
662 project.playwright_config.as_ref(),
663 project.vitest_config.as_ref(),
664 project.jest_config.as_ref(),
665 ]
666 .into_iter()
667 .flatten()
668 .cloned(),
669 );
670 files.sort();
671 files.dedup();
672 Ok(files)
673}
674
675fn git_integrity(root: &Path) -> Option<GitIntegrity> {
676 let revision = Command::new("git")
677 .args(["rev-parse", "HEAD"])
678 .current_dir(root)
679 .output()
680 .ok();
681 let status = Command::new("git")
682 .args(["status", "--porcelain=v1"])
683 .current_dir(root)
684 .output()
685 .ok();
686 if !revision
687 .as_ref()
688 .is_some_and(|output| output.status.success())
689 && !status
690 .as_ref()
691 .is_some_and(|output| output.status.success())
692 {
693 return None;
694 }
695 Some(GitIntegrity {
696 revision: revision
697 .filter(|output| output.status.success())
698 .and_then(|output| String::from_utf8(output.stdout).ok())
699 .map(|revision| revision.trim().to_owned()),
700 dirty: !status
701 .as_ref()
702 .is_some_and(|output| output.status.success() && output.stdout.is_empty()),
703 })
704}
705
706pub fn create_run_integrity(
707 root: &Path,
708 project: &CoverageProject,
709 frontend: &FrontendIntegrityInputs,
710) -> Result<RunIntegrity, IntegrityError> {
711 if !valid_sha256(&frontend.engine_instrumenter_sha256) {
712 return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
713 }
714 if !valid_sha256(&frontend.engine_execution_sha256) {
715 return Err(IntegrityError::InvalidEngineDigest("execution engine"));
716 }
717 let tests = test_files(root)?;
718 let dependencies = dependency_files(root)?;
719 let configuration = configuration_files(root, project)?;
720 let covered_elsewhere = tests
733 .iter()
734 .chain(dependencies.iter())
735 .chain(configuration.iter())
736 .collect::<std::collections::BTreeSet<_>>();
737 let source_paths = project
738 .source_files
739 .iter()
740 .map(|path| root.join(path))
741 .chain(
742 project
743 .source_scope
744 .entries
745 .iter()
746 .filter(|entry| !entry.is_generated_output())
747 .map(|entry| root.join(&entry.file))
748 .filter(|path| !covered_elsewhere.contains(path)),
749 )
750 .collect::<Vec<_>>();
751 let source = digest_files(root, source_paths)?;
752 let tests_digest = digest_files(root, tests.iter().cloned())?;
753 let dependency_digest = digest_manifests(root, dependencies)?;
754 let configuration_digest = digest_files(
755 root,
756 configuration
757 .into_iter()
758 .chain(crate::typescript_imports::config_paths(
759 root,
760 &project.source_files,
761 )),
762 )?;
763 let frontend_instrumenter =
764 digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
765 let frontend_execution =
766 digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
767 let instrumenter = domain_hash(
768 "supercov-run-instrumenter-v1",
769 &[
770 ("language", frontend.language.as_bytes()),
771 ("version", frontend.version.as_bytes()),
772 ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
773 ("shim", frontend_instrumenter.as_bytes()),
774 (
777 "executionEngine",
778 frontend.engine_execution_sha256.as_bytes(),
779 ),
780 ("executionShim", frontend_execution.as_bytes()),
781 ],
782 );
783 let build_environment = frontend_map_bytes(&project.build_environment);
784 let execution = domain_hash(
790 "supercov-run-execution-v1",
791 &[
792 ("language", frontend.language.as_bytes()),
793 ("version", frontend.version.as_bytes()),
794 ("source", source.as_bytes()),
795 ("dependencies", dependency_digest.as_bytes()),
796 ("configuration", configuration_digest.as_bytes()),
797 ("buildEnvironment", &build_environment),
798 ],
799 );
800 let combined = domain_hash(
801 "supercov-run-combined-v1",
802 &[
803 ("language", frontend.language.as_bytes()),
804 ("version", frontend.version.as_bytes()),
805 ("source", source.as_bytes()),
806 ("tests", tests_digest.as_bytes()),
807 ("dependencies", dependency_digest.as_bytes()),
808 ("configuration", configuration_digest.as_bytes()),
809 ("instrumenter", instrumenter.as_bytes()),
810 ],
811 );
812 Ok(RunIntegrity {
813 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
814 instrumenter_version: frontend.version.clone(),
815 git: git_integrity(root),
816 fingerprint: RunFingerprint {
817 algorithm: "sha256".into(),
818 source,
819 tests: tests_digest,
820 dependencies: dependency_digest,
821 configuration: configuration_digest,
822 instrumenter,
823 execution,
824 combined,
825 source_files: project.source_files.len(),
826 test_files: tests.len(),
827 },
828 stale: None,
829 stale_reasons: None,
830 })
831}
832
833pub fn create_explicit_run_integrity(
836 root: &Path,
837 inputs: &ExplicitIntegrityInputs,
838 frontend: &FrontendIntegrityInputs,
839) -> Result<RunIntegrity, IntegrityError> {
840 if !valid_sha256(&frontend.engine_instrumenter_sha256) {
841 return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
842 }
843 if !valid_sha256(&frontend.engine_execution_sha256) {
844 return Err(IntegrityError::InvalidEngineDigest("execution engine"));
845 }
846 let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
847 let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
848 let dependencies = digest_manifests(
849 root,
850 inputs.dependency_files.iter().map(|path| root.join(path)),
851 )?;
852 let configuration = digest_files(
853 root,
854 inputs
855 .configuration_files
856 .iter()
857 .map(|path| root.join(path)),
858 )?;
859 let frontend_instrumenter =
860 digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
861 let frontend_execution =
862 digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
863 let instrumenter = domain_hash(
864 "supercov-run-instrumenter-v1",
865 &[
866 ("language", frontend.language.as_bytes()),
867 ("version", frontend.version.as_bytes()),
868 ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
869 ("shim", frontend_instrumenter.as_bytes()),
870 (
873 "executionEngine",
874 frontend.engine_execution_sha256.as_bytes(),
875 ),
876 ("executionShim", frontend_execution.as_bytes()),
877 ],
878 );
879 let execution = domain_hash(
885 "supercov-run-execution-v1",
886 &[
887 ("language", frontend.language.as_bytes()),
888 ("version", frontend.version.as_bytes()),
889 ("source", source.as_bytes()),
890 ("dependencies", dependencies.as_bytes()),
891 ("configuration", configuration.as_bytes()),
892 ("executionConfiguration", &inputs.execution_configuration),
893 ],
894 );
895 let combined = domain_hash(
896 "supercov-run-combined-v1",
897 &[
898 ("language", frontend.language.as_bytes()),
899 ("version", frontend.version.as_bytes()),
900 ("source", source.as_bytes()),
901 ("tests", tests.as_bytes()),
902 ("dependencies", dependencies.as_bytes()),
903 ("configuration", configuration.as_bytes()),
904 ("instrumenter", instrumenter.as_bytes()),
905 ("execution", execution.as_bytes()),
906 ],
907 );
908 Ok(RunIntegrity {
909 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
910 instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
911 git: git_integrity(root),
912 fingerprint: RunFingerprint {
913 algorithm: "sha256".into(),
917 source,
918 tests,
919 dependencies,
920 configuration,
921 instrumenter,
922 execution,
923 combined,
924 source_files: inputs.source_files.len(),
925 test_files: inputs.test_files.len(),
926 },
927 stale: None,
928 stale_reasons: None,
929 })
930}
931
932fn frontend_map_bytes(values: &std::collections::BTreeMap<String, String>) -> Vec<u8> {
933 let mut bytes = Vec::new();
934 for (key, value) in values {
935 bytes.extend_from_slice(&key.len().to_le_bytes());
936 bytes.extend_from_slice(key.as_bytes());
937 bytes.extend_from_slice(&value.len().to_le_bytes());
938 bytes.extend_from_slice(value.as_bytes());
939 }
940 bytes
941}
942
943#[cfg(test)]
944mod tests {
945 use std::{
946 collections::BTreeMap,
947 fs,
948 sync::atomic::{AtomicU64, Ordering},
949 time::{SystemTime, UNIX_EPOCH},
950 };
951
952 use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
953
954 use super::*;
955
956 static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
957
958 fn directory(label: &str) -> PathBuf {
959 let nonce = SystemTime::now()
960 .duration_since(UNIX_EPOCH)
961 .unwrap()
962 .as_nanos();
963 let root = std::env::temp_dir().join(format!(
964 "supercov-integrity-{label}-{}-{nonce}-{}",
965 std::process::id(),
966 TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
967 ));
968 fs::create_dir_all(&root).unwrap();
969 root
970 }
971
972 const PACKAGE: &str = r#"{"name":"g","version":"1.0.0","dependencies":{"a":"^1.2.3"}}"#;
973 const LOCK: &str = r#"{"name":"g","version":"1.0.0","packages":{"":{"name":"g","version":"1.0.0"},"node_modules/a":{"version":"1.2.3"}}}"#;
974 const CARGO: &str =
975 "[package]\nname = \"g\"\nversion = \"1.0.0\"\n\n[dependencies]\na = \"1.2.3\"\n";
976 const PYPROJECT: &str =
977 "[project]\nname = \"g\"\nversion = \"1.0.0\"\ndependencies = [\"a==1.2.3\"]\n";
978
979 #[test]
980 fn a_release_bump_leaves_a_manifest_digest_alone() {
981 for (kind, before, after) in [
985 (
986 ManifestKind::PackageJson,
987 PACKAGE.to_owned(),
988 PACKAGE.replace("1.0.0", "2.0.0"),
989 ),
990 (
991 ManifestKind::PackageLock,
992 LOCK.to_owned(),
993 LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
994 ),
995 (
996 ManifestKind::CargoToml,
997 CARGO.to_owned(),
998 CARGO.replace("1.0.0", "2.0.0"),
999 ),
1000 (
1001 ManifestKind::PyprojectToml,
1002 PYPROJECT.to_owned(),
1003 PYPROJECT.replace("1.0.0", "2.0.0"),
1004 ),
1005 ] {
1006 let stable = behavioural_manifest(kind, before.as_bytes());
1007 assert!(stable.is_some());
1008 assert_eq!(stable, behavioural_manifest(kind, after.as_bytes()));
1009 }
1010 }
1011
1012 #[test]
1013 fn a_dependency_change_still_moves_a_manifest_digest() {
1014 for (kind, before, after) in [
1017 (
1018 ManifestKind::PackageJson,
1019 PACKAGE.to_owned(),
1020 PACKAGE.replace("^1.2.3", "^2.0.0"),
1021 ),
1022 (
1023 ManifestKind::PackageLock,
1024 LOCK.to_owned(),
1025 LOCK.replace(
1026 "\"node_modules/a\":{\"version\":\"1.2.3\"}",
1027 "\"node_modules/a\":{\"version\":\"9.9.9\"}",
1028 ),
1029 ),
1030 (
1031 ManifestKind::CargoToml,
1032 CARGO.to_owned(),
1033 CARGO.replace("a = \"1.2.3\"", "a = \"9.9.9\""),
1034 ),
1035 (
1036 ManifestKind::PyprojectToml,
1037 PYPROJECT.to_owned(),
1038 PYPROJECT.replace("a==1.2.3", "a==9.9.9"),
1039 ),
1040 ] {
1041 assert_ne!(
1042 behavioural_manifest(kind, before.as_bytes()),
1043 behavioural_manifest(kind, after.as_bytes())
1044 );
1045 }
1046 }
1047
1048 #[test]
1049 fn the_dependency_fingerprint_survives_a_release_but_not_an_upgrade() {
1050 let root = directory("manifest-fingerprint");
1054 let paths = || [root.join("package.json"), root.join("package-lock.json")];
1055 write(&root, "package.json", PACKAGE);
1056 write(&root, "package-lock.json", LOCK);
1057 let before = digest_manifests(&root, paths()).unwrap();
1058
1059 write(&root, "package.json", &PACKAGE.replace("1.0.0", "2.0.0"));
1060 write(
1061 &root,
1062 "package-lock.json",
1063 &LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1064 );
1065 assert_eq!(
1066 before,
1067 digest_manifests(&root, paths()).unwrap(),
1068 "a release must not move the dependency fingerprint"
1069 );
1070
1071 write(&root, "package.json", &PACKAGE.replace("^1.2.3", "^2.0.0"));
1072 assert_ne!(
1073 before,
1074 digest_manifests(&root, paths()).unwrap(),
1075 "an upgrade must still move it"
1076 );
1077 fs::remove_dir_all(root).unwrap();
1078 }
1079
1080 #[test]
1081 fn reformatting_a_manifest_leaves_its_digest_alone() {
1082 let reordered = r#"{"dependencies":{"a":"^1.2.3"}, "version":"1.0.0",
1085 "name":"g"}"#;
1086 assert_eq!(
1087 behavioural_manifest(ManifestKind::PackageJson, PACKAGE.as_bytes()),
1088 behavioural_manifest(ManifestKind::PackageJson, reordered.as_bytes())
1089 );
1090 }
1091
1092 #[test]
1093 fn an_unreadable_manifest_falls_back_to_its_bytes() {
1094 assert!(behavioural_manifest(ManifestKind::PackageJson, b"{ not json").is_none());
1097 assert!(behavioural_manifest(ManifestKind::CargoToml, b"[[[").is_none());
1098 let root = directory("manifest-fallback");
1099 write(&root, "package.json", "{ not json");
1100 let first = digest_manifests(&root, [root.join("package.json")]).unwrap();
1101 write(&root, "package.json", "{ still not json");
1102 assert_ne!(
1103 first,
1104 digest_manifests(&root, [root.join("package.json")]).unwrap()
1105 );
1106 fs::remove_dir_all(root).unwrap();
1107 }
1108
1109 #[test]
1110 fn linter_and_formatter_settings_are_not_execution_context() {
1111 for inert in [
1115 ".prettierrc",
1116 ".prettierrc.json",
1117 "prettier.config.js",
1118 ".eslintrc",
1119 ".eslintrc.json",
1120 "eslint.config.mjs",
1121 ] {
1122 assert!(!configuration_file(Path::new(inert)), "{inert}");
1123 }
1124 for real in ["tsconfig.json", ".babelrc.js", "vite.config.ts", ".npmrc"] {
1125 assert!(configuration_file(Path::new(real)), "{real}");
1126 }
1127 }
1128
1129 #[test]
1130 fn globally_tracked_names_what_the_fingerprint_already_covers() {
1131 for tracked in [
1132 "package-lock.json",
1133 "package.json",
1134 "Cargo.toml",
1135 "Gemfile.lock",
1136 "requirements-dev.txt",
1137 "supercov.gemspec",
1138 "tsconfig.json",
1139 "nested/pyproject.toml",
1140 ] {
1141 assert!(globally_tracked(tracked), "{tracked}");
1142 }
1143 for own in [
1144 "src/index.ts",
1145 "tests/helpers/gateway-process.ts",
1146 "README.md",
1147 ] {
1148 assert!(!globally_tracked(own), "{own}");
1149 }
1150 }
1151
1152 fn write(root: &Path, path: &str, contents: &str) {
1153 let path = root.join(path);
1154 fs::create_dir_all(path.parent().unwrap()).unwrap();
1155 fs::write(path, contents).unwrap();
1156 }
1157
1158 fn frontend(root: &Path) -> FrontendIntegrityInputs {
1159 FrontendIntegrityInputs {
1160 language: "javascript".into(),
1161 version: "javascript-v1".into(),
1162 root: root.to_owned(),
1163 instrumenter_files: vec![root.join("instrumenter.js")],
1164 execution_files: vec![root.join("runtime.mjs")],
1165 engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
1166 engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
1167 }
1168 }
1169
1170 fn fixture() -> (PathBuf, PathBuf) {
1171 let root = directory("project");
1172 let shim = directory("shim");
1173 write(
1174 &root,
1175 "package.json",
1176 r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
1177 );
1178 write(&root, "package-lock.json", "lock");
1179 write(&root, "src/index.ts", "export const ready = true");
1180 write(&root, "tests/index.test.ts", "test('ready', () => {})");
1181 write(&root, "vite.config.ts", "export default {}");
1182 write(&root, ".cache/test262/fake.test.js", "ignored");
1183 write(
1184 &root,
1185 "supercov/.supercov-workspace-store",
1186 "Supercov instrumented workspace. Safe to delete.\n",
1187 );
1188 write(
1189 &root,
1190 "supercov/workspace/copy/tests/copied.test.ts",
1191 "ignored copied test",
1192 );
1193 write(&shim, "instrumenter.js", "instrument");
1194 write(&shim, "runtime.mjs", "runtime");
1195 (root, shim)
1196 }
1197
1198 fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
1199 let project = discover_coverage_project(root, environment, &[]).unwrap();
1200 create_run_integrity(root, &project, &frontend(shim)).unwrap()
1201 }
1202
1203 #[test]
1204 fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
1205 let (root, shim) = fixture();
1209 write(
1210 &root,
1211 "package.json",
1212 r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
1213 );
1214 write(&root, "app_extensions/upsells/package.json", "{}");
1215 write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
1216 write(
1217 &root,
1218 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1219 "bundle one",
1220 );
1221 let first = integrity(&root, &shim, &BTreeMap::new());
1222
1223 fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
1224 write(
1225 &root,
1226 "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
1227 "bundle two",
1228 );
1229 let rebuilt = integrity(&root, &shim, &BTreeMap::new());
1230 assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
1231 assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
1232
1233 write(
1234 &root,
1235 "app_extensions/upsells/frontend/embed.ts",
1236 "edited source",
1237 );
1238 let edited = integrity(&root, &shim, &BTreeMap::new());
1239 assert_ne!(edited.fingerprint.source, first.fingerprint.source);
1240 fs::remove_dir_all(root).unwrap();
1241 fs::remove_dir_all(shim).unwrap();
1242 }
1243
1244 #[test]
1245 fn fingerprints_every_independent_input_domain_deterministically() {
1246 let (root, shim) = fixture();
1247 let first = integrity(&root, &shim, &BTreeMap::new());
1248 let second = integrity(&root, &shim, &BTreeMap::new());
1249 assert_eq!(first, second);
1250 assert_eq!(first.fingerprint.source_files, 1);
1251 assert_eq!(first.fingerprint.test_files, 1);
1252 for digest in [
1253 &first.fingerprint.source,
1254 &first.fingerprint.tests,
1255 &first.fingerprint.dependencies,
1256 &first.fingerprint.configuration,
1257 &first.fingerprint.instrumenter,
1258 &first.fingerprint.execution,
1259 &first.fingerprint.combined,
1260 ] {
1261 assert!(valid_sha256(digest));
1262 }
1263
1264 write(&root, "src/index.ts", "export const ready = false");
1265 let source = integrity(&root, &shim, &BTreeMap::new());
1266 assert_ne!(source.fingerprint.source, first.fingerprint.source);
1267 assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
1268 assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
1269
1270 write(&root, "src/index.ts", "export const ready = true");
1271 write(&root, "tests/index.test.ts", "test('changed', () => {})");
1272 let tests = integrity(&root, &shim, &BTreeMap::new());
1273 assert_eq!(tests.fingerprint.source, first.fingerprint.source);
1274 assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
1275 assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
1276
1277 write(&root, "tests/index.test.ts", "test('ready', () => {})");
1278 write(&root, "package-lock.json", "changed lock");
1279 let dependencies = integrity(&root, &shim, &BTreeMap::new());
1280 assert_ne!(
1281 dependencies.fingerprint.dependencies,
1282 first.fingerprint.dependencies
1283 );
1284 assert_ne!(
1285 dependencies.fingerprint.execution,
1286 first.fingerprint.execution
1287 );
1288
1289 write(&root, "package-lock.json", "lock");
1290 write(&root, "vite.config.ts", "export default { changed: true }");
1291 let configuration = integrity(&root, &shim, &BTreeMap::new());
1292 assert_ne!(
1293 configuration.fingerprint.configuration,
1294 first.fingerprint.configuration
1295 );
1296
1297 write(&root, "vite.config.ts", "export default {}");
1298 write(&shim, "instrumenter.js", "changed instrumenter");
1299 let instrumenter = integrity(&root, &shim, &BTreeMap::new());
1300 assert_ne!(
1301 instrumenter.fingerprint.instrumenter,
1302 first.fingerprint.instrumenter
1303 );
1304 assert_ne!(
1305 instrumenter.fingerprint.combined,
1306 first.fingerprint.combined
1307 );
1308 fs::remove_dir_all(root).unwrap();
1309 fs::remove_dir_all(shim).unwrap();
1310 }
1311
1312 #[test]
1313 fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
1314 let (root, shim) = fixture();
1315 write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
1316 write(
1317 &root,
1318 "packages/ui/tests/ui.test.ts",
1319 "import assert from 'node:assert/strict'; assert.equal(1, 1);",
1320 );
1321 let before = integrity(&root, &shim, &BTreeMap::new());
1322 for base in [".claude/worktrees/other", "nested-fork"] {
1323 write(
1324 &root,
1325 &format!("{base}/.git"),
1326 "gitdir: /unrelated/repository",
1327 );
1328 write(
1329 &root,
1330 &format!("{base}/tests/other.test.ts"),
1331 "assert.equal(2, 2);",
1332 );
1333 write(&root, &format!("{base}/package.json"), "{}");
1334 write(&root, &format!("{base}/tsconfig.json"), "{}");
1335 }
1336 let after = integrity(&root, &shim, &BTreeMap::new());
1337 assert_eq!(before.fingerprint, after.fingerprint);
1338 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1339 let paths = javascript_assertion_paths(&root, &project).unwrap();
1340 let inputs = crate::assertion_inputs::capture(&root, "javascript", paths).unwrap();
1341 assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
1342 assert!(
1343 !inputs
1344 .files
1345 .keys()
1346 .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
1347 );
1348 fs::remove_dir_all(root).unwrap();
1349 fs::remove_dir_all(shim).unwrap();
1350 }
1351
1352 #[test]
1353 fn a_new_supercov_moves_its_own_identity_and_nothing_else() {
1354 let (root, shim) = fixture();
1359 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1360 let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1361 let mut newer = frontend(&shim);
1362 newer.engine_instrumenter_sha256 = "b".repeat(64);
1363 newer.engine_execution_sha256 = "c".repeat(64);
1364 let upgraded = create_run_integrity(&root, &project, &newer).unwrap();
1365
1366 assert_ne!(
1367 baseline.fingerprint.instrumenter, upgraded.fingerprint.instrumenter,
1368 "a merge and the build caches still have to see this"
1369 );
1370 assert_eq!(
1371 baseline.fingerprint.execution, upgraded.fingerprint.execution,
1372 "the run's setup did not change"
1373 );
1374 assert!(
1375 !compare_run_integrity(Some(&baseline), &upgraded).stale,
1376 "upgrading Supercov must not discard a recorded run"
1377 );
1378 fs::remove_dir_all(root).unwrap();
1379 fs::remove_dir_all(shim).unwrap();
1380 }
1381
1382 #[test]
1383 fn fingerprints_nested_workspace_manifests_and_execution_environment() {
1384 let (root, shim) = fixture();
1385 write(
1386 &root,
1387 "packages/ui/package.json",
1388 r#"{"dependencies":{"react":"1"}}"#,
1389 );
1390 write(&root, "packages/ui/src/index.ts", "export const ui = true");
1391 let first = integrity(&root, &shim, &BTreeMap::new());
1392 write(
1393 &root,
1394 "packages/ui/package.json",
1395 r#"{"dependencies":{"react":"2"}}"#,
1396 );
1397 let dependency = integrity(&root, &shim, &BTreeMap::new());
1398 assert_ne!(
1399 first.fingerprint.dependencies,
1400 dependency.fingerprint.dependencies
1401 );
1402
1403 let mut environment = BTreeMap::new();
1404 environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
1405 let project = discover_coverage_project(&root, &environment, &[]).unwrap();
1406 let mut project_with_build_environment = project.clone();
1407 project_with_build_environment
1408 .build_environment
1409 .insert("MODE".into(), "test".into());
1410 let changed =
1411 create_run_integrity(&root, &project_with_build_environment, &frontend(&shim)).unwrap();
1412 let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1413 assert_ne!(
1414 baseline.fingerprint.execution,
1415 changed.fingerprint.execution
1416 );
1417 assert_eq!(baseline.fingerprint.combined, changed.fingerprint.combined);
1418 assert_eq!(
1419 compare_run_integrity(Some(&baseline), &changed).reasons,
1420 ["execution environment changed"]
1421 );
1422 fs::remove_dir_all(root).unwrap();
1423 fs::remove_dir_all(shim).unwrap();
1424 }
1425
1426 #[test]
1427 fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
1428 let root = directory("rust-project");
1429 write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
1430 write(
1431 &root,
1432 "Cargo.toml",
1433 "[package]\nname='fixture'\nversion='0.0.0'\n",
1434 );
1435 let inputs = ExplicitIntegrityInputs {
1436 source_files: vec!["src/lib.rs".into()],
1437 test_files: vec!["src/lib.rs".into()],
1438 dependency_files: vec!["Cargo.toml".into()],
1439 configuration_files: Vec::new(),
1440 execution_configuration: b"cargo\0test".to_vec(),
1441 };
1442 let result = create_explicit_run_integrity(
1443 &root,
1444 &inputs,
1445 &FrontendIntegrityInputs::embedded_rust(),
1446 )
1447 .unwrap();
1448 assert_eq!(result.fingerprint.algorithm, "sha256");
1449 fs::remove_dir_all(root).unwrap();
1450 }
1451
1452 #[cfg(unix)]
1453 #[test]
1454 fn rejects_linked_frontend_identity_files() {
1455 use std::os::unix::fs::symlink;
1456
1457 let (root, shim) = fixture();
1458 let outside = shim.join("outside.js");
1459 fs::write(&outside, "outside").unwrap();
1460 fs::remove_file(shim.join("instrumenter.js")).unwrap();
1461 symlink(&outside, shim.join("instrumenter.js")).unwrap();
1462 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1463 assert!(matches!(
1464 create_run_integrity(&root, &project, &frontend(&shim)),
1465 Err(IntegrityError::UnsafeFile(_))
1466 ));
1467 fs::remove_dir_all(root).unwrap();
1468 fs::remove_dir_all(shim).unwrap();
1469 }
1470}