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_ruby() -> Self {
85 Self {
86 language: "ruby".into(),
87 version: "ruby-coverage-v1".into(),
88 root: PathBuf::from("."),
89 instrumenter_files: Vec::new(),
90 execution_files: Vec::new(),
91 engine_instrumenter_sha256: env!("SUPERCOV_RUBY_FRONTEND_SOURCE_SHA256").into(),
92 engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct ExplicitIntegrityInputs {
99 pub source_files: Vec<PathBuf>,
100 pub test_files: Vec<PathBuf>,
101 pub dependency_files: Vec<PathBuf>,
102 pub configuration_files: Vec<PathBuf>,
103 pub execution_configuration: Vec<u8>,
104}
105
106impl ExplicitIntegrityInputs {
107 pub(crate) fn assertion_paths(&self) -> Vec<PathBuf> {
108 self.source_files
109 .iter()
110 .chain(&self.test_files)
111 .chain(&self.dependency_files)
112 .chain(&self.configuration_files)
113 .cloned()
114 .collect()
115 }
116}
117
118pub(crate) fn javascript_assertion_paths(
119 root: &Path,
120 project: &CoverageProject,
121) -> Result<Vec<PathBuf>, IntegrityError> {
122 let mut paths = test_files(root)?;
123 paths.extend(dependency_files(root)?);
124 paths.extend(configuration_files(root, project)?);
125 paths.extend(crate::typescript_imports::config_paths(
126 root,
127 &project.source_files,
128 ));
129 paths.extend(project.source_files.iter().map(|p| root.join(p)));
130 paths.extend(
131 project
132 .source_scope
133 .entries
134 .iter()
135 .filter(|e| !e.is_generated_output())
136 .map(|e| root.join(&e.file)),
137 );
138 Ok(paths)
139}
140
141#[derive(Debug)]
142pub enum IntegrityError {
143 Io { path: PathBuf, source: io::Error },
144 UnsafeFile(PathBuf),
145 NonUtf8Path(PathBuf),
146 OutsideRoot { root: PathBuf, path: PathBuf },
147 InvalidEngineDigest(&'static str),
148}
149
150impl std::fmt::Display for IntegrityError {
151 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 match self {
153 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
154 Self::UnsafeFile(path) => {
155 write!(
156 formatter,
157 "fingerprint input is not a regular file: {}",
158 path.display()
159 )
160 }
161 Self::NonUtf8Path(path) => {
162 write!(
163 formatter,
164 "fingerprint path is not valid UTF-8: {}",
165 path.display()
166 )
167 }
168 Self::OutsideRoot { root, path } => write!(
169 formatter,
170 "fingerprint input {} is outside {}",
171 path.display(),
172 root.display()
173 ),
174 Self::InvalidEngineDigest(field) => write!(formatter, "invalid {field} SHA-256"),
175 }
176 }
177}
178
179impl std::error::Error for IntegrityError {}
180
181fn io_error(path: &Path, source: io::Error) -> IntegrityError {
182 IntegrityError::Io {
183 path: path.to_owned(),
184 source,
185 }
186}
187
188fn valid_sha256(value: &str) -> bool {
189 value.len() == 64
190 && value
191 .bytes()
192 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
193}
194
195fn local_path(root: &Path, path: &Path) -> Result<String, IntegrityError> {
196 let path = path
197 .strip_prefix(root)
198 .map_err(|_| IntegrityError::OutsideRoot {
199 root: root.to_owned(),
200 path: path.to_owned(),
201 })?;
202 path.components()
203 .map(|component| {
204 component
205 .as_os_str()
206 .to_str()
207 .map(str::to_owned)
208 .ok_or_else(|| IntegrityError::NonUtf8Path(path.to_owned()))
209 })
210 .collect::<Result<Vec<_>, _>>()
211 .map(|parts| parts.join("/"))
212}
213
214fn digest_files(
215 root: &Path,
216 paths: impl IntoIterator<Item = PathBuf>,
217) -> Result<String, IntegrityError> {
218 let paths = paths.into_iter().collect::<BTreeSet<_>>();
219 let mut labeled = paths
220 .into_iter()
221 .map(|path| local_path(root, &path).map(|label| (label, path)))
222 .collect::<Result<Vec<_>, _>>()?;
223 labeled.sort_by(|left, right| left.0.cmp(&right.0));
224 let mut hash = Sha256::new();
225 let mut buffer = [0_u8; 128 * 1024];
226 for (label, path) in labeled {
227 let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
228 if !metadata.file_type().is_file() {
229 return Err(IntegrityError::UnsafeFile(path));
230 }
231 hash.update(label.as_bytes());
232 hash.update([0]);
233 let mut file = fs::File::open(&path).map_err(|source| io_error(&path, source))?;
234 loop {
235 let read = file
236 .read(&mut buffer)
237 .map_err(|source| io_error(&path, source))?;
238 if read == 0 {
239 break;
240 }
241 hash.update(&buffer[..read]);
242 }
243 hash.update([0]);
244 }
245 Ok(format!("{:x}", hash.finalize()))
246}
247
248fn domain_hash(domain: &str, fields: &[(&str, &[u8])]) -> String {
249 let mut hash = Sha256::new();
250 hash.update(domain.as_bytes());
251 hash.update([0]);
252 for (name, value) in fields {
253 hash.update((*name).len().to_le_bytes());
254 hash.update(name.as_bytes());
255 hash.update(value.len().to_le_bytes());
256 hash.update(value);
257 }
258 format!("{:x}", hash.finalize())
259}
260
261fn source_file(path: &Path) -> bool {
262 let lower = path
263 .file_name()
264 .and_then(|name| name.to_str())
265 .unwrap_or("")
266 .to_ascii_lowercase();
267 [
268 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
269 ".mtsx",
270 ]
271 .iter()
272 .any(|extension| lower.ends_with(extension))
273}
274
275fn skipped_directory(name: &str) -> bool {
276 [
277 ".cache",
278 ".git",
279 ".mcdc-pool",
280 ".next",
281 ".nuxt",
282 ".output",
283 ".supercov",
284 "build",
285 "coverage",
286 "dist",
287 "node_modules",
288 "out",
289 "playwright-report",
290 "results",
291 "test-results",
292 "vendor",
293 ]
294 .contains(&name)
295}
296
297fn owned_workspace_store(path: &Path) -> bool {
298 crate::workspace::owned_workspace_path(path)
299}
300
301fn walk_files(
302 directory: &Path,
303 predicate: &impl Fn(&Path) -> bool,
304 output: &mut Vec<PathBuf>,
305) -> Result<(), IntegrityError> {
306 let metadata = match fs::symlink_metadata(directory) {
307 Ok(metadata) => metadata,
308 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
309 Err(source) => return Err(io_error(directory, source)),
310 };
311 if !metadata.file_type().is_dir() {
312 return Err(IntegrityError::UnsafeFile(directory.to_owned()));
313 }
314 let mut entries = fs::read_dir(directory)
315 .map_err(|source| io_error(directory, source))?
316 .collect::<Result<Vec<_>, _>>()
317 .map_err(|source| io_error(directory, source))?;
318 entries.sort_by_key(fs::DirEntry::file_name);
319 for entry in entries {
320 let path = entry.path();
321 let file_type = entry
322 .file_type()
323 .map_err(|source| io_error(&path, source))?;
324 if file_type.is_symlink() {
325 continue;
326 }
327 if file_type.is_dir() {
328 let name = entry.file_name();
329 if !name
330 .to_str()
331 .is_some_and(|name| name.starts_with('.') || skipped_directory(name))
332 && !path.join(".git").exists()
333 && !owned_workspace_store(&path)
334 {
335 walk_files(&path, predicate, output)?;
336 }
337 } else if file_type.is_file() && predicate(&path) {
338 output.push(path);
339 }
340 }
341 Ok(())
342}
343
344fn test_file(root: &Path, path: &Path) -> bool {
345 if !source_file(path) {
346 return false;
347 }
348 let local = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
349 local
350 .to_ascii_lowercase()
351 .split(['/', '\\', '_', '.', '-'])
352 .any(|part| matches!(part, "test" | "spec"))
353}
354
355fn test_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
356 let mut files = Vec::new();
357 for directory in ["test", "tests", "__tests__"] {
358 walk_files(&root.join(directory), &source_file, &mut files)?;
359 }
360 walk_files(root, &|path| test_file(root, path), &mut files)?;
361 files.sort();
362 files.dedup();
363 Ok(files)
364}
365
366fn dependency_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
367 let mut files = Vec::new();
368 walk_files(
369 root,
370 &|path| path.file_name().is_some_and(|name| name == "package.json"),
371 &mut files,
372 )?;
373 for name in [
374 "package-lock.json",
375 "npm-shrinkwrap.json",
376 "pnpm-lock.yaml",
377 "yarn.lock",
378 "bun.lock",
379 "bun.lockb",
380 ] {
381 let path = root.join(name);
382 if path.is_file() {
383 files.push(path);
384 }
385 }
386 files.sort();
387 files.dedup();
388 Ok(files)
389}
390
391fn configuration_file(path: &Path) -> bool {
392 let name = path
393 .file_name()
394 .and_then(|name| name.to_str())
395 .unwrap_or("")
396 .to_ascii_lowercase();
397 name == ".npmrc"
398 || (name.starts_with("tsconfig") && name.ends_with(".json"))
399 || name.contains(".config.")
400 || name.starts_with(".babelrc.")
401 || name.starts_with(".eslint")
402 || name.starts_with(".prettier")
403}
404
405fn configuration_files(
406 root: &Path,
407 project: &CoverageProject,
408) -> Result<Vec<PathBuf>, IntegrityError> {
409 let mut files = Vec::new();
410 walk_files(root, &configuration_file, &mut files)?;
411 files.extend(
412 [
413 project.playwright_config.as_ref(),
414 project.vitest_config.as_ref(),
415 project.jest_config.as_ref(),
416 ]
417 .into_iter()
418 .flatten()
419 .cloned(),
420 );
421 files.sort();
422 files.dedup();
423 Ok(files)
424}
425
426fn git_integrity(root: &Path) -> Option<GitIntegrity> {
427 let revision = Command::new("git")
428 .args(["rev-parse", "HEAD"])
429 .current_dir(root)
430 .output()
431 .ok();
432 let status = Command::new("git")
433 .args(["status", "--porcelain=v1"])
434 .current_dir(root)
435 .output()
436 .ok();
437 if !revision
438 .as_ref()
439 .is_some_and(|output| output.status.success())
440 && !status
441 .as_ref()
442 .is_some_and(|output| output.status.success())
443 {
444 return None;
445 }
446 Some(GitIntegrity {
447 revision: revision
448 .filter(|output| output.status.success())
449 .and_then(|output| String::from_utf8(output.stdout).ok())
450 .map(|revision| revision.trim().to_owned()),
451 dirty: !status
452 .as_ref()
453 .is_some_and(|output| output.status.success() && output.stdout.is_empty()),
454 })
455}
456
457pub fn create_run_integrity(
458 root: &Path,
459 project: &CoverageProject,
460 frontend: &FrontendIntegrityInputs,
461) -> Result<RunIntegrity, IntegrityError> {
462 if !valid_sha256(&frontend.engine_instrumenter_sha256) {
463 return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
464 }
465 if !valid_sha256(&frontend.engine_execution_sha256) {
466 return Err(IntegrityError::InvalidEngineDigest("execution engine"));
467 }
468 let tests = test_files(root)?;
469 let dependencies = dependency_files(root)?;
470 let configuration = configuration_files(root, project)?;
471 let covered_elsewhere = tests
484 .iter()
485 .chain(dependencies.iter())
486 .chain(configuration.iter())
487 .collect::<std::collections::BTreeSet<_>>();
488 let source_paths = project
489 .source_files
490 .iter()
491 .map(|path| root.join(path))
492 .chain(
493 project
494 .source_scope
495 .entries
496 .iter()
497 .filter(|entry| !entry.is_generated_output())
498 .map(|entry| root.join(&entry.file))
499 .filter(|path| !covered_elsewhere.contains(path)),
500 )
501 .collect::<Vec<_>>();
502 let source = digest_files(root, source_paths)?;
503 let tests_digest = digest_files(root, tests.iter().cloned())?;
504 let dependency_digest = digest_files(root, dependencies)?;
505 let configuration_digest = digest_files(
506 root,
507 configuration
508 .into_iter()
509 .chain(crate::typescript_imports::config_paths(
510 root,
511 &project.source_files,
512 )),
513 )?;
514 let frontend_instrumenter =
515 digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
516 let frontend_execution =
517 digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
518 let instrumenter = domain_hash(
519 "supercov-run-instrumenter-v1",
520 &[
521 ("language", frontend.language.as_bytes()),
522 ("version", frontend.version.as_bytes()),
523 ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
524 ("shim", frontend_instrumenter.as_bytes()),
525 ],
526 );
527 let build_environment = frontend_map_bytes(&project.build_environment);
528 let execution = domain_hash(
529 "supercov-run-execution-v1",
530 &[
531 ("language", frontend.language.as_bytes()),
532 ("version", frontend.version.as_bytes()),
533 ("source", source.as_bytes()),
534 ("dependencies", dependency_digest.as_bytes()),
535 ("configuration", configuration_digest.as_bytes()),
536 ("buildEnvironment", &build_environment),
537 ("engine", frontend.engine_execution_sha256.as_bytes()),
538 ("shim", frontend_execution.as_bytes()),
539 ],
540 );
541 let combined = domain_hash(
542 "supercov-run-combined-v1",
543 &[
544 ("language", frontend.language.as_bytes()),
545 ("version", frontend.version.as_bytes()),
546 ("source", source.as_bytes()),
547 ("tests", tests_digest.as_bytes()),
548 ("dependencies", dependency_digest.as_bytes()),
549 ("configuration", configuration_digest.as_bytes()),
550 ("instrumenter", instrumenter.as_bytes()),
551 ],
552 );
553 Ok(RunIntegrity {
554 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
555 instrumenter_version: frontend.version.clone(),
556 git: git_integrity(root),
557 fingerprint: RunFingerprint {
558 algorithm: "sha256".into(),
559 source,
560 tests: tests_digest,
561 dependencies: dependency_digest,
562 configuration: configuration_digest,
563 instrumenter,
564 execution,
565 combined,
566 source_files: project.source_files.len(),
567 test_files: tests.len(),
568 },
569 stale: None,
570 stale_reasons: None,
571 })
572}
573
574pub fn create_explicit_run_integrity(
577 root: &Path,
578 inputs: &ExplicitIntegrityInputs,
579 frontend: &FrontendIntegrityInputs,
580) -> Result<RunIntegrity, IntegrityError> {
581 if !valid_sha256(&frontend.engine_instrumenter_sha256) {
582 return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
583 }
584 if !valid_sha256(&frontend.engine_execution_sha256) {
585 return Err(IntegrityError::InvalidEngineDigest("execution engine"));
586 }
587 let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
588 let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
589 let dependencies = digest_files(
590 root,
591 inputs.dependency_files.iter().map(|path| root.join(path)),
592 )?;
593 let configuration = digest_files(
594 root,
595 inputs
596 .configuration_files
597 .iter()
598 .map(|path| root.join(path)),
599 )?;
600 let frontend_instrumenter =
601 digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
602 let frontend_execution =
603 digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
604 let instrumenter = domain_hash(
605 "supercov-run-instrumenter-v1",
606 &[
607 ("language", frontend.language.as_bytes()),
608 ("version", frontend.version.as_bytes()),
609 ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
610 ("shim", frontend_instrumenter.as_bytes()),
611 ],
612 );
613 let execution = domain_hash(
614 "supercov-run-execution-v1",
615 &[
616 ("language", frontend.language.as_bytes()),
617 ("version", frontend.version.as_bytes()),
618 ("source", source.as_bytes()),
619 ("dependencies", dependencies.as_bytes()),
620 ("configuration", configuration.as_bytes()),
621 ("executionConfiguration", &inputs.execution_configuration),
622 ("engine", frontend.engine_execution_sha256.as_bytes()),
623 ("shim", frontend_execution.as_bytes()),
624 ],
625 );
626 let combined = domain_hash(
627 "supercov-run-combined-v1",
628 &[
629 ("language", frontend.language.as_bytes()),
630 ("version", frontend.version.as_bytes()),
631 ("source", source.as_bytes()),
632 ("tests", tests.as_bytes()),
633 ("dependencies", dependencies.as_bytes()),
634 ("configuration", configuration.as_bytes()),
635 ("instrumenter", instrumenter.as_bytes()),
636 ("execution", execution.as_bytes()),
637 ],
638 );
639 Ok(RunIntegrity {
640 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
641 instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
642 git: git_integrity(root),
643 fingerprint: RunFingerprint {
644 algorithm: "sha256".into(),
648 source,
649 tests,
650 dependencies,
651 configuration,
652 instrumenter,
653 execution,
654 combined,
655 source_files: inputs.source_files.len(),
656 test_files: inputs.test_files.len(),
657 },
658 stale: None,
659 stale_reasons: None,
660 })
661}
662
663fn frontend_map_bytes(values: &std::collections::BTreeMap<String, String>) -> Vec<u8> {
664 let mut bytes = Vec::new();
665 for (key, value) in values {
666 bytes.extend_from_slice(&key.len().to_le_bytes());
667 bytes.extend_from_slice(key.as_bytes());
668 bytes.extend_from_slice(&value.len().to_le_bytes());
669 bytes.extend_from_slice(value.as_bytes());
670 }
671 bytes
672}
673
674#[cfg(test)]
675mod tests {
676 use std::{
677 collections::BTreeMap,
678 fs,
679 sync::atomic::{AtomicU64, Ordering},
680 time::{SystemTime, UNIX_EPOCH},
681 };
682
683 use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
684
685 use super::*;
686
687 static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
688
689 fn directory(label: &str) -> PathBuf {
690 let nonce = SystemTime::now()
691 .duration_since(UNIX_EPOCH)
692 .unwrap()
693 .as_nanos();
694 let root = std::env::temp_dir().join(format!(
695 "supercov-integrity-{label}-{}-{nonce}-{}",
696 std::process::id(),
697 TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
698 ));
699 fs::create_dir_all(&root).unwrap();
700 root
701 }
702
703 fn write(root: &Path, path: &str, contents: &str) {
704 let path = root.join(path);
705 fs::create_dir_all(path.parent().unwrap()).unwrap();
706 fs::write(path, contents).unwrap();
707 }
708
709 fn frontend(root: &Path) -> FrontendIntegrityInputs {
710 FrontendIntegrityInputs {
711 language: "javascript".into(),
712 version: "javascript-v1".into(),
713 root: root.to_owned(),
714 instrumenter_files: vec![root.join("instrumenter.js")],
715 execution_files: vec![root.join("runtime.mjs")],
716 engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
717 engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
718 }
719 }
720
721 fn fixture() -> (PathBuf, PathBuf) {
722 let root = directory("project");
723 let shim = directory("shim");
724 write(
725 &root,
726 "package.json",
727 r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
728 );
729 write(&root, "package-lock.json", "lock");
730 write(&root, "src/index.ts", "export const ready = true");
731 write(&root, "tests/index.test.ts", "test('ready', () => {})");
732 write(&root, "vite.config.ts", "export default {}");
733 write(&root, ".cache/test262/fake.test.js", "ignored");
734 write(
735 &root,
736 "supercov/.supercov-workspace-store",
737 "Supercov instrumented workspace. Safe to delete.\n",
738 );
739 write(
740 &root,
741 "supercov/workspace/copy/tests/copied.test.ts",
742 "ignored copied test",
743 );
744 write(&shim, "instrumenter.js", "instrument");
745 write(&shim, "runtime.mjs", "runtime");
746 (root, shim)
747 }
748
749 fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
750 let project = discover_coverage_project(root, environment, &[]).unwrap();
751 create_run_integrity(root, &project, &frontend(shim)).unwrap()
752 }
753
754 #[test]
755 fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
756 let (root, shim) = fixture();
760 write(
761 &root,
762 "package.json",
763 r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
764 );
765 write(&root, "app_extensions/upsells/package.json", "{}");
766 write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
767 write(
768 &root,
769 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
770 "bundle one",
771 );
772 let first = integrity(&root, &shim, &BTreeMap::new());
773
774 fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
775 write(
776 &root,
777 "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
778 "bundle two",
779 );
780 let rebuilt = integrity(&root, &shim, &BTreeMap::new());
781 assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
782 assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
783
784 write(
785 &root,
786 "app_extensions/upsells/frontend/embed.ts",
787 "edited source",
788 );
789 let edited = integrity(&root, &shim, &BTreeMap::new());
790 assert_ne!(edited.fingerprint.source, first.fingerprint.source);
791 fs::remove_dir_all(root).unwrap();
792 fs::remove_dir_all(shim).unwrap();
793 }
794
795 #[test]
796 fn fingerprints_every_independent_input_domain_deterministically() {
797 let (root, shim) = fixture();
798 let first = integrity(&root, &shim, &BTreeMap::new());
799 let second = integrity(&root, &shim, &BTreeMap::new());
800 assert_eq!(first, second);
801 assert_eq!(first.fingerprint.source_files, 1);
802 assert_eq!(first.fingerprint.test_files, 1);
803 for digest in [
804 &first.fingerprint.source,
805 &first.fingerprint.tests,
806 &first.fingerprint.dependencies,
807 &first.fingerprint.configuration,
808 &first.fingerprint.instrumenter,
809 &first.fingerprint.execution,
810 &first.fingerprint.combined,
811 ] {
812 assert!(valid_sha256(digest));
813 }
814
815 write(&root, "src/index.ts", "export const ready = false");
816 let source = integrity(&root, &shim, &BTreeMap::new());
817 assert_ne!(source.fingerprint.source, first.fingerprint.source);
818 assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
819 assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
820
821 write(&root, "src/index.ts", "export const ready = true");
822 write(&root, "tests/index.test.ts", "test('changed', () => {})");
823 let tests = integrity(&root, &shim, &BTreeMap::new());
824 assert_eq!(tests.fingerprint.source, first.fingerprint.source);
825 assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
826 assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
827
828 write(&root, "tests/index.test.ts", "test('ready', () => {})");
829 write(&root, "package-lock.json", "changed lock");
830 let dependencies = integrity(&root, &shim, &BTreeMap::new());
831 assert_ne!(
832 dependencies.fingerprint.dependencies,
833 first.fingerprint.dependencies
834 );
835 assert_ne!(
836 dependencies.fingerprint.execution,
837 first.fingerprint.execution
838 );
839
840 write(&root, "package-lock.json", "lock");
841 write(&root, "vite.config.ts", "export default { changed: true }");
842 let configuration = integrity(&root, &shim, &BTreeMap::new());
843 assert_ne!(
844 configuration.fingerprint.configuration,
845 first.fingerprint.configuration
846 );
847
848 write(&root, "vite.config.ts", "export default {}");
849 write(&shim, "instrumenter.js", "changed instrumenter");
850 let instrumenter = integrity(&root, &shim, &BTreeMap::new());
851 assert_ne!(
852 instrumenter.fingerprint.instrumenter,
853 first.fingerprint.instrumenter
854 );
855 assert_ne!(
856 instrumenter.fingerprint.combined,
857 first.fingerprint.combined
858 );
859 fs::remove_dir_all(root).unwrap();
860 fs::remove_dir_all(shim).unwrap();
861 }
862
863 #[test]
864 fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
865 let (root, shim) = fixture();
866 write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
867 write(
868 &root,
869 "packages/ui/tests/ui.test.ts",
870 "import assert from 'node:assert/strict'; assert.equal(1, 1);",
871 );
872 let before = integrity(&root, &shim, &BTreeMap::new());
873 for base in [".claude/worktrees/other", "nested-fork"] {
874 write(
875 &root,
876 &format!("{base}/.git"),
877 "gitdir: /unrelated/repository",
878 );
879 write(
880 &root,
881 &format!("{base}/tests/other.test.ts"),
882 "assert.equal(2, 2);",
883 );
884 write(&root, &format!("{base}/package.json"), "{}");
885 write(&root, &format!("{base}/tsconfig.json"), "{}");
886 }
887 let after = integrity(&root, &shim, &BTreeMap::new());
888 assert_eq!(before.fingerprint, after.fingerprint);
889 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
890 let paths = javascript_assertion_paths(&root, &project).unwrap();
891 let inputs = crate::assertion_inputs::capture(&root, "javascript", paths).unwrap();
892 assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
893 assert!(
894 !inputs
895 .files
896 .keys()
897 .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
898 );
899 fs::remove_dir_all(root).unwrap();
900 fs::remove_dir_all(shim).unwrap();
901 }
902
903 #[test]
904 fn fingerprints_nested_workspace_manifests_and_execution_environment() {
905 let (root, shim) = fixture();
906 write(
907 &root,
908 "packages/ui/package.json",
909 r#"{"dependencies":{"react":"1"}}"#,
910 );
911 write(&root, "packages/ui/src/index.ts", "export const ui = true");
912 let first = integrity(&root, &shim, &BTreeMap::new());
913 write(
914 &root,
915 "packages/ui/package.json",
916 r#"{"dependencies":{"react":"2"}}"#,
917 );
918 let dependency = integrity(&root, &shim, &BTreeMap::new());
919 assert_ne!(
920 first.fingerprint.dependencies,
921 dependency.fingerprint.dependencies
922 );
923
924 let mut environment = BTreeMap::new();
925 environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
926 let project = discover_coverage_project(&root, &environment, &[]).unwrap();
927 let mut project_with_build_environment = project.clone();
928 project_with_build_environment
929 .build_environment
930 .insert("MODE".into(), "test".into());
931 let changed =
932 create_run_integrity(&root, &project_with_build_environment, &frontend(&shim)).unwrap();
933 let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
934 assert_ne!(
935 baseline.fingerprint.execution,
936 changed.fingerprint.execution
937 );
938 assert_eq!(baseline.fingerprint.combined, changed.fingerprint.combined);
939 assert_eq!(
940 compare_run_integrity(Some(&baseline), &changed).reasons,
941 ["execution environment changed"]
942 );
943 fs::remove_dir_all(root).unwrap();
944 fs::remove_dir_all(shim).unwrap();
945 }
946
947 #[test]
948 fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
949 let root = directory("rust-project");
950 write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
951 write(
952 &root,
953 "Cargo.toml",
954 "[package]\nname='fixture'\nversion='0.0.0'\n",
955 );
956 let inputs = ExplicitIntegrityInputs {
957 source_files: vec!["src/lib.rs".into()],
958 test_files: vec!["src/lib.rs".into()],
959 dependency_files: vec!["Cargo.toml".into()],
960 configuration_files: Vec::new(),
961 execution_configuration: b"cargo\0test".to_vec(),
962 };
963 let result = create_explicit_run_integrity(
964 &root,
965 &inputs,
966 &FrontendIntegrityInputs::embedded_rust(),
967 )
968 .unwrap();
969 assert_eq!(result.fingerprint.algorithm, "sha256");
970 fs::remove_dir_all(root).unwrap();
971 }
972
973 #[cfg(unix)]
974 #[test]
975 fn rejects_linked_frontend_identity_files() {
976 use std::os::unix::fs::symlink;
977
978 let (root, shim) = fixture();
979 let outside = shim.join("outside.js");
980 fs::write(&outside, "outside").unwrap();
981 fs::remove_file(shim.join("instrumenter.js")).unwrap();
982 symlink(&outside, shim.join("instrumenter.js")).unwrap();
983 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
984 assert!(matches!(
985 create_run_integrity(&root, &project, &frontend(&shim)),
986 Err(IntegrityError::UnsafeFile(_))
987 ));
988 fs::remove_dir_all(root).unwrap();
989 fs::remove_dir_all(shim).unwrap();
990 }
991}