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 build_environment = frontend_map_bytes(&project.build_environment);
809 let execution = domain_hash(
815 "supercov-run-execution-v1",
816 &[
817 ("language", frontend.language.as_bytes()),
818 ("version", frontend.version.as_bytes()),
819 ("source", source.as_bytes()),
820 ("dependencies", dependency_digest.as_bytes()),
821 ("configuration", configuration_digest.as_bytes()),
822 ("buildEnvironment", &build_environment),
823 ],
824 );
825 let combined = domain_hash(
826 "supercov-run-combined-v1",
827 &[
828 ("language", frontend.language.as_bytes()),
829 ("version", frontend.version.as_bytes()),
830 ("source", source.as_bytes()),
831 ("tests", tests_digest.as_bytes()),
832 ("dependencies", dependency_digest.as_bytes()),
833 ("configuration", configuration_digest.as_bytes()),
834 ("instrumenter", instrumenter.as_bytes()),
835 ],
836 );
837 Ok(RunIntegrity {
838 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
839 instrumenter_version: frontend.version.clone(),
840 git: git_integrity(root),
841 fingerprint: RunFingerprint {
842 algorithm: "sha256".into(),
843 source,
844 tests: tests_digest,
845 dependencies: dependency_digest,
846 configuration: configuration_digest,
847 instrumenter,
848 execution,
849 combined,
850 source_files: project.source_files.len(),
851 test_files: tests.len(),
852 },
853 stale: None,
854 stale_reasons: None,
855 })
856}
857
858pub fn create_explicit_run_integrity(
861 root: &Path,
862 inputs: &ExplicitIntegrityInputs,
863 frontend: &FrontendIntegrityInputs,
864) -> Result<RunIntegrity, IntegrityError> {
865 if !valid_sha256(&frontend.engine_instrumenter_sha256) {
866 return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
867 }
868 if !valid_sha256(&frontend.engine_execution_sha256) {
869 return Err(IntegrityError::InvalidEngineDigest("execution engine"));
870 }
871 let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
872 let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
873 let dependencies = digest_manifests(
874 root,
875 inputs.dependency_files.iter().map(|path| root.join(path)),
876 )?;
877 let configuration = digest_files(
878 root,
879 inputs
880 .configuration_files
881 .iter()
882 .map(|path| root.join(path)),
883 )?;
884 let frontend_instrumenter =
885 digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
886 let frontend_execution =
887 digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
888 let instrumenter = domain_hash(
889 "supercov-run-instrumenter-v1",
890 &[
891 ("language", frontend.language.as_bytes()),
892 ("version", frontend.version.as_bytes()),
893 ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
894 ("shim", frontend_instrumenter.as_bytes()),
895 (
898 "executionEngine",
899 frontend.engine_execution_sha256.as_bytes(),
900 ),
901 ("executionShim", frontend_execution.as_bytes()),
902 ],
903 );
904 let execution = domain_hash(
910 "supercov-run-execution-v1",
911 &[
912 ("language", frontend.language.as_bytes()),
913 ("version", frontend.version.as_bytes()),
914 ("source", source.as_bytes()),
915 ("dependencies", dependencies.as_bytes()),
916 ("configuration", configuration.as_bytes()),
917 ("executionConfiguration", &inputs.execution_configuration),
918 ],
919 );
920 let combined = domain_hash(
921 "supercov-run-combined-v1",
922 &[
923 ("language", frontend.language.as_bytes()),
924 ("version", frontend.version.as_bytes()),
925 ("source", source.as_bytes()),
926 ("tests", tests.as_bytes()),
927 ("dependencies", dependencies.as_bytes()),
928 ("configuration", configuration.as_bytes()),
929 ("instrumenter", instrumenter.as_bytes()),
930 ("execution", execution.as_bytes()),
931 ],
932 );
933 Ok(RunIntegrity {
934 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
935 instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
936 git: git_integrity(root),
937 fingerprint: RunFingerprint {
938 algorithm: "sha256".into(),
942 source,
943 tests,
944 dependencies,
945 configuration,
946 instrumenter,
947 execution,
948 combined,
949 source_files: inputs.source_files.len(),
950 test_files: inputs.test_files.len(),
951 },
952 stale: None,
953 stale_reasons: None,
954 })
955}
956
957fn frontend_map_bytes(values: &std::collections::BTreeMap<String, String>) -> Vec<u8> {
958 let mut bytes = Vec::new();
959 for (key, value) in values {
960 bytes.extend_from_slice(&key.len().to_le_bytes());
961 bytes.extend_from_slice(key.as_bytes());
962 bytes.extend_from_slice(&value.len().to_le_bytes());
963 bytes.extend_from_slice(value.as_bytes());
964 }
965 bytes
966}
967
968#[cfg(test)]
969mod tests {
970 use std::{
971 collections::BTreeMap,
972 fs,
973 sync::atomic::{AtomicU64, Ordering},
974 time::{SystemTime, UNIX_EPOCH},
975 };
976
977 use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
978
979 use super::*;
980
981 static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
982
983 fn directory(label: &str) -> PathBuf {
984 let nonce = SystemTime::now()
985 .duration_since(UNIX_EPOCH)
986 .unwrap()
987 .as_nanos();
988 let root = std::env::temp_dir().join(format!(
989 "supercov-integrity-{label}-{}-{nonce}-{}",
990 std::process::id(),
991 TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
992 ));
993 fs::create_dir_all(&root).unwrap();
994 root
995 }
996
997 #[test]
998 fn git_context_records_branch_and_local_changes_without_guessing_detached_heads() {
999 let root = directory("git-context");
1000 assert!(git_integrity(&root).is_none());
1001 let git = |args: &[&str]| {
1002 let output = Command::new("git")
1003 .args(args)
1004 .current_dir(&root)
1005 .output()
1006 .unwrap();
1007 assert!(
1008 output.status.success(),
1009 "{}",
1010 String::from_utf8_lossy(&output.stderr)
1011 );
1012 };
1013 git(&["init", "--initial-branch=report-test"]);
1014 git(&[
1015 "-c",
1016 "user.name=Report Test",
1017 "-c",
1018 "user.email=report@example.invalid",
1019 "commit",
1020 "--allow-empty",
1021 "-m",
1022 "fixture",
1023 ]);
1024 let clean = git_integrity(&root).unwrap();
1025 assert_eq!(clean.branch.as_deref(), Some("report-test"));
1026 assert!(!clean.dirty);
1027 fs::write(root.join("local.txt"), "uncommitted").unwrap();
1028 let dirty = git_integrity(&root).unwrap();
1029 assert!(dirty.dirty);
1030 assert_eq!(dirty.revision, clean.revision);
1031 git(&["checkout", "--detach"]);
1032 let detached = git_integrity(&root).unwrap();
1033 assert!(detached.branch.is_none());
1034 assert_eq!(detached.revision, clean.revision);
1035 let old: GitIntegrity = serde_json::from_value(serde_json::json!({
1037 "revision": clean.revision, "dirty": false
1038 }))
1039 .unwrap();
1040 assert!(old.branch.is_none());
1041 fs::remove_dir_all(root).unwrap();
1042 }
1043
1044 const PACKAGE: &str = r#"{"name":"g","version":"1.0.0","dependencies":{"a":"^1.2.3"}}"#;
1045 const LOCK: &str = r#"{"name":"g","version":"1.0.0","packages":{"":{"name":"g","version":"1.0.0"},"node_modules/a":{"version":"1.2.3"}}}"#;
1046 const CARGO: &str =
1047 "[package]\nname = \"g\"\nversion = \"1.0.0\"\n\n[dependencies]\na = \"1.2.3\"\n";
1048 const PYPROJECT: &str =
1049 "[project]\nname = \"g\"\nversion = \"1.0.0\"\ndependencies = [\"a==1.2.3\"]\n";
1050
1051 #[test]
1052 fn a_release_bump_leaves_a_manifest_digest_alone() {
1053 for (kind, before, after) in [
1057 (
1058 ManifestKind::PackageJson,
1059 PACKAGE.to_owned(),
1060 PACKAGE.replace("1.0.0", "2.0.0"),
1061 ),
1062 (
1063 ManifestKind::PackageLock,
1064 LOCK.to_owned(),
1065 LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1066 ),
1067 (
1068 ManifestKind::CargoToml,
1069 CARGO.to_owned(),
1070 CARGO.replace("1.0.0", "2.0.0"),
1071 ),
1072 (
1073 ManifestKind::PyprojectToml,
1074 PYPROJECT.to_owned(),
1075 PYPROJECT.replace("1.0.0", "2.0.0"),
1076 ),
1077 ] {
1078 let stable = behavioural_manifest(kind, before.as_bytes());
1079 assert!(stable.is_some());
1080 assert_eq!(stable, behavioural_manifest(kind, after.as_bytes()));
1081 }
1082 }
1083
1084 #[test]
1085 fn a_dependency_change_still_moves_a_manifest_digest() {
1086 for (kind, before, after) in [
1089 (
1090 ManifestKind::PackageJson,
1091 PACKAGE.to_owned(),
1092 PACKAGE.replace("^1.2.3", "^2.0.0"),
1093 ),
1094 (
1095 ManifestKind::PackageLock,
1096 LOCK.to_owned(),
1097 LOCK.replace(
1098 "\"node_modules/a\":{\"version\":\"1.2.3\"}",
1099 "\"node_modules/a\":{\"version\":\"9.9.9\"}",
1100 ),
1101 ),
1102 (
1103 ManifestKind::CargoToml,
1104 CARGO.to_owned(),
1105 CARGO.replace("a = \"1.2.3\"", "a = \"9.9.9\""),
1106 ),
1107 (
1108 ManifestKind::PyprojectToml,
1109 PYPROJECT.to_owned(),
1110 PYPROJECT.replace("a==1.2.3", "a==9.9.9"),
1111 ),
1112 ] {
1113 assert_ne!(
1114 behavioural_manifest(kind, before.as_bytes()),
1115 behavioural_manifest(kind, after.as_bytes())
1116 );
1117 }
1118 }
1119
1120 #[test]
1121 fn the_dependency_fingerprint_survives_a_release_but_not_an_upgrade() {
1122 let root = directory("manifest-fingerprint");
1126 let paths = || [root.join("package.json"), root.join("package-lock.json")];
1127 write(&root, "package.json", PACKAGE);
1128 write(&root, "package-lock.json", LOCK);
1129 let before = digest_manifests(&root, paths()).unwrap();
1130
1131 write(&root, "package.json", &PACKAGE.replace("1.0.0", "2.0.0"));
1132 write(
1133 &root,
1134 "package-lock.json",
1135 &LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1136 );
1137 assert_eq!(
1138 before,
1139 digest_manifests(&root, paths()).unwrap(),
1140 "a release must not move the dependency fingerprint"
1141 );
1142
1143 write(&root, "package.json", &PACKAGE.replace("^1.2.3", "^2.0.0"));
1144 assert_ne!(
1145 before,
1146 digest_manifests(&root, paths()).unwrap(),
1147 "an upgrade must still move it"
1148 );
1149 fs::remove_dir_all(root).unwrap();
1150 }
1151
1152 #[test]
1153 fn reformatting_a_manifest_leaves_its_digest_alone() {
1154 let reordered = r#"{"dependencies":{"a":"^1.2.3"}, "version":"1.0.0",
1157 "name":"g"}"#;
1158 assert_eq!(
1159 behavioural_manifest(ManifestKind::PackageJson, PACKAGE.as_bytes()),
1160 behavioural_manifest(ManifestKind::PackageJson, reordered.as_bytes())
1161 );
1162 }
1163
1164 #[test]
1165 fn an_unreadable_manifest_falls_back_to_its_bytes() {
1166 assert!(behavioural_manifest(ManifestKind::PackageJson, b"{ not json").is_none());
1169 assert!(behavioural_manifest(ManifestKind::CargoToml, b"[[[").is_none());
1170 let root = directory("manifest-fallback");
1171 write(&root, "package.json", "{ not json");
1172 let first = digest_manifests(&root, [root.join("package.json")]).unwrap();
1173 write(&root, "package.json", "{ still not json");
1174 assert_ne!(
1175 first,
1176 digest_manifests(&root, [root.join("package.json")]).unwrap()
1177 );
1178 fs::remove_dir_all(root).unwrap();
1179 }
1180
1181 #[test]
1182 fn linter_and_formatter_settings_are_not_execution_context() {
1183 for inert in [
1187 ".prettierrc",
1188 ".prettierrc.json",
1189 "prettier.config.js",
1190 ".eslintrc",
1191 ".eslintrc.json",
1192 "eslint.config.mjs",
1193 ] {
1194 assert!(!configuration_file(Path::new(inert)), "{inert}");
1195 }
1196 for real in ["tsconfig.json", ".babelrc.js", "vite.config.ts", ".npmrc"] {
1197 assert!(configuration_file(Path::new(real)), "{real}");
1198 }
1199 }
1200
1201 #[test]
1202 fn globally_tracked_names_what_the_fingerprint_already_covers() {
1203 for tracked in [
1204 "package-lock.json",
1205 "package.json",
1206 "Cargo.toml",
1207 "Gemfile.lock",
1208 "requirements-dev.txt",
1209 "supercov.gemspec",
1210 "tsconfig.json",
1211 "nested/pyproject.toml",
1212 ] {
1213 assert!(globally_tracked(tracked), "{tracked}");
1214 }
1215 for own in [
1216 "src/index.ts",
1217 "tests/helpers/gateway-process.ts",
1218 "README.md",
1219 ] {
1220 assert!(!globally_tracked(own), "{own}");
1221 }
1222 }
1223
1224 fn write(root: &Path, path: &str, contents: &str) {
1225 let path = root.join(path);
1226 fs::create_dir_all(path.parent().unwrap()).unwrap();
1227 fs::write(path, contents).unwrap();
1228 }
1229
1230 fn frontend(root: &Path) -> FrontendIntegrityInputs {
1231 FrontendIntegrityInputs {
1232 language: "javascript".into(),
1233 version: "javascript-v1".into(),
1234 root: root.to_owned(),
1235 instrumenter_files: vec![root.join("instrumenter.js")],
1236 execution_files: vec![root.join("runtime.mjs")],
1237 engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
1238 engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
1239 }
1240 }
1241
1242 fn fixture() -> (PathBuf, PathBuf) {
1243 let root = directory("project");
1244 let shim = directory("shim");
1245 write(
1246 &root,
1247 "package.json",
1248 r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
1249 );
1250 write(&root, "package-lock.json", "lock");
1251 write(&root, "src/index.ts", "export const ready = true");
1252 write(&root, "tests/index.test.ts", "test('ready', () => {})");
1253 write(&root, "vite.config.ts", "export default {}");
1254 write(&root, ".cache/test262/fake.test.js", "ignored");
1255 write(
1256 &root,
1257 "supercov/.supercov-workspace-store",
1258 "Supercov instrumented workspace. Safe to delete.\n",
1259 );
1260 write(
1261 &root,
1262 "supercov/workspace/copy/tests/copied.test.ts",
1263 "ignored copied test",
1264 );
1265 write(&shim, "instrumenter.js", "instrument");
1266 write(&shim, "runtime.mjs", "runtime");
1267 (root, shim)
1268 }
1269
1270 fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
1271 let project = discover_coverage_project(root, environment, &[]).unwrap();
1272 create_run_integrity(root, &project, &frontend(shim)).unwrap()
1273 }
1274
1275 #[test]
1276 fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
1277 let (root, shim) = fixture();
1281 write(
1282 &root,
1283 "package.json",
1284 r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
1285 );
1286 write(&root, "app_extensions/upsells/package.json", "{}");
1287 write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
1288 write(
1289 &root,
1290 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1291 "bundle one",
1292 );
1293 let first = integrity(&root, &shim, &BTreeMap::new());
1294
1295 fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
1296 write(
1297 &root,
1298 "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
1299 "bundle two",
1300 );
1301 let rebuilt = integrity(&root, &shim, &BTreeMap::new());
1302 assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
1303 assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
1304
1305 write(
1306 &root,
1307 "app_extensions/upsells/frontend/embed.ts",
1308 "edited source",
1309 );
1310 let edited = integrity(&root, &shim, &BTreeMap::new());
1311 assert_ne!(edited.fingerprint.source, first.fingerprint.source);
1312 fs::remove_dir_all(root).unwrap();
1313 fs::remove_dir_all(shim).unwrap();
1314 }
1315
1316 #[test]
1317 fn fingerprints_every_independent_input_domain_deterministically() {
1318 let (root, shim) = fixture();
1319 let first = integrity(&root, &shim, &BTreeMap::new());
1320 let second = integrity(&root, &shim, &BTreeMap::new());
1321 assert_eq!(first, second);
1322 assert_eq!(first.fingerprint.source_files, 1);
1323 assert_eq!(first.fingerprint.test_files, 1);
1324 for digest in [
1325 &first.fingerprint.source,
1326 &first.fingerprint.tests,
1327 &first.fingerprint.dependencies,
1328 &first.fingerprint.configuration,
1329 &first.fingerprint.instrumenter,
1330 &first.fingerprint.execution,
1331 &first.fingerprint.combined,
1332 ] {
1333 assert!(valid_sha256(digest));
1334 }
1335
1336 write(&root, "src/index.ts", "export const ready = false");
1337 let source = integrity(&root, &shim, &BTreeMap::new());
1338 assert_ne!(source.fingerprint.source, first.fingerprint.source);
1339 assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
1340 assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
1341
1342 write(&root, "src/index.ts", "export const ready = true");
1343 write(&root, "tests/index.test.ts", "test('changed', () => {})");
1344 let tests = integrity(&root, &shim, &BTreeMap::new());
1345 assert_eq!(tests.fingerprint.source, first.fingerprint.source);
1346 assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
1347 assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
1348
1349 write(&root, "tests/index.test.ts", "test('ready', () => {})");
1350 write(&root, "package-lock.json", "changed lock");
1351 let dependencies = integrity(&root, &shim, &BTreeMap::new());
1352 assert_ne!(
1353 dependencies.fingerprint.dependencies,
1354 first.fingerprint.dependencies
1355 );
1356 assert_ne!(
1357 dependencies.fingerprint.execution,
1358 first.fingerprint.execution
1359 );
1360
1361 write(&root, "package-lock.json", "lock");
1362 write(&root, "vite.config.ts", "export default { changed: true }");
1363 let configuration = integrity(&root, &shim, &BTreeMap::new());
1364 assert_ne!(
1365 configuration.fingerprint.configuration,
1366 first.fingerprint.configuration
1367 );
1368
1369 write(&root, "vite.config.ts", "export default {}");
1370 write(&shim, "instrumenter.js", "changed instrumenter");
1371 let instrumenter = integrity(&root, &shim, &BTreeMap::new());
1372 assert_ne!(
1373 instrumenter.fingerprint.instrumenter,
1374 first.fingerprint.instrumenter
1375 );
1376 assert_ne!(
1377 instrumenter.fingerprint.combined,
1378 first.fingerprint.combined
1379 );
1380 fs::remove_dir_all(root).unwrap();
1381 fs::remove_dir_all(shim).unwrap();
1382 }
1383
1384 #[test]
1385 fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
1386 let (root, shim) = fixture();
1387 write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
1388 write(
1389 &root,
1390 "packages/ui/tests/ui.test.ts",
1391 "import assert from 'node:assert/strict'; assert.equal(1, 1);",
1392 );
1393 let before = integrity(&root, &shim, &BTreeMap::new());
1394 for base in [".claude/worktrees/other", "nested-fork"] {
1395 write(
1396 &root,
1397 &format!("{base}/.git"),
1398 "gitdir: /unrelated/repository",
1399 );
1400 write(
1401 &root,
1402 &format!("{base}/tests/other.test.ts"),
1403 "assert.equal(2, 2);",
1404 );
1405 write(&root, &format!("{base}/package.json"), "{}");
1406 write(&root, &format!("{base}/tsconfig.json"), "{}");
1407 }
1408 let after = integrity(&root, &shim, &BTreeMap::new());
1409 assert_eq!(before.fingerprint, after.fingerprint);
1410 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1411 let paths = javascript_assertion_paths(&root, &project).unwrap();
1412 let inputs = crate::assertion_inputs::capture(&root, "javascript", paths).unwrap();
1413 assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
1414 assert!(
1415 !inputs
1416 .files
1417 .keys()
1418 .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
1419 );
1420 fs::remove_dir_all(root).unwrap();
1421 fs::remove_dir_all(shim).unwrap();
1422 }
1423
1424 #[test]
1425 fn a_new_supercov_moves_its_own_identity_and_nothing_else() {
1426 let (root, shim) = fixture();
1431 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1432 let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1433 let mut newer = frontend(&shim);
1434 newer.engine_instrumenter_sha256 = "b".repeat(64);
1435 newer.engine_execution_sha256 = "c".repeat(64);
1436 let upgraded = create_run_integrity(&root, &project, &newer).unwrap();
1437
1438 assert_ne!(
1439 baseline.fingerprint.instrumenter, upgraded.fingerprint.instrumenter,
1440 "a merge and the build caches still have to see this"
1441 );
1442 assert_eq!(
1443 baseline.fingerprint.execution, upgraded.fingerprint.execution,
1444 "the run's setup did not change"
1445 );
1446 assert!(
1447 !compare_run_integrity(Some(&baseline), &upgraded).stale,
1448 "upgrading Supercov must not discard a recorded run"
1449 );
1450 fs::remove_dir_all(root).unwrap();
1451 fs::remove_dir_all(shim).unwrap();
1452 }
1453
1454 #[test]
1455 fn fingerprints_nested_workspace_manifests_and_execution_environment() {
1456 let (root, shim) = fixture();
1457 write(
1458 &root,
1459 "packages/ui/package.json",
1460 r#"{"dependencies":{"react":"1"}}"#,
1461 );
1462 write(&root, "packages/ui/src/index.ts", "export const ui = true");
1463 let first = integrity(&root, &shim, &BTreeMap::new());
1464 write(
1465 &root,
1466 "packages/ui/package.json",
1467 r#"{"dependencies":{"react":"2"}}"#,
1468 );
1469 let dependency = integrity(&root, &shim, &BTreeMap::new());
1470 assert_ne!(
1471 first.fingerprint.dependencies,
1472 dependency.fingerprint.dependencies
1473 );
1474
1475 let mut environment = BTreeMap::new();
1476 environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
1477 let project = discover_coverage_project(&root, &environment, &[]).unwrap();
1478 let mut project_with_build_environment = project.clone();
1479 project_with_build_environment
1480 .build_environment
1481 .insert("MODE".into(), "test".into());
1482 let changed =
1483 create_run_integrity(&root, &project_with_build_environment, &frontend(&shim)).unwrap();
1484 let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1485 assert_ne!(
1486 baseline.fingerprint.execution,
1487 changed.fingerprint.execution
1488 );
1489 assert_eq!(baseline.fingerprint.combined, changed.fingerprint.combined);
1490 assert_eq!(
1491 compare_run_integrity(Some(&baseline), &changed).reasons,
1492 ["execution environment changed"]
1493 );
1494 fs::remove_dir_all(root).unwrap();
1495 fs::remove_dir_all(shim).unwrap();
1496 }
1497
1498 #[test]
1499 fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
1500 let root = directory("rust-project");
1501 write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
1502 write(
1503 &root,
1504 "Cargo.toml",
1505 "[package]\nname='fixture'\nversion='0.0.0'\n",
1506 );
1507 let inputs = ExplicitIntegrityInputs {
1508 source_files: vec!["src/lib.rs".into()],
1509 test_files: vec!["src/lib.rs".into()],
1510 dependency_files: vec!["Cargo.toml".into()],
1511 configuration_files: Vec::new(),
1512 execution_configuration: b"cargo\0test".to_vec(),
1513 };
1514 let result = create_explicit_run_integrity(
1515 &root,
1516 &inputs,
1517 &FrontendIntegrityInputs::embedded_rust(),
1518 )
1519 .unwrap();
1520 assert_eq!(result.fingerprint.algorithm, "sha256");
1521 fs::remove_dir_all(root).unwrap();
1522 }
1523
1524 #[cfg(unix)]
1525 #[test]
1526 fn rejects_linked_frontend_identity_files() {
1527 use std::os::unix::fs::symlink;
1528
1529 let (root, shim) = fixture();
1530 let outside = shim.join("outside.js");
1531 fs::write(&outside, "outside").unwrap();
1532 fs::remove_file(shim.join("instrumenter.js")).unwrap();
1533 symlink(&outside, shim.join("instrumenter.js")).unwrap();
1534 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1535 assert!(matches!(
1536 create_run_integrity(&root, &project, &frontend(&shim)),
1537 Err(IntegrityError::UnsafeFile(_))
1538 ));
1539 fs::remove_dir_all(root).unwrap();
1540 fs::remove_dir_all(shim).unwrap();
1541 }
1542}