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