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