1use crate::{LockedPackage, LockfileGraph, shared_local_dep_path};
30use serde::Serialize;
31use std::collections::BTreeMap;
32
33fn child_dep_path(alias: &str, tail: &str) -> String {
51 shared_local_dep_path(alias, tail).unwrap_or_else(|| format!("{alias}@{tail}"))
52}
53
54use aube_util::collections::FxMap as FxHashMap;
55use aube_util::collections::FxSet as FxHashSet;
56
57pub type AllowBuildFn<'a> = &'a dyn Fn(&LockedPackage) -> bool;
63
64#[derive(Debug, Clone)]
68pub struct EngineName(pub String);
69
70pub fn engine_name_default(node_version: &str) -> EngineName {
77 let os = std::env::consts::OS;
78 let arch = node_arch(std::env::consts::ARCH);
79 let major = node_version
80 .trim_start_matches('v')
81 .split('.')
82 .next()
83 .unwrap_or("");
84 EngineName(format!("{os}-{arch}-node{major}"))
85}
86
87fn node_arch(rust_arch: &str) -> &str {
92 match rust_arch {
93 "x86_64" => "x64",
94 "aarch64" => "arm64",
95 "x86" => "ia32",
96 "powerpc64" => "ppc64",
97 "powerpc" => "ppc",
98 other => other,
99 }
100}
101
102#[derive(Debug, Default, Clone)]
104pub struct GraphHashes {
105 pub node_hash: BTreeMap<String, String>,
107}
108
109impl GraphHashes {
110 pub fn hashed_dep_path(&self, dep_path: &str) -> String {
115 match self.node_hash.get(dep_path) {
116 Some(hex) => append_hex_to_leaf(dep_path, hex),
117 None => dep_path.to_string(),
118 }
119 }
120}
121
122fn append_hex_to_leaf(dep_path: &str, hex: &str) -> String {
128 let short = &hex[..hex.len().min(16)];
133 match dep_path.rfind('/') {
134 Some(i) => format!("{}/{}-{}", &dep_path[..i], &dep_path[i + 1..], short),
135 None => format!("{dep_path}-{short}"),
136 }
137}
138
139pub type PatchHashFn<'a> = &'a dyn Fn(&str, &str) -> Option<String>;
145
146pub type ContentHashFn<'a> = &'a dyn Fn(&str) -> Option<String>;
161
162pub fn compute_graph_hashes(
168 graph: &LockfileGraph,
169 allow_build: AllowBuildFn<'_>,
170 engine: Option<&EngineName>,
171) -> GraphHashes {
172 compute_graph_hashes_with_patches(graph, allow_build, engine, &|_, _| None)
173}
174
175pub fn compute_graph_hashes_with_patches(
179 graph: &LockfileGraph,
180 allow_build: AllowBuildFn<'_>,
181 engine: Option<&EngineName>,
182 patch_hash: PatchHashFn<'_>,
183) -> GraphHashes {
184 compute_graph_hashes_full(graph, allow_build, engine, patch_hash, &|_| None)
185}
186
187pub fn compute_graph_hashes_full(
193 graph: &LockfileGraph,
194 allow_build: AllowBuildFn<'_>,
195 engine: Option<&EngineName>,
196 patch_hash: PatchHashFn<'_>,
197 content_hash: ContentHashFn<'_>,
198) -> GraphHashes {
199 let mut builds: FxHashSet<String> = FxHashSet::default();
202 for (dep_path, pkg) in &graph.packages {
203 if allow_build(pkg) {
204 builds.insert(dep_path.clone());
205 }
206 }
207
208 let mut deps_hash_cache: FxHashMap<String, String> = FxHashMap::default();
210 for dep_path in graph.packages.keys() {
211 let _ = calc_deps_hash(
212 graph,
213 dep_path,
214 &mut deps_hash_cache,
215 &mut FxHashSet::default(),
216 patch_hash,
217 content_hash,
218 );
219 }
220
221 let mut requires_build_cache: FxHashMap<String, bool> = FxHashMap::default();
224 for dep_path in graph.packages.keys() {
225 transitively_requires_build(
226 graph,
227 &builds,
228 dep_path,
229 &mut requires_build_cache,
230 &mut FxHashSet::default(),
231 );
232 }
233
234 let mut node_hash: BTreeMap<String, String> = BTreeMap::new();
236 for dep_path in graph.packages.keys() {
237 let include_engine =
238 engine.is_some() && *requires_build_cache.get(dep_path).unwrap_or(&false);
239 let engine_str = if include_engine {
240 Some(engine.unwrap().0.as_str())
241 } else {
242 None
243 };
244 let deps_hash = deps_hash_cache.get(dep_path).cloned().unwrap_or_default();
245 let hex = hash_canonical(&NodeHashInput {
246 engine: engine_str,
247 deps: &deps_hash,
248 });
249 node_hash.insert(dep_path.clone(), hex);
250 }
251
252 GraphHashes { node_hash }
253}
254
255fn calc_deps_hash(
264 graph: &LockfileGraph,
265 dep_path: &str,
266 cache: &mut FxHashMap<String, String>,
267 parents: &mut FxHashSet<String>,
268 patch_hash: PatchHashFn<'_>,
269 content_hash: ContentHashFn<'_>,
270) -> String {
271 if let Some(cached) = cache.get(dep_path) {
272 return cached.clone();
273 }
274 if !parents.insert(dep_path.to_string()) {
275 return String::new();
280 }
281
282 let hash = match graph.packages.get(dep_path) {
283 Some(pkg) => {
284 let id = full_pkg_id(pkg, patch_hash, content_hash(dep_path).as_deref());
285 let mut deps: BTreeMap<String, String> = BTreeMap::new();
286 for (alias, child_tail) in &pkg.dependencies {
287 let child_dep_path = child_dep_path(alias, child_tail);
288 if !graph.packages.contains_key(&child_dep_path) {
292 continue;
293 }
294 let child_hash = calc_deps_hash(
295 graph,
296 &child_dep_path,
297 cache,
298 parents,
299 patch_hash,
300 content_hash,
301 );
302 deps.insert(alias.clone(), child_hash);
303 }
304 hash_canonical(&DepsHashInput {
305 id: &id,
306 deps: &deps,
307 })
308 }
309 None => String::new(),
310 };
311
312 parents.remove(dep_path);
313 cache.insert(dep_path.to_string(), hash.clone());
314 hash
315}
316
317fn transitively_requires_build(
320 graph: &LockfileGraph,
321 builds: &FxHashSet<String>,
322 dep_path: &str,
323 cache: &mut FxHashMap<String, bool>,
324 parents: &mut FxHashSet<String>,
325) -> bool {
326 if let Some(&cached) = cache.get(dep_path) {
327 return cached;
328 }
329 if builds.contains(dep_path) {
330 cache.insert(dep_path.to_string(), true);
331 return true;
332 }
333 if !parents.insert(dep_path.to_string()) {
334 return false;
335 }
336 let result = match graph.packages.get(dep_path) {
337 Some(pkg) => pkg.dependencies.iter().any(|(alias, tail)| {
338 let child_dep_path = child_dep_path(alias, tail);
339 transitively_requires_build(graph, builds, &child_dep_path, cache, parents)
340 }),
341 None => false,
342 };
343 parents.remove(dep_path);
344 cache.insert(dep_path.to_string(), result);
345 result
346}
347
348pub fn content_affected_dep_paths(graph: &LockfileGraph) -> FxHashSet<String> {
376 let mut parents_of: FxHashMap<String, Vec<String>> = FxHashMap::default();
377 let mut stack: Vec<String> = Vec::new();
378 for (dep_path, pkg) in &graph.packages {
379 if pkg
380 .local_source
381 .as_ref()
382 .is_some_and(|source| source.is_globally_shareable())
383 {
384 stack.push(dep_path.clone());
385 }
386 for (alias, child_tail) in &pkg.dependencies {
387 let child = child_dep_path(alias, child_tail);
388 if graph.packages.contains_key(&child) {
389 parents_of.entry(child).or_default().push(dep_path.clone());
390 }
391 }
392 }
393 let mut affected: FxHashSet<String> = FxHashSet::default();
394 while let Some(dep_path) = stack.pop() {
395 if !affected.insert(dep_path.clone()) {
396 continue;
397 }
398 if let Some(parents) = parents_of.get(&dep_path) {
399 stack.extend(parents.iter().cloned());
400 }
401 }
402 affected
403}
404
405fn full_pkg_id(pkg: &LockedPackage, patch_hash: PatchHashFn<'_>, content: Option<&str>) -> String {
416 let integrity = pkg.integrity.as_deref().unwrap_or("<no-integrity>");
417 let source = pkg
418 .local_source
419 .as_ref()
420 .map(|source| format!(":source:{}", source.specifier()))
421 .unwrap_or_default();
422 let content = content
423 .map(|hex| format!(":content:{hex}"))
424 .unwrap_or_default();
425 let patch = patch_hash(&pkg.name, &pkg.version).or_else(|| {
435 let registry_name = pkg.registry_name();
436 (registry_name != pkg.name)
437 .then(|| patch_hash(registry_name, &pkg.version))
438 .flatten()
439 });
440 match patch {
441 Some(hex) => format!(
442 "{}@{}:patch:{hex}{source}{content}:{integrity}",
443 pkg.name, pkg.version
444 ),
445 None => format!("{}@{}{source}{content}:{integrity}", pkg.name, pkg.version),
446 }
447}
448
449fn hash_canonical<T: Serialize>(value: &T) -> String {
454 let json = serde_json::to_vec(value).expect("graph hash input must serialize");
455 blake3::hash(&json).to_hex().to_string()
456}
457
458#[derive(Serialize)]
459struct NodeHashInput<'a> {
460 engine: Option<&'a str>,
461 deps: &'a str,
462}
463
464#[derive(Serialize)]
465struct DepsHashInput<'a> {
466 id: &'a str,
467 deps: &'a BTreeMap<String, String>,
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::{DirectDep, LocalSource, LockedPackage, LockfileGraph};
474 use std::path::PathBuf;
475
476 fn mk_pkg(name: &str, ver: &str, integrity: Option<&str>) -> LockedPackage {
477 LockedPackage {
478 name: name.into(),
479 version: ver.into(),
480 integrity: integrity.map(str::to_string),
481 dependencies: BTreeMap::new(),
482 peer_dependencies: BTreeMap::new(),
483 peer_dependencies_meta: BTreeMap::new(),
484 dep_path: format!("{name}@{ver}"),
485 ..Default::default()
486 }
487 }
488
489 fn empty_graph() -> LockfileGraph {
490 let mut importers = BTreeMap::new();
491 importers.insert(".".into(), Vec::<DirectDep>::new());
492 LockfileGraph {
493 importers,
494 packages: BTreeMap::new(),
495 ..Default::default()
496 }
497 }
498
499 #[test]
500 fn hash_is_deterministic_across_runs() {
501 let mut g = empty_graph();
502 g.packages.insert(
503 "foo@1.0.0".into(),
504 mk_pkg("foo", "1.0.0", Some("sha512-ABC")),
505 );
506 let h1 = compute_graph_hashes(&g, &|_| false, None);
507 let h2 = compute_graph_hashes(&g, &|_| false, None);
508 assert_eq!(h1.node_hash, h2.node_hash);
509 }
510
511 #[test]
512 fn different_integrity_produces_different_hash() {
513 let mut g1 = empty_graph();
514 g1.packages
515 .insert("foo@1.0.0".into(), mk_pkg("foo", "1.0.0", Some("sha512-A")));
516 let mut g2 = empty_graph();
517 g2.packages
518 .insert("foo@1.0.0".into(), mk_pkg("foo", "1.0.0", Some("sha512-B")));
519 let h1 = compute_graph_hashes(&g1, &|_| false, None);
520 let h2 = compute_graph_hashes(&g2, &|_| false, None);
521 assert_ne!(h1.node_hash["foo@1.0.0"], h2.node_hash["foo@1.0.0"]);
522 }
523
524 #[test]
525 fn child_change_cascades_to_parent() {
526 let mut g1 = empty_graph();
527 g1.packages
528 .insert("foo@1.0.0".into(), mk_pkg("foo", "1.0.0", Some("sha512-F")));
529 let mut foo = mk_pkg("foo", "1.0.0", Some("sha512-F"));
530 foo.dependencies.insert("bar".into(), "1.0.0".into());
531 g1.packages.insert("foo@1.0.0".into(), foo);
532 g1.packages.insert(
533 "bar@1.0.0".into(),
534 mk_pkg("bar", "1.0.0", Some("sha512-B1")),
535 );
536
537 let mut g2 = g1.clone();
538 g2.packages.insert(
539 "bar@1.0.0".into(),
540 mk_pkg("bar", "1.0.0", Some("sha512-B2")),
541 );
542
543 let h1 = compute_graph_hashes(&g1, &|_| false, None);
544 let h2 = compute_graph_hashes(&g2, &|_| false, None);
545 assert_ne!(h1.node_hash["foo@1.0.0"], h2.node_hash["foo@1.0.0"]);
546 assert_ne!(h1.node_hash["bar@1.0.0"], h2.node_hash["bar@1.0.0"]);
547 }
548
549 #[test]
550 fn source_change_cascades_to_parent() {
551 let mut g1 = empty_graph();
552 let mut parent = mk_pkg("parent", "1.0.0", Some("sha512-P"));
553 parent
554 .dependencies
555 .insert("child".into(), "file+aaa".into());
556 g1.packages.insert("parent@1.0.0".into(), parent);
557 let mut child = mk_pkg("child", "1.0.0", None);
558 child.dep_path = "child@file+aaa".into();
559 child.local_source = Some(LocalSource::Directory(PathBuf::from("vendor/a")));
560 g1.packages.insert("child@file+aaa".into(), child);
561
562 let mut g2 = empty_graph();
563 let mut parent = mk_pkg("parent", "1.0.0", Some("sha512-P"));
564 parent
565 .dependencies
566 .insert("child".into(), "file+bbb".into());
567 g2.packages.insert("parent@1.0.0".into(), parent);
568 let mut child = mk_pkg("child", "1.0.0", None);
569 child.dep_path = "child@file+bbb".into();
570 child.local_source = Some(LocalSource::Directory(PathBuf::from("vendor/b")));
571 g2.packages.insert("child@file+bbb".into(), child);
572
573 let h1 = compute_graph_hashes(&g1, &|_| false, None);
574 let h2 = compute_graph_hashes(&g2, &|_| false, None);
575
576 assert_ne!(
577 h1.node_hash["child@file+aaa"],
578 h2.node_hash["child@file+bbb"]
579 );
580 assert_ne!(h1.node_hash["parent@1.0.0"], h2.node_hash["parent@1.0.0"]);
581 }
582
583 #[test]
584 fn engine_only_affects_packages_transitively_requiring_build() {
585 let mut g = empty_graph();
586 g.packages.insert(
587 "pure@1.0.0".into(),
588 mk_pkg("pure", "1.0.0", Some("sha512-P")),
589 );
590 g.packages.insert(
591 "native@1.0.0".into(),
592 mk_pkg("native", "1.0.0", Some("sha512-N")),
593 );
594 let mut consumer = mk_pkg("consumer", "1.0.0", Some("sha512-C"));
595 consumer
596 .dependencies
597 .insert("native".into(), "1.0.0".into());
598 g.packages.insert("consumer@1.0.0".into(), consumer);
599
600 let allow_native = |pkg: &LockedPackage| pkg.registry_name() == "native";
601 let engine_a = EngineName("linux-x64-node20".into());
602 let engine_b = EngineName("linux-x64-node22".into());
603
604 let h_a = compute_graph_hashes(&g, &allow_native, Some(&engine_a));
605 let h_b = compute_graph_hashes(&g, &allow_native, Some(&engine_b));
606
607 assert_ne!(h_a.node_hash["native@1.0.0"], h_b.node_hash["native@1.0.0"]);
609 assert_ne!(
611 h_a.node_hash["consumer@1.0.0"],
612 h_b.node_hash["consumer@1.0.0"]
613 );
614 assert_eq!(h_a.node_hash["pure@1.0.0"], h_b.node_hash["pure@1.0.0"]);
616 }
617
618 #[test]
619 fn content_hash_disambiguates_same_coordinate() {
620 let mut g = empty_graph();
626 let mut pkg = mk_pkg("gitdep", "1.0.0", None);
627 pkg.dep_path = "gitdep@git+abc".into();
628 pkg.local_source = Some(LocalSource::Directory(PathBuf::from("clone")));
629 g.packages.insert("gitdep@git+abc".into(), pkg);
630
631 let none = compute_graph_hashes_full(&g, &|_| false, None, &|_, _| None, &|_| None);
632 let prepared = compute_graph_hashes_full(&g, &|_| false, None, &|_, _| None, &|dp| {
633 (dp == "gitdep@git+abc").then(|| "prepared".to_string())
634 });
635 let raw = compute_graph_hashes_full(&g, &|_| false, None, &|_, _| None, &|dp| {
636 (dp == "gitdep@git+abc").then(|| "raw".to_string())
637 });
638
639 assert_ne!(
640 prepared.node_hash["gitdep@git+abc"], raw.node_hash["gitdep@git+abc"],
641 "different content fingerprints must produce different hashes"
642 );
643 assert_ne!(
644 none.node_hash["gitdep@git+abc"], prepared.node_hash["gitdep@git+abc"],
645 "folding in a fingerprint must change the hash vs none"
646 );
647 let with_patches = compute_graph_hashes_with_patches(&g, &|_| false, None, &|_, _| None);
650 assert_eq!(none.node_hash, with_patches.node_hash);
651 }
652
653 #[test]
654 fn content_hash_cascades_to_parent() {
655 let mut g = empty_graph();
659 let mut parent = mk_pkg("parent", "1.0.0", Some("sha512-P"));
660 parent
661 .dependencies
662 .insert("gitdep".into(), "git+abc".into());
663 g.packages.insert("parent@1.0.0".into(), parent);
664 let mut child = mk_pkg("gitdep", "1.0.0", None);
665 child.dep_path = "gitdep@git+abc".into();
666 child.local_source = Some(LocalSource::Directory(PathBuf::from("clone")));
667 g.packages.insert("gitdep@git+abc".into(), child);
668
669 let a = compute_graph_hashes_full(&g, &|_| false, None, &|_, _| None, &|dp| {
670 (dp == "gitdep@git+abc").then(|| "prepared".to_string())
671 });
672 let b = compute_graph_hashes_full(&g, &|_| false, None, &|_, _| None, &|dp| {
673 (dp == "gitdep@git+abc").then(|| "raw".to_string())
674 });
675 assert_ne!(a.node_hash["parent@1.0.0"], b.node_hash["parent@1.0.0"]);
676 }
677
678 const URL_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
679
680 #[test]
681 fn url_shaped_git_child_content_cascades_to_parent() {
682 let url = format!("https://github.com/request/request.git#{URL_SHA}");
691 let child_key = shared_local_dep_path("request", &url).expect("git url is shareable");
692 assert!(
693 child_key.starts_with("request@git+"),
694 "unexpected: {child_key}"
695 );
696
697 let mut g = empty_graph();
698 let mut parent = mk_pkg("parent", "1.0.0", Some("sha512-P"));
699 parent.dependencies.insert("request".into(), url);
700 g.packages.insert("parent@1.0.0".into(), parent);
701 let mut child = mk_pkg("request", "2.88.0", None);
702 child.dep_path = child_key.clone();
703 child.local_source = Some(LocalSource::Directory(PathBuf::from("clone")));
704 g.packages.insert(child_key.clone(), child);
705
706 let prepared = compute_graph_hashes_full(&g, &|_| false, None, &|_, _| None, &|dp| {
707 (dp == child_key.as_str()).then(|| "prepared".to_string())
708 });
709 let raw = compute_graph_hashes_full(&g, &|_| false, None, &|_, _| None, &|dp| {
710 (dp == child_key.as_str()).then(|| "raw".to_string())
711 });
712 assert_ne!(
713 prepared.node_hash["parent@1.0.0"], raw.node_hash["parent@1.0.0"],
714 "URL-shaped git child fingerprint must cascade into the parent hash"
715 );
716 }
717
718 #[test]
719 fn url_shaped_tarball_child_content_cascades_to_parent() {
720 let url = format!("https://codeload.github.com/request/request/tar.gz/{URL_SHA}");
724 let child_key = shared_local_dep_path("request", &url).expect("tarball url is shareable");
725 assert!(
726 child_key.starts_with("request@url+"),
727 "unexpected: {child_key}"
728 );
729
730 let mut g = empty_graph();
731 let mut parent = mk_pkg("parent", "1.0.0", Some("sha512-P"));
732 parent.dependencies.insert("request".into(), url);
733 g.packages.insert("parent@1.0.0".into(), parent);
734 let mut child = mk_pkg("request", "2.88.0", None);
735 child.dep_path = child_key.clone();
736 child.local_source = Some(LocalSource::Directory(PathBuf::from("clone")));
737 g.packages.insert(child_key.clone(), child);
738
739 let prepared = compute_graph_hashes_full(&g, &|_| false, None, &|_, _| None, &|dp| {
740 (dp == child_key.as_str()).then(|| "prepared".to_string())
741 });
742 let raw = compute_graph_hashes_full(&g, &|_| false, None, &|_, _| None, &|dp| {
743 (dp == child_key.as_str()).then(|| "raw".to_string())
744 });
745 assert_ne!(
746 prepared.node_hash["parent@1.0.0"], raw.node_hash["parent@1.0.0"],
747 "URL-shaped tarball child fingerprint must cascade into the parent hash"
748 );
749 }
750
751 #[test]
752 fn url_shaped_git_child_engine_taint_cascades_to_parent() {
753 let url = format!("https://github.com/request/request.git#{URL_SHA}");
759 let child_key = shared_local_dep_path("request", &url).expect("git url is shareable");
760
761 let mut g = empty_graph();
762 let mut parent = mk_pkg("parent", "1.0.0", Some("sha512-P"));
763 parent.dependencies.insert("request".into(), url);
764 g.packages.insert("parent@1.0.0".into(), parent);
765 let mut child = mk_pkg("request", "2.88.0", None);
766 child.dep_path = child_key.clone();
767 child.local_source = Some(LocalSource::Directory(PathBuf::from("clone")));
768 g.packages.insert(child_key, child);
769
770 let allow_request = |pkg: &LockedPackage| pkg.registry_name() == "request";
771 let engine_a = EngineName("linux-x64-node20".into());
772 let engine_b = EngineName("linux-x64-node22".into());
773 let h_a = compute_graph_hashes(&g, &allow_request, Some(&engine_a));
774 let h_b = compute_graph_hashes(&g, &allow_request, Some(&engine_b));
775 assert_ne!(
776 h_a.node_hash["parent@1.0.0"], h_b.node_hash["parent@1.0.0"],
777 "URL-shaped building git child must make the parent engine-sensitive"
778 );
779 }
780
781 #[test]
782 fn cycles_do_not_panic() {
783 let mut g = empty_graph();
784 let mut a = mk_pkg("a", "1.0.0", Some("sha512-A"));
785 a.dependencies.insert("b".into(), "1.0.0".into());
786 let mut b = mk_pkg("b", "1.0.0", Some("sha512-B"));
787 b.dependencies.insert("a".into(), "1.0.0".into());
788 g.packages.insert("a@1.0.0".into(), a);
789 g.packages.insert("b@1.0.0".into(), b);
790
791 let h = compute_graph_hashes(&g, &|_| false, None);
792 assert!(h.node_hash.contains_key("a@1.0.0"));
793 assert!(h.node_hash.contains_key("b@1.0.0"));
794 }
795
796 fn shareable_source() -> LocalSource {
797 LocalSource::RemoteTarball(crate::RemoteTarballSource {
798 url: "https://example.com/dep.tgz".into(),
799 integrity: "sha512-Z".into(),
800 git_hosted: false,
801 })
802 }
803
804 #[test]
805 fn content_affected_covers_shareable_source_and_all_ancestors() {
806 let mut g = empty_graph();
809 let mut parent = mk_pkg("parent", "1.0.0", Some("sha512-P"));
810 parent.dependencies.insert("midware".into(), "1.0.0".into());
811 g.packages.insert("parent@1.0.0".into(), parent);
812
813 let mut midware = mk_pkg("midware", "1.0.0", Some("sha512-M"));
814 midware
815 .dependencies
816 .insert("tardep".into(), "url+aaa".into());
817 g.packages.insert("midware@1.0.0".into(), midware);
818
819 let mut tardep = mk_pkg("tardep", "1.0.0", None);
820 tardep.dep_path = "tardep@url+aaa".into();
821 tardep.local_source = Some(shareable_source());
822 g.packages.insert("tardep@url+aaa".into(), tardep);
823
824 g.packages.insert(
825 "pure@1.0.0".into(),
826 mk_pkg("pure", "1.0.0", Some("sha512-X")),
827 );
828
829 let affected = content_affected_dep_paths(&g);
830 assert!(
831 affected.contains("tardep@url+aaa"),
832 "the source leaf itself"
833 );
834 assert!(affected.contains("midware@1.0.0"), "direct ancestor");
835 assert!(affected.contains("parent@1.0.0"), "transitive ancestor");
836 assert!(
837 !affected.contains("pure@1.0.0"),
838 "a source-free subtree must stay prewarm-eligible"
839 );
840 }
841
842 #[test]
843 fn content_affected_handles_self_referential_cycle() {
844 let mut g = empty_graph();
849 let mut host = mk_pkg("host", "2.0.0", Some("sha512-L"));
850 host.dependencies.insert("srcdep".into(), "url+bbb".into());
851 g.packages.insert("host@2.0.0".into(), host);
852
853 let mut srcdep = mk_pkg("srcdep", "2.0.0", None);
854 srcdep.dep_path = "srcdep@url+bbb".into();
855 srcdep.local_source = Some(shareable_source());
856 srcdep.dependencies.insert("host".into(), "2.0.0".into());
857 g.packages.insert("srcdep@url+bbb".into(), srcdep);
858
859 let affected = content_affected_dep_paths(&g);
860 assert!(affected.contains("srcdep@url+bbb"));
861 assert!(
862 affected.contains("host@2.0.0"),
863 "ancestor inside a cycle with the source must still be flagged"
864 );
865 }
866
867 #[test]
868 fn content_affected_ignores_non_shareable_local_sources() {
869 let mut g = empty_graph();
873 let mut parent = mk_pkg("parent", "1.0.0", Some("sha512-P"));
874 parent.dependencies.insert("dir".into(), "file+ccc".into());
875 g.packages.insert("parent@1.0.0".into(), parent);
876
877 let mut dir = mk_pkg("dir", "1.0.0", None);
878 dir.dep_path = "dir@file+ccc".into();
879 dir.local_source = Some(LocalSource::Directory(PathBuf::from("vendor/dir")));
880 g.packages.insert("dir@file+ccc".into(), dir);
881
882 let affected = content_affected_dep_paths(&g);
883 assert!(affected.is_empty(), "got: {affected:?}");
884 }
885
886 #[test]
887 fn hashed_dep_path_appends_to_leaf() {
888 let mut h = GraphHashes::default();
889 h.node_hash.insert("foo@1.0.0".into(), "a".repeat(64));
890 assert!(h.hashed_dep_path("foo@1.0.0").starts_with("foo@1.0.0-aa"));
891 }
892
893 #[test]
894 fn hashed_dep_path_preserves_scope() {
895 let mut h = GraphHashes::default();
896 h.node_hash.insert("@swc/core@1.3.0".into(), "b".repeat(64));
897 let got = h.hashed_dep_path("@swc/core@1.3.0");
898 assert!(got.starts_with("@swc/core@1.3.0-bb"), "got: {got}");
899 assert!(got.starts_with("@swc/"));
902 }
903
904 #[test]
905 fn hashed_dep_path_falls_back_to_raw_when_absent() {
906 let h = GraphHashes::default();
907 assert_eq!(h.hashed_dep_path("foo@1.0.0"), "foo@1.0.0");
908 }
909
910 #[test]
911 fn engine_name_parses_node_version() {
912 let e = engine_name_default("v20.10.0");
913 assert!(e.0.ends_with("-node20"));
914 let e = engine_name_default("22.0.0");
915 assert!(e.0.ends_with("-node22"));
916 }
917
918 #[test]
919 fn node_arch_maps_to_node_conventions() {
920 assert_eq!(node_arch("x86_64"), "x64");
921 assert_eq!(node_arch("aarch64"), "arm64");
922 assert_eq!(node_arch("x86"), "ia32");
923 assert_eq!(node_arch("riscv64"), "riscv64");
926 }
927
928 #[test]
929 fn aliased_patch_hash_resolves_by_registry_identity() {
930 let mut g = empty_graph();
937 let mut pkg = mk_pkg("odd-alias", "3.0.1", Some("sha512-A"));
938 pkg.alias_of = Some("is-odd".into());
939 g.packages.insert("odd-alias@3.0.1".into(), pkg);
940
941 let unpatched = compute_graph_hashes_with_patches(&g, &|_| false, None, &|_, _| None);
942 let patched = compute_graph_hashes_with_patches(&g, &|_| false, None, &|name, ver| {
943 (name == "is-odd" && ver == "3.0.1").then(|| "deadbeef".to_string())
944 });
945 assert_ne!(
946 unpatched.node_hash["odd-alias@3.0.1"], patched.node_hash["odd-alias@3.0.1"],
947 "a registry-name patch must change the aliased node's graph hash"
948 );
949 }
950
951 #[test]
952 fn aliased_patch_hash_prefers_alias_key_over_registry_key() {
953 let mut g = empty_graph();
959 let mut pkg = mk_pkg("odd-alias", "3.0.1", Some("sha512-A"));
960 pkg.alias_of = Some("is-odd".into());
961 g.packages.insert("odd-alias@3.0.1".into(), pkg);
962
963 let alias_keyed =
964 compute_graph_hashes_with_patches(
965 &g,
966 &|_| false,
967 None,
968 &|name, ver| match (name, ver) {
969 ("odd-alias", "3.0.1") => Some("alias-patch".to_string()),
970 ("is-odd", "3.0.1") => Some("registry-patch".to_string()),
971 _ => None,
972 },
973 );
974 let alias_only = compute_graph_hashes_with_patches(&g, &|_| false, None, &|name, ver| {
975 (name == "odd-alias" && ver == "3.0.1").then(|| "alias-patch".to_string())
976 });
977 assert_eq!(
978 alias_keyed.node_hash["odd-alias@3.0.1"], alias_only.node_hash["odd-alias@3.0.1"],
979 "alias-keyed patch must take precedence over the registry-keyed one"
980 );
981 }
982}