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: crate::python_evidence::PYTHON_FRONTEND_VERSION.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
376pub fn digest_source_files(
386 root: &Path,
387 paths: impl IntoIterator<Item = PathBuf>,
388) -> Result<String, IntegrityError> {
389 digest_files(root, paths)
390}
391
392fn digest_manifests(
401 root: &Path,
402 paths: impl IntoIterator<Item = PathBuf>,
403) -> Result<String, IntegrityError> {
404 digest_paths(root, paths, true)
405}
406
407fn digest_paths(
408 root: &Path,
409 paths: impl IntoIterator<Item = PathBuf>,
410 manifests: bool,
411) -> Result<String, IntegrityError> {
412 let paths = paths.into_iter().collect::<BTreeSet<_>>();
413 let mut labeled = paths
414 .into_iter()
415 .map(|path| local_path(root, &path).map(|label| (label, path)))
416 .collect::<Result<Vec<_>, _>>()?;
417 labeled.sort_by(|left, right| left.0.cmp(&right.0));
418 let mut hash = Sha256::new();
419 let mut buffer = [0_u8; 128 * 1024];
420 for (label, path) in labeled {
421 let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
422 if !metadata.file_type().is_file() {
423 return Err(IntegrityError::UnsafeFile(path));
424 }
425 hash.update(label.as_bytes());
426 hash.update([0]);
427 let meaning = manifests
431 .then(|| manifest_kind(&path))
432 .flatten()
433 .and_then(|kind| behavioural_manifest(kind, &fs::read(&path).ok()?));
434 if let Some(bytes) = meaning {
435 hash.update(&bytes);
436 } else {
437 let mut file = fs::File::open(&path).map_err(|source| io_error(&path, source))?;
438 loop {
439 let read = file
440 .read(&mut buffer)
441 .map_err(|source| io_error(&path, source))?;
442 if read == 0 {
443 break;
444 }
445 hash.update(&buffer[..read]);
446 }
447 }
448 hash.update([0]);
449 }
450 Ok(format!("{:x}", hash.finalize()))
451}
452
453fn domain_hash(domain: &str, fields: &[(&str, &[u8])]) -> String {
454 let mut hash = Sha256::new();
455 hash.update(domain.as_bytes());
456 hash.update([0]);
457 for (name, value) in fields {
458 hash.update((*name).len().to_le_bytes());
459 hash.update(name.as_bytes());
460 hash.update(value.len().to_le_bytes());
461 hash.update(value);
462 }
463 format!("{:x}", hash.finalize())
464}
465
466fn source_file(path: &Path) -> bool {
467 let lower = path
468 .file_name()
469 .and_then(|name| name.to_str())
470 .unwrap_or("")
471 .to_ascii_lowercase();
472 [
473 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
474 ".mtsx",
475 ]
476 .iter()
477 .any(|extension| lower.ends_with(extension))
478}
479
480fn skipped_directory(name: &str) -> bool {
481 [
482 ".cache",
483 ".git",
484 ".mcdc-pool",
485 ".next",
486 ".nuxt",
487 ".output",
488 ".supercov",
489 "build",
490 "coverage",
491 "dist",
492 "node_modules",
493 "out",
494 "playwright-report",
495 "results",
496 "test-results",
497 "vendor",
498 ]
499 .contains(&name)
500}
501
502fn owned_workspace_store(path: &Path) -> bool {
503 crate::workspace::owned_workspace_path(path)
504}
505
506fn walk_files(
507 directory: &Path,
508 predicate: &impl Fn(&Path) -> bool,
509 output: &mut Vec<PathBuf>,
510) -> Result<(), IntegrityError> {
511 let metadata = match fs::symlink_metadata(directory) {
512 Ok(metadata) => metadata,
513 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
514 Err(source) => return Err(io_error(directory, source)),
515 };
516 if !metadata.file_type().is_dir() {
517 return Err(IntegrityError::UnsafeFile(directory.to_owned()));
518 }
519 let mut entries = fs::read_dir(directory)
520 .map_err(|source| io_error(directory, source))?
521 .collect::<Result<Vec<_>, _>>()
522 .map_err(|source| io_error(directory, source))?;
523 entries.sort_by_key(fs::DirEntry::file_name);
524 for entry in entries {
525 let path = entry.path();
526 let file_type = entry
527 .file_type()
528 .map_err(|source| io_error(&path, source))?;
529 if file_type.is_symlink() {
530 continue;
531 }
532 if file_type.is_dir() {
533 let name = entry.file_name();
534 if !name
535 .to_str()
536 .is_some_and(|name| name.starts_with('.') || skipped_directory(name))
537 && !path.join(".git").exists()
538 && !owned_workspace_store(&path)
539 {
540 walk_files(&path, predicate, output)?;
541 }
542 } else if file_type.is_file() && predicate(&path) {
543 output.push(path);
544 }
545 }
546 Ok(())
547}
548
549fn test_file(root: &Path, path: &Path) -> bool {
550 if !source_file(path) {
551 return false;
552 }
553 let local = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
554 local
555 .to_ascii_lowercase()
556 .split(['/', '\\', '_', '.', '-'])
557 .any(|part| matches!(part, "test" | "spec"))
558}
559
560fn test_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
561 let mut files = Vec::new();
562 for directory in ["test", "tests", "__tests__"] {
563 walk_files(&root.join(directory), &source_file, &mut files)?;
564 }
565 walk_files(root, &|path| test_file(root, path), &mut files)?;
566 files.sort();
567 files.dedup();
568 Ok(files)
569}
570
571fn dependency_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
572 let mut files = Vec::new();
573 walk_files(
574 root,
575 &|path| path.file_name().is_some_and(|name| name == "package.json"),
576 &mut files,
577 )?;
578 for name in [
579 "package-lock.json",
580 "npm-shrinkwrap.json",
581 "pnpm-lock.yaml",
582 "yarn.lock",
583 "bun.lock",
584 "bun.lockb",
585 ] {
586 let path = root.join(name);
587 if path.is_file() {
588 files.push(path);
589 }
590 }
591 files.sort();
592 files.dedup();
593 Ok(files)
594}
595
596fn configuration_file(path: &Path) -> bool {
597 let name = path
598 .file_name()
599 .and_then(|name| name.to_str())
600 .unwrap_or("")
601 .to_ascii_lowercase();
602 if formatting_only(&name) {
603 return false;
604 }
605 name == ".npmrc"
606 || (name.starts_with("tsconfig") && name.ends_with(".json"))
607 || name.contains(".config.")
608 || name.starts_with(".babelrc.")
609}
610
611fn formatting_only(name: &str) -> bool {
617 let stem = name.strip_prefix('.').unwrap_or(name);
618 stem.starts_with("eslint") || stem.starts_with("prettier")
619}
620
621const TRACKED_MANIFESTS: &[&str] = &[
629 "Cargo.lock",
630 "Cargo.toml",
631 "Gemfile",
632 "Gemfile.lock",
633 "Pipfile",
634 "Pipfile.lock",
635 "bun.lock",
636 "bun.lockb",
637 "npm-shrinkwrap.json",
638 "package-lock.json",
639 "package.json",
640 "pdm.lock",
641 "pnpm-lock.yaml",
642 "poetry.lock",
643 "pyproject.toml",
644 "setup.cfg",
645 "setup.py",
646 "uv.lock",
647 "yarn.lock",
648 ".ruby-version",
649 ".tool-versions",
650];
651
652pub fn tracked_manifest(path: &str) -> bool {
659 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
660 TRACKED_MANIFESTS.contains(&name)
661 || name.ends_with(".gemspec")
662 || (name.starts_with("requirements") && name.ends_with(".txt"))
663}
664
665pub fn globally_tracked(path: &str) -> bool {
666 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
667 tracked_manifest(name) || configuration_file(Path::new(name))
668}
669
670fn configuration_files(
671 root: &Path,
672 project: &CoverageProject,
673) -> Result<Vec<PathBuf>, IntegrityError> {
674 let mut files = Vec::new();
675 walk_files(root, &configuration_file, &mut files)?;
676 files.extend(
677 [
678 project.playwright_config.as_ref(),
679 project.vitest_config.as_ref(),
680 project.jest_config.as_ref(),
681 ]
682 .into_iter()
683 .flatten()
684 .cloned(),
685 );
686 files.sort();
687 files.dedup();
688 Ok(files)
689}
690
691fn git_integrity(root: &Path) -> Option<GitIntegrity> {
692 let revision = Command::new("git")
693 .args(["rev-parse", "HEAD"])
694 .current_dir(root)
695 .output()
696 .ok();
697 let status = Command::new("git")
698 .args(["status", "--porcelain=v1"])
699 .current_dir(root)
700 .output()
701 .ok();
702 if !revision
703 .as_ref()
704 .is_some_and(|output| output.status.success())
705 && !status
706 .as_ref()
707 .is_some_and(|output| output.status.success())
708 {
709 return None;
710 }
711 Some(GitIntegrity {
712 branch: Command::new("git")
713 .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
714 .current_dir(root)
715 .output()
716 .ok()
717 .filter(|output| output.status.success())
718 .and_then(|output| String::from_utf8(output.stdout).ok())
719 .map(|branch| branch.trim().to_owned())
720 .filter(|branch| !branch.is_empty()),
721 revision: revision
722 .filter(|output| output.status.success())
723 .and_then(|output| String::from_utf8(output.stdout).ok())
724 .map(|revision| revision.trim().to_owned()),
725 dirty: !status
726 .as_ref()
727 .is_some_and(|output| output.status.success() && output.stdout.is_empty()),
728 })
729}
730
731pub fn create_run_integrity(
732 root: &Path,
733 project: &CoverageProject,
734 frontend: &FrontendIntegrityInputs,
735) -> Result<RunIntegrity, IntegrityError> {
736 if !valid_sha256(&frontend.engine_instrumenter_sha256) {
737 return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
738 }
739 if !valid_sha256(&frontend.engine_execution_sha256) {
740 return Err(IntegrityError::InvalidEngineDigest("execution engine"));
741 }
742 let tests = test_files(root)?;
743 let dependencies = dependency_files(root)?;
744 let configuration = configuration_files(root, project)?;
745 let covered_elsewhere = tests
758 .iter()
759 .chain(dependencies.iter())
760 .chain(configuration.iter())
761 .collect::<std::collections::BTreeSet<_>>();
762 let source_paths = project
763 .source_files
764 .iter()
765 .map(|path| root.join(path))
766 .chain(
767 project
768 .source_scope
769 .entries
770 .iter()
771 .filter(|entry| !entry.is_generated_output())
772 .map(|entry| root.join(&entry.file))
773 .filter(|path| !covered_elsewhere.contains(path)),
774 )
775 .collect::<Vec<_>>();
776 let source = digest_files(root, source_paths)?;
777 let tests_digest = digest_files(root, tests.iter().cloned())?;
778 let dependency_digest = digest_manifests(root, dependencies)?;
779 let configuration_digest = digest_files(
780 root,
781 configuration
782 .into_iter()
783 .chain(crate::typescript_imports::config_paths(
784 root,
785 &project.source_files,
786 )),
787 )?;
788 let frontend_instrumenter =
789 digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
790 let frontend_execution =
791 digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
792 let instrumenter = domain_hash(
793 "supercov-run-instrumenter-v1",
794 &[
795 ("language", frontend.language.as_bytes()),
796 ("version", frontend.version.as_bytes()),
797 ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
798 ("shim", frontend_instrumenter.as_bytes()),
799 (
802 "executionEngine",
803 frontend.engine_execution_sha256.as_bytes(),
804 ),
805 ("executionShim", frontend_execution.as_bytes()),
806 ],
807 );
808 let execution = domain_hash(
814 "supercov-run-execution-v1",
815 &[
816 ("language", frontend.language.as_bytes()),
817 ("version", frontend.version.as_bytes()),
818 ("source", source.as_bytes()),
819 ("dependencies", dependency_digest.as_bytes()),
820 ("configuration", configuration_digest.as_bytes()),
821 ("buildEnvironment", &[]),
825 ],
826 );
827 let combined = domain_hash(
828 "supercov-run-combined-v1",
829 &[
830 ("language", frontend.language.as_bytes()),
831 ("version", frontend.version.as_bytes()),
832 ("source", source.as_bytes()),
833 ("tests", tests_digest.as_bytes()),
834 ("dependencies", dependency_digest.as_bytes()),
835 ("configuration", configuration_digest.as_bytes()),
836 ("instrumenter", instrumenter.as_bytes()),
837 ],
838 );
839 Ok(RunIntegrity {
840 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
841 instrumenter_version: frontend.version.clone(),
842 git: git_integrity(root),
843 fingerprint: RunFingerprint {
844 algorithm: "sha256".into(),
845 source,
846 tests: tests_digest,
847 dependencies: dependency_digest,
848 configuration: configuration_digest,
849 instrumenter,
850 execution,
851 combined,
852 source_files: project.source_files.len(),
853 test_files: tests.len(),
854 },
855 stale: None,
856 stale_reasons: None,
857 })
858}
859
860pub fn create_explicit_run_integrity(
863 root: &Path,
864 inputs: &ExplicitIntegrityInputs,
865 frontend: &FrontendIntegrityInputs,
866) -> Result<RunIntegrity, IntegrityError> {
867 if !valid_sha256(&frontend.engine_instrumenter_sha256) {
868 return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
869 }
870 if !valid_sha256(&frontend.engine_execution_sha256) {
871 return Err(IntegrityError::InvalidEngineDigest("execution engine"));
872 }
873 let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
874 let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
875 let dependencies = digest_manifests(
876 root,
877 inputs.dependency_files.iter().map(|path| root.join(path)),
878 )?;
879 let configuration = digest_files(
880 root,
881 inputs
882 .configuration_files
883 .iter()
884 .map(|path| root.join(path)),
885 )?;
886 let frontend_instrumenter =
887 digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
888 let frontend_execution =
889 digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
890 let instrumenter = domain_hash(
891 "supercov-run-instrumenter-v1",
892 &[
893 ("language", frontend.language.as_bytes()),
894 ("version", frontend.version.as_bytes()),
895 ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
896 ("shim", frontend_instrumenter.as_bytes()),
897 (
900 "executionEngine",
901 frontend.engine_execution_sha256.as_bytes(),
902 ),
903 ("executionShim", frontend_execution.as_bytes()),
904 ],
905 );
906 let execution = domain_hash(
912 "supercov-run-execution-v1",
913 &[
914 ("language", frontend.language.as_bytes()),
915 ("version", frontend.version.as_bytes()),
916 ("source", source.as_bytes()),
917 ("dependencies", dependencies.as_bytes()),
918 ("configuration", configuration.as_bytes()),
919 ("executionConfiguration", &inputs.execution_configuration),
920 ],
921 );
922 let combined = domain_hash(
923 "supercov-run-combined-v1",
924 &[
925 ("language", frontend.language.as_bytes()),
926 ("version", frontend.version.as_bytes()),
927 ("source", source.as_bytes()),
928 ("tests", tests.as_bytes()),
929 ("dependencies", dependencies.as_bytes()),
930 ("configuration", configuration.as_bytes()),
931 ("instrumenter", instrumenter.as_bytes()),
932 ("execution", execution.as_bytes()),
933 ],
934 );
935 Ok(RunIntegrity {
936 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
937 instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
938 git: git_integrity(root),
939 fingerprint: RunFingerprint {
940 algorithm: "sha256".into(),
944 source,
945 tests,
946 dependencies,
947 configuration,
948 instrumenter,
949 execution,
950 combined,
951 source_files: inputs.source_files.len(),
952 test_files: inputs.test_files.len(),
953 },
954 stale: None,
955 stale_reasons: None,
956 })
957}
958
959#[cfg(test)]
960mod tests {
961 use std::{
962 collections::BTreeMap,
963 fs,
964 sync::atomic::{AtomicU64, Ordering},
965 time::{SystemTime, UNIX_EPOCH},
966 };
967
968 use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
969
970 use super::*;
971
972 static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
973
974 fn directory(label: &str) -> PathBuf {
975 let nonce = SystemTime::now()
976 .duration_since(UNIX_EPOCH)
977 .unwrap()
978 .as_nanos();
979 let root = std::env::temp_dir().join(format!(
980 "supercov-integrity-{label}-{}-{nonce}-{}",
981 std::process::id(),
982 TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
983 ));
984 fs::create_dir_all(&root).unwrap();
985 root
986 }
987
988 #[test]
989 fn git_context_records_branch_and_local_changes_without_guessing_detached_heads() {
990 let root = directory("git-context");
991 assert!(git_integrity(&root).is_none());
992 let git = |args: &[&str]| {
993 let output = Command::new("git")
994 .args(args)
995 .current_dir(&root)
996 .output()
997 .unwrap();
998 assert!(
999 output.status.success(),
1000 "{}",
1001 String::from_utf8_lossy(&output.stderr)
1002 );
1003 };
1004 git(&["init", "--initial-branch=report-test"]);
1005 git(&[
1006 "-c",
1007 "user.name=Report Test",
1008 "-c",
1009 "user.email=report@example.invalid",
1010 "commit",
1011 "--allow-empty",
1012 "-m",
1013 "fixture",
1014 ]);
1015 let clean = git_integrity(&root).unwrap();
1016 assert_eq!(clean.branch.as_deref(), Some("report-test"));
1017 assert!(!clean.dirty);
1018 fs::write(root.join("local.txt"), "uncommitted").unwrap();
1019 let dirty = git_integrity(&root).unwrap();
1020 assert!(dirty.dirty);
1021 assert_eq!(dirty.revision, clean.revision);
1022 git(&["checkout", "--detach"]);
1023 let detached = git_integrity(&root).unwrap();
1024 assert!(detached.branch.is_none());
1025 assert_eq!(detached.revision, clean.revision);
1026 let old: GitIntegrity = serde_json::from_value(serde_json::json!({
1028 "revision": clean.revision, "dirty": false
1029 }))
1030 .unwrap();
1031 assert!(old.branch.is_none());
1032 fs::remove_dir_all(root).unwrap();
1033 }
1034
1035 const PACKAGE: &str = r#"{"name":"g","version":"1.0.0","dependencies":{"a":"^1.2.3"}}"#;
1036 const LOCK: &str = r#"{"name":"g","version":"1.0.0","packages":{"":{"name":"g","version":"1.0.0"},"node_modules/a":{"version":"1.2.3"}}}"#;
1037 const CARGO: &str =
1038 "[package]\nname = \"g\"\nversion = \"1.0.0\"\n\n[dependencies]\na = \"1.2.3\"\n";
1039 const PYPROJECT: &str =
1040 "[project]\nname = \"g\"\nversion = \"1.0.0\"\ndependencies = [\"a==1.2.3\"]\n";
1041
1042 #[test]
1043 fn a_release_bump_leaves_a_manifest_digest_alone() {
1044 for (kind, before, after) in [
1048 (
1049 ManifestKind::PackageJson,
1050 PACKAGE.to_owned(),
1051 PACKAGE.replace("1.0.0", "2.0.0"),
1052 ),
1053 (
1054 ManifestKind::PackageLock,
1055 LOCK.to_owned(),
1056 LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1057 ),
1058 (
1059 ManifestKind::CargoToml,
1060 CARGO.to_owned(),
1061 CARGO.replace("1.0.0", "2.0.0"),
1062 ),
1063 (
1064 ManifestKind::PyprojectToml,
1065 PYPROJECT.to_owned(),
1066 PYPROJECT.replace("1.0.0", "2.0.0"),
1067 ),
1068 ] {
1069 let stable = behavioural_manifest(kind, before.as_bytes());
1070 assert!(stable.is_some());
1071 assert_eq!(stable, behavioural_manifest(kind, after.as_bytes()));
1072 }
1073 }
1074
1075 #[test]
1076 fn a_dependency_change_still_moves_a_manifest_digest() {
1077 for (kind, before, after) in [
1080 (
1081 ManifestKind::PackageJson,
1082 PACKAGE.to_owned(),
1083 PACKAGE.replace("^1.2.3", "^2.0.0"),
1084 ),
1085 (
1086 ManifestKind::PackageLock,
1087 LOCK.to_owned(),
1088 LOCK.replace(
1089 "\"node_modules/a\":{\"version\":\"1.2.3\"}",
1090 "\"node_modules/a\":{\"version\":\"9.9.9\"}",
1091 ),
1092 ),
1093 (
1094 ManifestKind::CargoToml,
1095 CARGO.to_owned(),
1096 CARGO.replace("a = \"1.2.3\"", "a = \"9.9.9\""),
1097 ),
1098 (
1099 ManifestKind::PyprojectToml,
1100 PYPROJECT.to_owned(),
1101 PYPROJECT.replace("a==1.2.3", "a==9.9.9"),
1102 ),
1103 ] {
1104 assert_ne!(
1105 behavioural_manifest(kind, before.as_bytes()),
1106 behavioural_manifest(kind, after.as_bytes())
1107 );
1108 }
1109 }
1110
1111 #[test]
1112 fn the_dependency_fingerprint_survives_a_release_but_not_an_upgrade() {
1113 let root = directory("manifest-fingerprint");
1117 let paths = || [root.join("package.json"), root.join("package-lock.json")];
1118 write(&root, "package.json", PACKAGE);
1119 write(&root, "package-lock.json", LOCK);
1120 let before = digest_manifests(&root, paths()).unwrap();
1121
1122 write(&root, "package.json", &PACKAGE.replace("1.0.0", "2.0.0"));
1123 write(
1124 &root,
1125 "package-lock.json",
1126 &LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1127 );
1128 assert_eq!(
1129 before,
1130 digest_manifests(&root, paths()).unwrap(),
1131 "a release must not move the dependency fingerprint"
1132 );
1133
1134 write(&root, "package.json", &PACKAGE.replace("^1.2.3", "^2.0.0"));
1135 assert_ne!(
1136 before,
1137 digest_manifests(&root, paths()).unwrap(),
1138 "an upgrade must still move it"
1139 );
1140 fs::remove_dir_all(root).unwrap();
1141 }
1142
1143 #[test]
1144 fn reformatting_a_manifest_leaves_its_digest_alone() {
1145 let reordered = r#"{"dependencies":{"a":"^1.2.3"}, "version":"1.0.0",
1148 "name":"g"}"#;
1149 assert_eq!(
1150 behavioural_manifest(ManifestKind::PackageJson, PACKAGE.as_bytes()),
1151 behavioural_manifest(ManifestKind::PackageJson, reordered.as_bytes())
1152 );
1153 }
1154
1155 #[test]
1156 fn an_unreadable_manifest_falls_back_to_its_bytes() {
1157 assert!(behavioural_manifest(ManifestKind::PackageJson, b"{ not json").is_none());
1160 assert!(behavioural_manifest(ManifestKind::CargoToml, b"[[[").is_none());
1161 let root = directory("manifest-fallback");
1162 write(&root, "package.json", "{ not json");
1163 let first = digest_manifests(&root, [root.join("package.json")]).unwrap();
1164 write(&root, "package.json", "{ still not json");
1165 assert_ne!(
1166 first,
1167 digest_manifests(&root, [root.join("package.json")]).unwrap()
1168 );
1169 fs::remove_dir_all(root).unwrap();
1170 }
1171
1172 #[test]
1173 fn linter_and_formatter_settings_are_not_execution_context() {
1174 for inert in [
1178 ".prettierrc",
1179 ".prettierrc.json",
1180 "prettier.config.js",
1181 ".eslintrc",
1182 ".eslintrc.json",
1183 "eslint.config.mjs",
1184 ] {
1185 assert!(!configuration_file(Path::new(inert)), "{inert}");
1186 }
1187 for real in ["tsconfig.json", ".babelrc.js", "vite.config.ts", ".npmrc"] {
1188 assert!(configuration_file(Path::new(real)), "{real}");
1189 }
1190 }
1191
1192 #[test]
1193 fn globally_tracked_names_what_the_fingerprint_already_covers() {
1194 for tracked in [
1195 "package-lock.json",
1196 "package.json",
1197 "Cargo.toml",
1198 "Gemfile.lock",
1199 "requirements-dev.txt",
1200 "supercov.gemspec",
1201 "tsconfig.json",
1202 "nested/pyproject.toml",
1203 ] {
1204 assert!(globally_tracked(tracked), "{tracked}");
1205 }
1206 for own in [
1207 "src/index.ts",
1208 "tests/helpers/gateway-process.ts",
1209 "README.md",
1210 ] {
1211 assert!(!globally_tracked(own), "{own}");
1212 }
1213 }
1214
1215 fn write(root: &Path, path: &str, contents: &str) {
1216 let path = root.join(path);
1217 fs::create_dir_all(path.parent().unwrap()).unwrap();
1218 fs::write(path, contents).unwrap();
1219 }
1220
1221 fn frontend(root: &Path) -> FrontendIntegrityInputs {
1222 FrontendIntegrityInputs {
1223 language: "javascript".into(),
1224 version: "javascript-v1".into(),
1225 root: root.to_owned(),
1226 instrumenter_files: vec![root.join("instrumenter.js")],
1227 execution_files: vec![root.join("runtime.mjs")],
1228 engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
1229 engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
1230 }
1231 }
1232
1233 fn fixture() -> (PathBuf, PathBuf) {
1234 let root = directory("project");
1235 let shim = directory("shim");
1236 write(
1237 &root,
1238 "package.json",
1239 r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
1240 );
1241 write(&root, "package-lock.json", "lock");
1242 write(&root, "src/index.ts", "export const ready = true");
1243 write(&root, "tests/index.test.ts", "test('ready', () => {})");
1244 write(&root, "vite.config.ts", "export default {}");
1245 write(&root, ".cache/test262/fake.test.js", "ignored");
1246 write(
1247 &root,
1248 "supercov/.supercov-workspace-store",
1249 "Supercov instrumented workspace. Safe to delete.\n",
1250 );
1251 write(
1252 &root,
1253 "supercov/workspace/copy/tests/copied.test.ts",
1254 "ignored copied test",
1255 );
1256 write(&shim, "instrumenter.js", "instrument");
1257 write(&shim, "runtime.mjs", "runtime");
1258 (root, shim)
1259 }
1260
1261 fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
1262 let project = discover_coverage_project(root, environment, &[]).unwrap();
1263 create_run_integrity(root, &project, &frontend(shim)).unwrap()
1264 }
1265
1266 #[test]
1267 fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
1268 let (root, shim) = fixture();
1272 write(
1273 &root,
1274 "package.json",
1275 r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
1276 );
1277 write(&root, "app_extensions/upsells/package.json", "{}");
1278 write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
1279 write(
1280 &root,
1281 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1282 "bundle one",
1283 );
1284 let first = integrity(&root, &shim, &BTreeMap::new());
1285
1286 fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
1287 write(
1288 &root,
1289 "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
1290 "bundle two",
1291 );
1292 let rebuilt = integrity(&root, &shim, &BTreeMap::new());
1293 assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
1294 assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
1295
1296 write(
1297 &root,
1298 "app_extensions/upsells/frontend/embed.ts",
1299 "edited source",
1300 );
1301 let edited = integrity(&root, &shim, &BTreeMap::new());
1302 assert_ne!(edited.fingerprint.source, first.fingerprint.source);
1303 fs::remove_dir_all(root).unwrap();
1304 fs::remove_dir_all(shim).unwrap();
1305 }
1306
1307 #[test]
1308 fn fingerprints_every_independent_input_domain_deterministically() {
1309 let (root, shim) = fixture();
1310 let first = integrity(&root, &shim, &BTreeMap::new());
1311 let second = integrity(&root, &shim, &BTreeMap::new());
1312 assert_eq!(first, second);
1313 assert_eq!(first.fingerprint.source_files, 1);
1314 assert_eq!(first.fingerprint.test_files, 1);
1315 for digest in [
1316 &first.fingerprint.source,
1317 &first.fingerprint.tests,
1318 &first.fingerprint.dependencies,
1319 &first.fingerprint.configuration,
1320 &first.fingerprint.instrumenter,
1321 &first.fingerprint.execution,
1322 &first.fingerprint.combined,
1323 ] {
1324 assert!(valid_sha256(digest));
1325 }
1326
1327 write(&root, "src/index.ts", "export const ready = false");
1328 let source = integrity(&root, &shim, &BTreeMap::new());
1329 assert_ne!(source.fingerprint.source, first.fingerprint.source);
1330 assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
1331 assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
1332
1333 write(&root, "src/index.ts", "export const ready = true");
1334 write(&root, "tests/index.test.ts", "test('changed', () => {})");
1335 let tests = integrity(&root, &shim, &BTreeMap::new());
1336 assert_eq!(tests.fingerprint.source, first.fingerprint.source);
1337 assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
1338 assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
1339
1340 write(&root, "tests/index.test.ts", "test('ready', () => {})");
1341 write(&root, "package-lock.json", "changed lock");
1342 let dependencies = integrity(&root, &shim, &BTreeMap::new());
1343 assert_ne!(
1344 dependencies.fingerprint.dependencies,
1345 first.fingerprint.dependencies
1346 );
1347 assert_ne!(
1348 dependencies.fingerprint.execution,
1349 first.fingerprint.execution
1350 );
1351
1352 write(&root, "package-lock.json", "lock");
1353 write(&root, "vite.config.ts", "export default { changed: true }");
1354 let configuration = integrity(&root, &shim, &BTreeMap::new());
1355 assert_ne!(
1356 configuration.fingerprint.configuration,
1357 first.fingerprint.configuration
1358 );
1359
1360 write(&root, "vite.config.ts", "export default {}");
1361 write(&shim, "instrumenter.js", "changed instrumenter");
1362 let instrumenter = integrity(&root, &shim, &BTreeMap::new());
1363 assert_ne!(
1364 instrumenter.fingerprint.instrumenter,
1365 first.fingerprint.instrumenter
1366 );
1367 assert_ne!(
1368 instrumenter.fingerprint.combined,
1369 first.fingerprint.combined
1370 );
1371 fs::remove_dir_all(root).unwrap();
1372 fs::remove_dir_all(shim).unwrap();
1373 }
1374
1375 #[test]
1376 fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
1377 let (root, shim) = fixture();
1378 write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
1379 write(
1380 &root,
1381 "packages/ui/tests/ui.test.ts",
1382 "import assert from 'node:assert/strict'; assert.equal(1, 1);",
1383 );
1384 let before = integrity(&root, &shim, &BTreeMap::new());
1385 for base in [".claude/worktrees/other", "nested-fork"] {
1386 write(
1387 &root,
1388 &format!("{base}/.git"),
1389 "gitdir: /unrelated/repository",
1390 );
1391 write(
1392 &root,
1393 &format!("{base}/tests/other.test.ts"),
1394 "assert.equal(2, 2);",
1395 );
1396 write(&root, &format!("{base}/package.json"), "{}");
1397 write(&root, &format!("{base}/tsconfig.json"), "{}");
1398 }
1399 let after = integrity(&root, &shim, &BTreeMap::new());
1400 assert_eq!(before.fingerprint, after.fingerprint);
1401 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1402 let paths = javascript_assertion_paths(&root, &project).unwrap();
1403 let inputs = crate::source_capture::capture(&root, "javascript", paths).unwrap();
1404 assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
1405 assert!(
1406 !inputs
1407 .files
1408 .keys()
1409 .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
1410 );
1411 fs::remove_dir_all(root).unwrap();
1412 fs::remove_dir_all(shim).unwrap();
1413 }
1414
1415 #[test]
1416 fn a_new_supercov_moves_its_own_identity_and_nothing_else() {
1417 let (root, shim) = fixture();
1422 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1423 let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1424 let mut newer = frontend(&shim);
1425 newer.engine_instrumenter_sha256 = "b".repeat(64);
1426 newer.engine_execution_sha256 = "c".repeat(64);
1427 let upgraded = create_run_integrity(&root, &project, &newer).unwrap();
1428
1429 assert_ne!(
1430 baseline.fingerprint.instrumenter, upgraded.fingerprint.instrumenter,
1431 "a merge and the build caches still have to see this"
1432 );
1433 assert_eq!(
1434 baseline.fingerprint.execution, upgraded.fingerprint.execution,
1435 "the run's setup did not change"
1436 );
1437 assert!(
1438 !compare_run_integrity(Some(&baseline), &upgraded).stale,
1439 "upgrading Supercov must not discard a recorded run"
1440 );
1441 fs::remove_dir_all(root).unwrap();
1442 fs::remove_dir_all(shim).unwrap();
1443 }
1444
1445 #[test]
1446 fn fingerprints_nested_workspace_manifests_and_execution_environment() {
1447 let (root, shim) = fixture();
1448 write(
1449 &root,
1450 "packages/ui/package.json",
1451 r#"{"dependencies":{"react":"1"}}"#,
1452 );
1453 write(&root, "packages/ui/src/index.ts", "export const ui = true");
1454 let first = integrity(&root, &shim, &BTreeMap::new());
1455 write(
1456 &root,
1457 "packages/ui/package.json",
1458 r#"{"dependencies":{"react":"2"}}"#,
1459 );
1460 let dependency = integrity(&root, &shim, &BTreeMap::new());
1461 assert_ne!(
1462 first.fingerprint.dependencies,
1463 dependency.fingerprint.dependencies
1464 );
1465
1466 let mut environment = BTreeMap::new();
1467 environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
1468 let project = discover_coverage_project(&root, &environment, &[]).unwrap();
1469 let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1470 let again = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1471 assert_eq!(baseline.fingerprint.execution, again.fingerprint.execution);
1472 assert!(
1473 compare_run_integrity(Some(&baseline), &again)
1474 .reasons
1475 .is_empty()
1476 );
1477 fs::remove_dir_all(root).unwrap();
1478 fs::remove_dir_all(shim).unwrap();
1479 }
1480
1481 #[test]
1482 fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
1483 let root = directory("rust-project");
1484 write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
1485 write(
1486 &root,
1487 "Cargo.toml",
1488 "[package]\nname='fixture'\nversion='0.0.0'\n",
1489 );
1490 let inputs = ExplicitIntegrityInputs {
1491 source_files: vec!["src/lib.rs".into()],
1492 test_files: vec!["src/lib.rs".into()],
1493 dependency_files: vec!["Cargo.toml".into()],
1494 configuration_files: Vec::new(),
1495 execution_configuration: b"cargo\0test".to_vec(),
1496 };
1497 let result = create_explicit_run_integrity(
1498 &root,
1499 &inputs,
1500 &FrontendIntegrityInputs::embedded_rust(),
1501 )
1502 .unwrap();
1503 assert_eq!(result.fingerprint.algorithm, "sha256");
1504 fs::remove_dir_all(root).unwrap();
1505 }
1506
1507 #[cfg(unix)]
1508 #[test]
1509 fn rejects_linked_frontend_identity_files() {
1510 use std::os::unix::fs::symlink;
1511
1512 let (root, shim) = fixture();
1513 let outside = shim.join("outside.js");
1514 fs::write(&outside, "outside").unwrap();
1515 fs::remove_file(shim.join("instrumenter.js")).unwrap();
1516 symlink(&outside, shim.join("instrumenter.js")).unwrap();
1517 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1518 assert!(matches!(
1519 create_run_integrity(&root, &project, &frontend(&shim)),
1520 Err(IntegrityError::UnsafeFile(_))
1521 ));
1522 fs::remove_dir_all(root).unwrap();
1523 fs::remove_dir_all(shim).unwrap();
1524 }
1525}