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
214const RELEASE_METADATA: &[&str] = &[
218 "author",
219 "authors",
220 "bugs",
221 "categories",
222 "classifiers",
223 "contributors",
224 "description",
225 "documentation",
226 "funding",
227 "homepage",
228 "keywords",
229 "license",
230 "license-file",
231 "maintainers",
232 "man",
233 "readme",
234 "repository",
235 "urls",
236 "version",
237];
238
239#[derive(Clone, Copy, PartialEq, Eq)]
240enum ManifestKind {
241 PackageJson,
242 PackageLock,
243 CargoToml,
244 PyprojectToml,
245}
246
247fn manifest_kind(path: &Path) -> Option<ManifestKind> {
248 match path.file_name()?.to_str()? {
249 "package.json" => Some(ManifestKind::PackageJson),
250 "package-lock.json" | "npm-shrinkwrap.json" => Some(ManifestKind::PackageLock),
251 "Cargo.toml" => Some(ManifestKind::CargoToml),
252 "pyproject.toml" => Some(ManifestKind::PyprojectToml),
253 _ => None,
254 }
255}
256
257fn behavioural_manifest(kind: ManifestKind, raw: &[u8]) -> Option<Vec<u8>> {
265 let mut value: serde_json::Value = match kind {
266 ManifestKind::PackageJson | ManifestKind::PackageLock => {
267 serde_json::from_slice(raw).ok()?
268 }
269 ManifestKind::CargoToml | ManifestKind::PyprojectToml => {
270 let text = std::str::from_utf8(raw).ok()?;
271 serde_json::to_value(toml::from_str::<toml::Value>(text).ok()?).ok()?
272 }
273 };
274 match kind {
275 ManifestKind::PackageJson => strip_metadata(&mut value, &[]),
276 ManifestKind::PackageLock => {
280 strip_metadata(&mut value, &[]);
281 strip_metadata(&mut value, &["packages", ""]);
282 }
283 ManifestKind::CargoToml => {
284 strip_metadata(&mut value, &["package"]);
285 strip_metadata(&mut value, &["workspace", "package"]);
286 }
287 ManifestKind::PyprojectToml => {
288 strip_metadata(&mut value, &["project"]);
289 strip_metadata(&mut value, &["tool", "poetry"]);
290 }
291 }
292 let mut bytes = Vec::new();
293 canonical(&value, &mut bytes);
294 Some(bytes)
295}
296
297fn strip_metadata(value: &mut serde_json::Value, path: &[&str]) {
298 let mut table = value;
299 for key in path {
300 match table.get_mut(*key) {
301 Some(next) => table = next,
302 None => return,
303 }
304 }
305 let Some(table) = table.as_object_mut() else {
306 return;
307 };
308 for key in RELEASE_METADATA {
309 table.remove(*key);
310 }
311}
312
313fn canonical(value: &serde_json::Value, out: &mut Vec<u8>) {
316 match value {
317 serde_json::Value::Null => out.push(0),
318 serde_json::Value::Bool(flag) => out.extend([1, u8::from(*flag)]),
319 serde_json::Value::Number(number) => tagged(out, 2, number.to_string().as_bytes()),
320 serde_json::Value::String(text) => tagged(out, 3, text.as_bytes()),
321 serde_json::Value::Array(items) => {
322 tagged(out, 4, &(items.len() as u64).to_le_bytes());
323 for item in items {
324 canonical(item, out);
325 }
326 }
327 serde_json::Value::Object(table) => {
328 let mut keys = table.keys().collect::<Vec<_>>();
329 keys.sort();
330 tagged(out, 5, &(keys.len() as u64).to_le_bytes());
331 for key in keys {
332 tagged(out, 6, key.as_bytes());
333 canonical(&table[key], out);
334 }
335 }
336 }
337}
338
339fn tagged(out: &mut Vec<u8>, tag: u8, bytes: &[u8]) {
340 out.push(tag);
341 out.extend((bytes.len() as u64).to_le_bytes());
342 out.extend(bytes);
343}
344
345fn digest_files(
346 root: &Path,
347 paths: impl IntoIterator<Item = PathBuf>,
348) -> Result<String, IntegrityError> {
349 digest_paths(root, paths, false)
350}
351
352fn digest_manifests(
361 root: &Path,
362 paths: impl IntoIterator<Item = PathBuf>,
363) -> Result<String, IntegrityError> {
364 digest_paths(root, paths, true)
365}
366
367fn digest_paths(
368 root: &Path,
369 paths: impl IntoIterator<Item = PathBuf>,
370 manifests: bool,
371) -> Result<String, IntegrityError> {
372 let paths = paths.into_iter().collect::<BTreeSet<_>>();
373 let mut labeled = paths
374 .into_iter()
375 .map(|path| local_path(root, &path).map(|label| (label, path)))
376 .collect::<Result<Vec<_>, _>>()?;
377 labeled.sort_by(|left, right| left.0.cmp(&right.0));
378 let mut hash = Sha256::new();
379 let mut buffer = [0_u8; 128 * 1024];
380 for (label, path) in labeled {
381 let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
382 if !metadata.file_type().is_file() {
383 return Err(IntegrityError::UnsafeFile(path));
384 }
385 hash.update(label.as_bytes());
386 hash.update([0]);
387 let meaning = manifests
391 .then(|| manifest_kind(&path))
392 .flatten()
393 .and_then(|kind| behavioural_manifest(kind, &fs::read(&path).ok()?));
394 if let Some(bytes) = meaning {
395 hash.update(&bytes);
396 } else {
397 let mut file = fs::File::open(&path).map_err(|source| io_error(&path, source))?;
398 loop {
399 let read = file
400 .read(&mut buffer)
401 .map_err(|source| io_error(&path, source))?;
402 if read == 0 {
403 break;
404 }
405 hash.update(&buffer[..read]);
406 }
407 }
408 hash.update([0]);
409 }
410 Ok(format!("{:x}", hash.finalize()))
411}
412
413fn domain_hash(domain: &str, fields: &[(&str, &[u8])]) -> String {
414 let mut hash = Sha256::new();
415 hash.update(domain.as_bytes());
416 hash.update([0]);
417 for (name, value) in fields {
418 hash.update((*name).len().to_le_bytes());
419 hash.update(name.as_bytes());
420 hash.update(value.len().to_le_bytes());
421 hash.update(value);
422 }
423 format!("{:x}", hash.finalize())
424}
425
426fn source_file(path: &Path) -> bool {
427 let lower = path
428 .file_name()
429 .and_then(|name| name.to_str())
430 .unwrap_or("")
431 .to_ascii_lowercase();
432 [
433 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
434 ".mtsx",
435 ]
436 .iter()
437 .any(|extension| lower.ends_with(extension))
438}
439
440fn skipped_directory(name: &str) -> bool {
441 [
442 ".cache",
443 ".git",
444 ".mcdc-pool",
445 ".next",
446 ".nuxt",
447 ".output",
448 ".supercov",
449 "build",
450 "coverage",
451 "dist",
452 "node_modules",
453 "out",
454 "playwright-report",
455 "results",
456 "test-results",
457 "vendor",
458 ]
459 .contains(&name)
460}
461
462fn owned_workspace_store(path: &Path) -> bool {
463 crate::workspace::owned_workspace_path(path)
464}
465
466fn walk_files(
467 directory: &Path,
468 predicate: &impl Fn(&Path) -> bool,
469 output: &mut Vec<PathBuf>,
470) -> Result<(), IntegrityError> {
471 let metadata = match fs::symlink_metadata(directory) {
472 Ok(metadata) => metadata,
473 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
474 Err(source) => return Err(io_error(directory, source)),
475 };
476 if !metadata.file_type().is_dir() {
477 return Err(IntegrityError::UnsafeFile(directory.to_owned()));
478 }
479 let mut entries = fs::read_dir(directory)
480 .map_err(|source| io_error(directory, source))?
481 .collect::<Result<Vec<_>, _>>()
482 .map_err(|source| io_error(directory, source))?;
483 entries.sort_by_key(fs::DirEntry::file_name);
484 for entry in entries {
485 let path = entry.path();
486 let file_type = entry
487 .file_type()
488 .map_err(|source| io_error(&path, source))?;
489 if file_type.is_symlink() {
490 continue;
491 }
492 if file_type.is_dir() {
493 let name = entry.file_name();
494 if !name
495 .to_str()
496 .is_some_and(|name| name.starts_with('.') || skipped_directory(name))
497 && !path.join(".git").exists()
498 && !owned_workspace_store(&path)
499 {
500 walk_files(&path, predicate, output)?;
501 }
502 } else if file_type.is_file() && predicate(&path) {
503 output.push(path);
504 }
505 }
506 Ok(())
507}
508
509fn test_file(root: &Path, path: &Path) -> bool {
510 if !source_file(path) {
511 return false;
512 }
513 let local = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
514 local
515 .to_ascii_lowercase()
516 .split(['/', '\\', '_', '.', '-'])
517 .any(|part| matches!(part, "test" | "spec"))
518}
519
520fn test_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
521 let mut files = Vec::new();
522 for directory in ["test", "tests", "__tests__"] {
523 walk_files(&root.join(directory), &source_file, &mut files)?;
524 }
525 walk_files(root, &|path| test_file(root, path), &mut files)?;
526 files.sort();
527 files.dedup();
528 Ok(files)
529}
530
531fn dependency_files(root: &Path) -> Result<Vec<PathBuf>, IntegrityError> {
532 let mut files = Vec::new();
533 walk_files(
534 root,
535 &|path| path.file_name().is_some_and(|name| name == "package.json"),
536 &mut files,
537 )?;
538 for name in [
539 "package-lock.json",
540 "npm-shrinkwrap.json",
541 "pnpm-lock.yaml",
542 "yarn.lock",
543 "bun.lock",
544 "bun.lockb",
545 ] {
546 let path = root.join(name);
547 if path.is_file() {
548 files.push(path);
549 }
550 }
551 files.sort();
552 files.dedup();
553 Ok(files)
554}
555
556fn configuration_file(path: &Path) -> bool {
557 let name = path
558 .file_name()
559 .and_then(|name| name.to_str())
560 .unwrap_or("")
561 .to_ascii_lowercase();
562 if formatting_only(&name) {
563 return false;
564 }
565 name == ".npmrc"
566 || (name.starts_with("tsconfig") && name.ends_with(".json"))
567 || name.contains(".config.")
568 || name.starts_with(".babelrc.")
569}
570
571fn formatting_only(name: &str) -> bool {
577 let stem = name.strip_prefix('.').unwrap_or(name);
578 stem.starts_with("eslint") || stem.starts_with("prettier")
579}
580
581const TRACKED_MANIFESTS: &[&str] = &[
589 "Cargo.lock",
590 "Cargo.toml",
591 "Gemfile",
592 "Gemfile.lock",
593 "Pipfile",
594 "Pipfile.lock",
595 "bun.lock",
596 "bun.lockb",
597 "npm-shrinkwrap.json",
598 "package-lock.json",
599 "package.json",
600 "pdm.lock",
601 "pnpm-lock.yaml",
602 "poetry.lock",
603 "pyproject.toml",
604 "setup.cfg",
605 "setup.py",
606 "uv.lock",
607 "yarn.lock",
608 ".ruby-version",
609 ".tool-versions",
610];
611
612pub fn tracked_manifest(path: &str) -> bool {
619 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
620 TRACKED_MANIFESTS.contains(&name)
621 || name.ends_with(".gemspec")
622 || (name.starts_with("requirements") && name.ends_with(".txt"))
623}
624
625pub fn globally_tracked(path: &str) -> bool {
626 let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
627 tracked_manifest(name) || configuration_file(Path::new(name))
628}
629
630fn configuration_files(
631 root: &Path,
632 project: &CoverageProject,
633) -> Result<Vec<PathBuf>, IntegrityError> {
634 let mut files = Vec::new();
635 walk_files(root, &configuration_file, &mut files)?;
636 files.extend(
637 [
638 project.playwright_config.as_ref(),
639 project.vitest_config.as_ref(),
640 project.jest_config.as_ref(),
641 ]
642 .into_iter()
643 .flatten()
644 .cloned(),
645 );
646 files.sort();
647 files.dedup();
648 Ok(files)
649}
650
651fn git_integrity(root: &Path) -> Option<GitIntegrity> {
652 let revision = Command::new("git")
653 .args(["rev-parse", "HEAD"])
654 .current_dir(root)
655 .output()
656 .ok();
657 let status = Command::new("git")
658 .args(["status", "--porcelain=v1"])
659 .current_dir(root)
660 .output()
661 .ok();
662 if !revision
663 .as_ref()
664 .is_some_and(|output| output.status.success())
665 && !status
666 .as_ref()
667 .is_some_and(|output| output.status.success())
668 {
669 return None;
670 }
671 Some(GitIntegrity {
672 revision: revision
673 .filter(|output| output.status.success())
674 .and_then(|output| String::from_utf8(output.stdout).ok())
675 .map(|revision| revision.trim().to_owned()),
676 dirty: !status
677 .as_ref()
678 .is_some_and(|output| output.status.success() && output.stdout.is_empty()),
679 })
680}
681
682pub fn create_run_integrity(
683 root: &Path,
684 project: &CoverageProject,
685 frontend: &FrontendIntegrityInputs,
686) -> Result<RunIntegrity, IntegrityError> {
687 if !valid_sha256(&frontend.engine_instrumenter_sha256) {
688 return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
689 }
690 if !valid_sha256(&frontend.engine_execution_sha256) {
691 return Err(IntegrityError::InvalidEngineDigest("execution engine"));
692 }
693 let tests = test_files(root)?;
694 let dependencies = dependency_files(root)?;
695 let configuration = configuration_files(root, project)?;
696 let covered_elsewhere = tests
709 .iter()
710 .chain(dependencies.iter())
711 .chain(configuration.iter())
712 .collect::<std::collections::BTreeSet<_>>();
713 let source_paths = project
714 .source_files
715 .iter()
716 .map(|path| root.join(path))
717 .chain(
718 project
719 .source_scope
720 .entries
721 .iter()
722 .filter(|entry| !entry.is_generated_output())
723 .map(|entry| root.join(&entry.file))
724 .filter(|path| !covered_elsewhere.contains(path)),
725 )
726 .collect::<Vec<_>>();
727 let source = digest_files(root, source_paths)?;
728 let tests_digest = digest_files(root, tests.iter().cloned())?;
729 let dependency_digest = digest_manifests(root, dependencies)?;
730 let configuration_digest = digest_files(
731 root,
732 configuration
733 .into_iter()
734 .chain(crate::typescript_imports::config_paths(
735 root,
736 &project.source_files,
737 )),
738 )?;
739 let frontend_instrumenter =
740 digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
741 let frontend_execution =
742 digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
743 let instrumenter = domain_hash(
744 "supercov-run-instrumenter-v1",
745 &[
746 ("language", frontend.language.as_bytes()),
747 ("version", frontend.version.as_bytes()),
748 ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
749 ("shim", frontend_instrumenter.as_bytes()),
750 (
753 "executionEngine",
754 frontend.engine_execution_sha256.as_bytes(),
755 ),
756 ("executionShim", frontend_execution.as_bytes()),
757 ],
758 );
759 let build_environment = frontend_map_bytes(&project.build_environment);
760 let execution = domain_hash(
766 "supercov-run-execution-v1",
767 &[
768 ("language", frontend.language.as_bytes()),
769 ("version", frontend.version.as_bytes()),
770 ("source", source.as_bytes()),
771 ("dependencies", dependency_digest.as_bytes()),
772 ("configuration", configuration_digest.as_bytes()),
773 ("buildEnvironment", &build_environment),
774 ],
775 );
776 let combined = domain_hash(
777 "supercov-run-combined-v1",
778 &[
779 ("language", frontend.language.as_bytes()),
780 ("version", frontend.version.as_bytes()),
781 ("source", source.as_bytes()),
782 ("tests", tests_digest.as_bytes()),
783 ("dependencies", dependency_digest.as_bytes()),
784 ("configuration", configuration_digest.as_bytes()),
785 ("instrumenter", instrumenter.as_bytes()),
786 ],
787 );
788 Ok(RunIntegrity {
789 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
790 instrumenter_version: frontend.version.clone(),
791 git: git_integrity(root),
792 fingerprint: RunFingerprint {
793 algorithm: "sha256".into(),
794 source,
795 tests: tests_digest,
796 dependencies: dependency_digest,
797 configuration: configuration_digest,
798 instrumenter,
799 execution,
800 combined,
801 source_files: project.source_files.len(),
802 test_files: tests.len(),
803 },
804 stale: None,
805 stale_reasons: None,
806 })
807}
808
809pub fn create_explicit_run_integrity(
812 root: &Path,
813 inputs: &ExplicitIntegrityInputs,
814 frontend: &FrontendIntegrityInputs,
815) -> Result<RunIntegrity, IntegrityError> {
816 if !valid_sha256(&frontend.engine_instrumenter_sha256) {
817 return Err(IntegrityError::InvalidEngineDigest("instrumenter engine"));
818 }
819 if !valid_sha256(&frontend.engine_execution_sha256) {
820 return Err(IntegrityError::InvalidEngineDigest("execution engine"));
821 }
822 let source = digest_files(root, inputs.source_files.iter().map(|path| root.join(path)))?;
823 let tests = digest_files(root, inputs.test_files.iter().map(|path| root.join(path)))?;
824 let dependencies = digest_manifests(
825 root,
826 inputs.dependency_files.iter().map(|path| root.join(path)),
827 )?;
828 let configuration = digest_files(
829 root,
830 inputs
831 .configuration_files
832 .iter()
833 .map(|path| root.join(path)),
834 )?;
835 let frontend_instrumenter =
836 digest_files(&frontend.root, frontend.instrumenter_files.iter().cloned())?;
837 let frontend_execution =
838 digest_files(&frontend.root, frontend.execution_files.iter().cloned())?;
839 let instrumenter = domain_hash(
840 "supercov-run-instrumenter-v1",
841 &[
842 ("language", frontend.language.as_bytes()),
843 ("version", frontend.version.as_bytes()),
844 ("engine", frontend.engine_instrumenter_sha256.as_bytes()),
845 ("shim", frontend_instrumenter.as_bytes()),
846 (
849 "executionEngine",
850 frontend.engine_execution_sha256.as_bytes(),
851 ),
852 ("executionShim", frontend_execution.as_bytes()),
853 ],
854 );
855 let execution = domain_hash(
861 "supercov-run-execution-v1",
862 &[
863 ("language", frontend.language.as_bytes()),
864 ("version", frontend.version.as_bytes()),
865 ("source", source.as_bytes()),
866 ("dependencies", dependencies.as_bytes()),
867 ("configuration", configuration.as_bytes()),
868 ("executionConfiguration", &inputs.execution_configuration),
869 ],
870 );
871 let combined = domain_hash(
872 "supercov-run-combined-v1",
873 &[
874 ("language", frontend.language.as_bytes()),
875 ("version", frontend.version.as_bytes()),
876 ("source", source.as_bytes()),
877 ("tests", tests.as_bytes()),
878 ("dependencies", dependencies.as_bytes()),
879 ("configuration", configuration.as_bytes()),
880 ("instrumenter", instrumenter.as_bytes()),
881 ("execution", execution.as_bytes()),
882 ],
883 );
884 Ok(RunIntegrity {
885 schema_version: RUN_INTEGRITY_SCHEMA_VERSION,
886 instrumenter_version: format!("supercov-{}-{}", frontend.language, frontend.version),
887 git: git_integrity(root),
888 fingerprint: RunFingerprint {
889 algorithm: "sha256".into(),
893 source,
894 tests,
895 dependencies,
896 configuration,
897 instrumenter,
898 execution,
899 combined,
900 source_files: inputs.source_files.len(),
901 test_files: inputs.test_files.len(),
902 },
903 stale: None,
904 stale_reasons: None,
905 })
906}
907
908fn frontend_map_bytes(values: &std::collections::BTreeMap<String, String>) -> Vec<u8> {
909 let mut bytes = Vec::new();
910 for (key, value) in values {
911 bytes.extend_from_slice(&key.len().to_le_bytes());
912 bytes.extend_from_slice(key.as_bytes());
913 bytes.extend_from_slice(&value.len().to_le_bytes());
914 bytes.extend_from_slice(value.as_bytes());
915 }
916 bytes
917}
918
919#[cfg(test)]
920mod tests {
921 use std::{
922 collections::BTreeMap,
923 fs,
924 sync::atomic::{AtomicU64, Ordering},
925 time::{SystemTime, UNIX_EPOCH},
926 };
927
928 use crate::{project_discovery::discover_coverage_project, run_store::compare_run_integrity};
929
930 use super::*;
931
932 static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
933
934 fn directory(label: &str) -> PathBuf {
935 let nonce = SystemTime::now()
936 .duration_since(UNIX_EPOCH)
937 .unwrap()
938 .as_nanos();
939 let root = std::env::temp_dir().join(format!(
940 "supercov-integrity-{label}-{}-{nonce}-{}",
941 std::process::id(),
942 TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed)
943 ));
944 fs::create_dir_all(&root).unwrap();
945 root
946 }
947
948 const PACKAGE: &str = r#"{"name":"g","version":"1.0.0","dependencies":{"a":"^1.2.3"}}"#;
949 const LOCK: &str = r#"{"name":"g","version":"1.0.0","packages":{"":{"name":"g","version":"1.0.0"},"node_modules/a":{"version":"1.2.3"}}}"#;
950 const CARGO: &str =
951 "[package]\nname = \"g\"\nversion = \"1.0.0\"\n\n[dependencies]\na = \"1.2.3\"\n";
952 const PYPROJECT: &str =
953 "[project]\nname = \"g\"\nversion = \"1.0.0\"\ndependencies = [\"a==1.2.3\"]\n";
954
955 #[test]
956 fn a_release_bump_leaves_a_manifest_digest_alone() {
957 for (kind, before, after) in [
961 (
962 ManifestKind::PackageJson,
963 PACKAGE.to_owned(),
964 PACKAGE.replace("1.0.0", "2.0.0"),
965 ),
966 (
967 ManifestKind::PackageLock,
968 LOCK.to_owned(),
969 LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
970 ),
971 (
972 ManifestKind::CargoToml,
973 CARGO.to_owned(),
974 CARGO.replace("1.0.0", "2.0.0"),
975 ),
976 (
977 ManifestKind::PyprojectToml,
978 PYPROJECT.to_owned(),
979 PYPROJECT.replace("1.0.0", "2.0.0"),
980 ),
981 ] {
982 let stable = behavioural_manifest(kind, before.as_bytes());
983 assert!(stable.is_some());
984 assert_eq!(stable, behavioural_manifest(kind, after.as_bytes()));
985 }
986 }
987
988 #[test]
989 fn a_dependency_change_still_moves_a_manifest_digest() {
990 for (kind, before, after) in [
993 (
994 ManifestKind::PackageJson,
995 PACKAGE.to_owned(),
996 PACKAGE.replace("^1.2.3", "^2.0.0"),
997 ),
998 (
999 ManifestKind::PackageLock,
1000 LOCK.to_owned(),
1001 LOCK.replace(
1002 "\"node_modules/a\":{\"version\":\"1.2.3\"}",
1003 "\"node_modules/a\":{\"version\":\"9.9.9\"}",
1004 ),
1005 ),
1006 (
1007 ManifestKind::CargoToml,
1008 CARGO.to_owned(),
1009 CARGO.replace("a = \"1.2.3\"", "a = \"9.9.9\""),
1010 ),
1011 (
1012 ManifestKind::PyprojectToml,
1013 PYPROJECT.to_owned(),
1014 PYPROJECT.replace("a==1.2.3", "a==9.9.9"),
1015 ),
1016 ] {
1017 assert_ne!(
1018 behavioural_manifest(kind, before.as_bytes()),
1019 behavioural_manifest(kind, after.as_bytes())
1020 );
1021 }
1022 }
1023
1024 #[test]
1025 fn the_dependency_fingerprint_survives_a_release_but_not_an_upgrade() {
1026 let root = directory("manifest-fingerprint");
1030 let paths = || [root.join("package.json"), root.join("package-lock.json")];
1031 write(&root, "package.json", PACKAGE);
1032 write(&root, "package-lock.json", LOCK);
1033 let before = digest_manifests(&root, paths()).unwrap();
1034
1035 write(&root, "package.json", &PACKAGE.replace("1.0.0", "2.0.0"));
1036 write(
1037 &root,
1038 "package-lock.json",
1039 &LOCK.replace("\"version\":\"1.0.0\"", "\"version\":\"2.0.0\""),
1040 );
1041 assert_eq!(
1042 before,
1043 digest_manifests(&root, paths()).unwrap(),
1044 "a release must not move the dependency fingerprint"
1045 );
1046
1047 write(&root, "package.json", &PACKAGE.replace("^1.2.3", "^2.0.0"));
1048 assert_ne!(
1049 before,
1050 digest_manifests(&root, paths()).unwrap(),
1051 "an upgrade must still move it"
1052 );
1053 fs::remove_dir_all(root).unwrap();
1054 }
1055
1056 #[test]
1057 fn reformatting_a_manifest_leaves_its_digest_alone() {
1058 let reordered = r#"{"dependencies":{"a":"^1.2.3"}, "version":"1.0.0",
1061 "name":"g"}"#;
1062 assert_eq!(
1063 behavioural_manifest(ManifestKind::PackageJson, PACKAGE.as_bytes()),
1064 behavioural_manifest(ManifestKind::PackageJson, reordered.as_bytes())
1065 );
1066 }
1067
1068 #[test]
1069 fn an_unreadable_manifest_falls_back_to_its_bytes() {
1070 assert!(behavioural_manifest(ManifestKind::PackageJson, b"{ not json").is_none());
1073 assert!(behavioural_manifest(ManifestKind::CargoToml, b"[[[").is_none());
1074 let root = directory("manifest-fallback");
1075 write(&root, "package.json", "{ not json");
1076 let first = digest_manifests(&root, [root.join("package.json")]).unwrap();
1077 write(&root, "package.json", "{ still not json");
1078 assert_ne!(
1079 first,
1080 digest_manifests(&root, [root.join("package.json")]).unwrap()
1081 );
1082 fs::remove_dir_all(root).unwrap();
1083 }
1084
1085 #[test]
1086 fn linter_and_formatter_settings_are_not_execution_context() {
1087 for inert in [
1091 ".prettierrc",
1092 ".prettierrc.json",
1093 "prettier.config.js",
1094 ".eslintrc",
1095 ".eslintrc.json",
1096 "eslint.config.mjs",
1097 ] {
1098 assert!(!configuration_file(Path::new(inert)), "{inert}");
1099 }
1100 for real in ["tsconfig.json", ".babelrc.js", "vite.config.ts", ".npmrc"] {
1101 assert!(configuration_file(Path::new(real)), "{real}");
1102 }
1103 }
1104
1105 #[test]
1106 fn globally_tracked_names_what_the_fingerprint_already_covers() {
1107 for tracked in [
1108 "package-lock.json",
1109 "package.json",
1110 "Cargo.toml",
1111 "Gemfile.lock",
1112 "requirements-dev.txt",
1113 "supercov.gemspec",
1114 "tsconfig.json",
1115 "nested/pyproject.toml",
1116 ] {
1117 assert!(globally_tracked(tracked), "{tracked}");
1118 }
1119 for own in [
1120 "src/index.ts",
1121 "tests/helpers/gateway-process.ts",
1122 "README.md",
1123 ] {
1124 assert!(!globally_tracked(own), "{own}");
1125 }
1126 }
1127
1128 fn write(root: &Path, path: &str, contents: &str) {
1129 let path = root.join(path);
1130 fs::create_dir_all(path.parent().unwrap()).unwrap();
1131 fs::write(path, contents).unwrap();
1132 }
1133
1134 fn frontend(root: &Path) -> FrontendIntegrityInputs {
1135 FrontendIntegrityInputs {
1136 language: "javascript".into(),
1137 version: "javascript-v1".into(),
1138 root: root.to_owned(),
1139 instrumenter_files: vec![root.join("instrumenter.js")],
1140 execution_files: vec![root.join("runtime.mjs")],
1141 engine_instrumenter_sha256: env!("SUPERCOV_JS_FRONTEND_SOURCE_SHA256").into(),
1142 engine_execution_sha256: env!("SUPERCOV_ENGINE_SOURCE_SHA256").into(),
1143 }
1144 }
1145
1146 fn fixture() -> (PathBuf, PathBuf) {
1147 let root = directory("project");
1148 let shim = directory("shim");
1149 write(
1150 &root,
1151 "package.json",
1152 r#"{"scripts":{"build":"vite build","test":"node --test"}}"#,
1153 );
1154 write(&root, "package-lock.json", "lock");
1155 write(&root, "src/index.ts", "export const ready = true");
1156 write(&root, "tests/index.test.ts", "test('ready', () => {})");
1157 write(&root, "vite.config.ts", "export default {}");
1158 write(&root, ".cache/test262/fake.test.js", "ignored");
1159 write(
1160 &root,
1161 "supercov/.supercov-workspace-store",
1162 "Supercov instrumented workspace. Safe to delete.\n",
1163 );
1164 write(
1165 &root,
1166 "supercov/workspace/copy/tests/copied.test.ts",
1167 "ignored copied test",
1168 );
1169 write(&shim, "instrumenter.js", "instrument");
1170 write(&shim, "runtime.mjs", "runtime");
1171 (root, shim)
1172 }
1173
1174 fn integrity(root: &Path, shim: &Path, environment: &BTreeMap<String, String>) -> RunIntegrity {
1175 let project = discover_coverage_project(root, environment, &[]).unwrap();
1176 create_run_integrity(root, &project, &frontend(shim)).unwrap()
1177 }
1178
1179 #[test]
1180 fn built_assets_the_command_regenerates_do_not_move_the_source_fingerprint() {
1181 let (root, shim) = fixture();
1185 write(
1186 &root,
1187 "package.json",
1188 r#"{"workspaces":["app_extensions/*"],"scripts":{"test":"node --test"}}"#,
1189 );
1190 write(&root, "app_extensions/upsells/package.json", "{}");
1191 write(&root, "app_extensions/upsells/frontend/embed.ts", "source");
1192 write(
1193 &root,
1194 "app_extensions/upsells/assets/app-embed-Be-aUw9g.js",
1195 "bundle one",
1196 );
1197 let first = integrity(&root, &shim, &BTreeMap::new());
1198
1199 fs::remove_file(root.join("app_extensions/upsells/assets/app-embed-Be-aUw9g.js")).unwrap();
1200 write(
1201 &root,
1202 "app_extensions/upsells/assets/app-embed-CygpnWPQ.js",
1203 "bundle two",
1204 );
1205 let rebuilt = integrity(&root, &shim, &BTreeMap::new());
1206 assert_eq!(rebuilt.fingerprint.source, first.fingerprint.source);
1207 assert!(!compare_run_integrity(Some(&first), &rebuilt).stale);
1208
1209 write(
1210 &root,
1211 "app_extensions/upsells/frontend/embed.ts",
1212 "edited source",
1213 );
1214 let edited = integrity(&root, &shim, &BTreeMap::new());
1215 assert_ne!(edited.fingerprint.source, first.fingerprint.source);
1216 fs::remove_dir_all(root).unwrap();
1217 fs::remove_dir_all(shim).unwrap();
1218 }
1219
1220 #[test]
1221 fn fingerprints_every_independent_input_domain_deterministically() {
1222 let (root, shim) = fixture();
1223 let first = integrity(&root, &shim, &BTreeMap::new());
1224 let second = integrity(&root, &shim, &BTreeMap::new());
1225 assert_eq!(first, second);
1226 assert_eq!(first.fingerprint.source_files, 1);
1227 assert_eq!(first.fingerprint.test_files, 1);
1228 for digest in [
1229 &first.fingerprint.source,
1230 &first.fingerprint.tests,
1231 &first.fingerprint.dependencies,
1232 &first.fingerprint.configuration,
1233 &first.fingerprint.instrumenter,
1234 &first.fingerprint.execution,
1235 &first.fingerprint.combined,
1236 ] {
1237 assert!(valid_sha256(digest));
1238 }
1239
1240 write(&root, "src/index.ts", "export const ready = false");
1241 let source = integrity(&root, &shim, &BTreeMap::new());
1242 assert_ne!(source.fingerprint.source, first.fingerprint.source);
1243 assert_eq!(source.fingerprint.tests, first.fingerprint.tests);
1244 assert_ne!(source.fingerprint.execution, first.fingerprint.execution);
1245
1246 write(&root, "src/index.ts", "export const ready = true");
1247 write(&root, "tests/index.test.ts", "test('changed', () => {})");
1248 let tests = integrity(&root, &shim, &BTreeMap::new());
1249 assert_eq!(tests.fingerprint.source, first.fingerprint.source);
1250 assert_ne!(tests.fingerprint.tests, first.fingerprint.tests);
1251 assert_eq!(tests.fingerprint.execution, first.fingerprint.execution);
1252
1253 write(&root, "tests/index.test.ts", "test('ready', () => {})");
1254 write(&root, "package-lock.json", "changed lock");
1255 let dependencies = integrity(&root, &shim, &BTreeMap::new());
1256 assert_ne!(
1257 dependencies.fingerprint.dependencies,
1258 first.fingerprint.dependencies
1259 );
1260 assert_ne!(
1261 dependencies.fingerprint.execution,
1262 first.fingerprint.execution
1263 );
1264
1265 write(&root, "package-lock.json", "lock");
1266 write(&root, "vite.config.ts", "export default { changed: true }");
1267 let configuration = integrity(&root, &shim, &BTreeMap::new());
1268 assert_ne!(
1269 configuration.fingerprint.configuration,
1270 first.fingerprint.configuration
1271 );
1272
1273 write(&root, "vite.config.ts", "export default {}");
1274 write(&shim, "instrumenter.js", "changed instrumenter");
1275 let instrumenter = integrity(&root, &shim, &BTreeMap::new());
1276 assert_ne!(
1277 instrumenter.fingerprint.instrumenter,
1278 first.fingerprint.instrumenter
1279 );
1280 assert_ne!(
1281 instrumenter.fingerprint.combined,
1282 first.fingerprint.combined
1283 );
1284 fs::remove_dir_all(root).unwrap();
1285 fs::remove_dir_all(shim).unwrap();
1286 }
1287
1288 #[test]
1289 fn assertion_inputs_ignore_tool_worktrees_and_nested_repositories() {
1290 let (root, shim) = fixture();
1291 write(&root, "packages/ui/package.json", r#"{"name":"ui"}"#);
1292 write(
1293 &root,
1294 "packages/ui/tests/ui.test.ts",
1295 "import assert from 'node:assert/strict'; assert.equal(1, 1);",
1296 );
1297 let before = integrity(&root, &shim, &BTreeMap::new());
1298 for base in [".claude/worktrees/other", "nested-fork"] {
1299 write(
1300 &root,
1301 &format!("{base}/.git"),
1302 "gitdir: /unrelated/repository",
1303 );
1304 write(
1305 &root,
1306 &format!("{base}/tests/other.test.ts"),
1307 "assert.equal(2, 2);",
1308 );
1309 write(&root, &format!("{base}/package.json"), "{}");
1310 write(&root, &format!("{base}/tsconfig.json"), "{}");
1311 }
1312 let after = integrity(&root, &shim, &BTreeMap::new());
1313 assert_eq!(before.fingerprint, after.fingerprint);
1314 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1315 let paths = javascript_assertion_paths(&root, &project).unwrap();
1316 let inputs = crate::assertion_inputs::capture(&root, "javascript", paths).unwrap();
1317 assert!(inputs.files.contains_key("packages/ui/tests/ui.test.ts"));
1318 assert!(
1319 !inputs
1320 .files
1321 .keys()
1322 .any(|p| p.starts_with(".claude/") || p.starts_with("nested-fork/"))
1323 );
1324 fs::remove_dir_all(root).unwrap();
1325 fs::remove_dir_all(shim).unwrap();
1326 }
1327
1328 #[test]
1329 fn a_new_supercov_moves_its_own_identity_and_nothing_else() {
1330 let (root, shim) = fixture();
1335 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1336 let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1337 let mut newer = frontend(&shim);
1338 newer.engine_instrumenter_sha256 = "b".repeat(64);
1339 newer.engine_execution_sha256 = "c".repeat(64);
1340 let upgraded = create_run_integrity(&root, &project, &newer).unwrap();
1341
1342 assert_ne!(
1343 baseline.fingerprint.instrumenter, upgraded.fingerprint.instrumenter,
1344 "a merge and the build caches still have to see this"
1345 );
1346 assert_eq!(
1347 baseline.fingerprint.execution, upgraded.fingerprint.execution,
1348 "the run's setup did not change"
1349 );
1350 assert!(
1351 !compare_run_integrity(Some(&baseline), &upgraded).stale,
1352 "upgrading Supercov must not discard a recorded run"
1353 );
1354 fs::remove_dir_all(root).unwrap();
1355 fs::remove_dir_all(shim).unwrap();
1356 }
1357
1358 #[test]
1359 fn fingerprints_nested_workspace_manifests_and_execution_environment() {
1360 let (root, shim) = fixture();
1361 write(
1362 &root,
1363 "packages/ui/package.json",
1364 r#"{"dependencies":{"react":"1"}}"#,
1365 );
1366 write(&root, "packages/ui/src/index.ts", "export const ui = true");
1367 let first = integrity(&root, &shim, &BTreeMap::new());
1368 write(
1369 &root,
1370 "packages/ui/package.json",
1371 r#"{"dependencies":{"react":"2"}}"#,
1372 );
1373 let dependency = integrity(&root, &shim, &BTreeMap::new());
1374 assert_ne!(
1375 first.fingerprint.dependencies,
1376 dependency.fingerprint.dependencies
1377 );
1378
1379 let mut environment = BTreeMap::new();
1380 environment.insert("SUPERCOV_SOURCE_ROOTS".into(), "src,packages/ui/src".into());
1381 let project = discover_coverage_project(&root, &environment, &[]).unwrap();
1382 let mut project_with_build_environment = project.clone();
1383 project_with_build_environment
1384 .build_environment
1385 .insert("MODE".into(), "test".into());
1386 let changed =
1387 create_run_integrity(&root, &project_with_build_environment, &frontend(&shim)).unwrap();
1388 let baseline = create_run_integrity(&root, &project, &frontend(&shim)).unwrap();
1389 assert_ne!(
1390 baseline.fingerprint.execution,
1391 changed.fingerprint.execution
1392 );
1393 assert_eq!(baseline.fingerprint.combined, changed.fingerprint.combined);
1394 assert_eq!(
1395 compare_run_integrity(Some(&baseline), &changed).reasons,
1396 ["execution environment changed"]
1397 );
1398 fs::remove_dir_all(root).unwrap();
1399 fs::remove_dir_all(shim).unwrap();
1400 }
1401
1402 #[test]
1403 fn explicit_language_integrity_uses_the_frozen_store_digest_label() {
1404 let root = directory("rust-project");
1405 write(&root, "src/lib.rs", "pub fn ready() -> bool { true }");
1406 write(
1407 &root,
1408 "Cargo.toml",
1409 "[package]\nname='fixture'\nversion='0.0.0'\n",
1410 );
1411 let inputs = ExplicitIntegrityInputs {
1412 source_files: vec!["src/lib.rs".into()],
1413 test_files: vec!["src/lib.rs".into()],
1414 dependency_files: vec!["Cargo.toml".into()],
1415 configuration_files: Vec::new(),
1416 execution_configuration: b"cargo\0test".to_vec(),
1417 };
1418 let result = create_explicit_run_integrity(
1419 &root,
1420 &inputs,
1421 &FrontendIntegrityInputs::embedded_rust(),
1422 )
1423 .unwrap();
1424 assert_eq!(result.fingerprint.algorithm, "sha256");
1425 fs::remove_dir_all(root).unwrap();
1426 }
1427
1428 #[cfg(unix)]
1429 #[test]
1430 fn rejects_linked_frontend_identity_files() {
1431 use std::os::unix::fs::symlink;
1432
1433 let (root, shim) = fixture();
1434 let outside = shim.join("outside.js");
1435 fs::write(&outside, "outside").unwrap();
1436 fs::remove_file(shim.join("instrumenter.js")).unwrap();
1437 symlink(&outside, shim.join("instrumenter.js")).unwrap();
1438 let project = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1439 assert!(matches!(
1440 create_run_integrity(&root, &project, &frontend(&shim)),
1441 Err(IntegrityError::UnsafeFile(_))
1442 ));
1443 fs::remove_dir_all(root).unwrap();
1444 fs::remove_dir_all(shim).unwrap();
1445 }
1446}