Skip to main content

aube_lockfile/
graph_hash.rs

1//! Content-addressed virtual store path computation.
2//!
3//! Ports pnpm's `calcGraphNodeHash` from `/tmp/pnpm/deps/graph-hasher/` —
4//! the mechanism that lets pnpm's global virtual store safely share
5//! built packages across projects. The core idea:
6//!
7//! 1. Each lockfile node gets a **dep-graph hash** derived from its own
8//!    identity (the integrity hash / fullPkgId) plus the recursively
9//!    hashed dep-graph subtree. Two projects whose resolution produces
10//!    the same `(foo, [same children, same versions, same identities])`
11//!    end up with the same hash, so they share a virtual-store entry.
12//! 2. For packages that **transitively depend on anything allowed to
13//!    run build scripts**, the hash also folds in an engine string
14//!    (os/arch/node-version). Building a native module against node 20
15//!    produces a different hash than building it against node 22, so
16//!    the two artifacts live at different paths and never collide.
17//! 3. Everything else (pure-JS packages whose subtree contains nothing
18//!    that builds) has a hash of `engine=null` — stable across
19//!    architectures, so pure-JS trees are still shared globally.
20//!
21//! Unlike pnpm, we use BLAKE3 over a canonical JSON serialization —
22//! aube's virtual store is internal to aube (the CAS under
23//! `$XDG_DATA_HOME/aube/store/v1/files` is ours alone), so we don't
24//! need bit-for-bit compatibility with pnpm's `object-hash`.
25//! Determinism is all that matters, and `serde_json` plus `BTreeMap`
26//! gives us alphabetized keys for free. BLAKE3 is the project default
27//! for non-crypto-verifying hashes (3-5x faster than SHA-256).
28
29use crate::{LockedPackage, LockfileGraph, shared_local_dep_path};
30use serde::Serialize;
31use std::collections::BTreeMap;
32
33/// Resolve a child dependency's recorded `(alias, tail)` to the graph
34/// key the target package is stored under.
35///
36/// Registry deps record their version verbatim, so `alias@tail` is the
37/// key. Git / remote-tarball deps record their *resolved URL* as the
38/// tail while the package is keyed under the hashed
39/// `alias@git+<hash>` / `alias@url+<hash>` form; [`shared_local_dep_path`]
40/// performs that translation. Falling back to the raw `alias@tail`
41/// keeps the common case allocation-light and behaves identically to
42/// the pre-canonicalization lookup for everything that isn't a
43/// content-pinned source.
44///
45/// Keeping this in lockstep with the linker's sibling-symlink keying
46/// (which calls the same helper) is load-bearing: if the hasher skipped
47/// a URL-shaped git child, the parent's GVS hash would omit that child's
48/// content fingerprint and build/engine taint, and two materially
49/// different trees would collide on one virtual-store path.
50fn 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
57/// A callback the caller provides to tell the hasher which
58/// `(name, version)` combinations are allowed to run lifecycle
59/// scripts. Implemented by `aube-scripts::BuildPolicy` in practice,
60/// but the hasher stays oblivious to the policy crate so the lockfile
61/// crate doesn't depend on it.
62pub type AllowBuildFn<'a> = &'a dyn Fn(&LockedPackage) -> bool;
63
64/// Engine fingerprint folded into a node's hash when any of its
65/// transitive deps are allowed to build. Callers compute this once
66/// per install; see [`engine_name_default`] for the standard format.
67#[derive(Debug, Clone)]
68pub struct EngineName(pub String);
69
70/// `<os>-<arch>-node<major>` — e.g. `linux-x64-node20`. Enough to
71/// distinguish builds across the axes that actually break native
72/// modules. The arch string is translated from Rust's naming
73/// (`x86_64`, `aarch64`) to Node's (`x64`, `arm64`) so the virtual
74/// store directories look familiar next to `process.arch` output.
75/// Libc detection is a known gap (TODO: musl vs glibc).
76pub 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
87/// Map Rust `std::env::consts::ARCH` values to Node's `process.arch`
88/// convention. Unknown inputs pass through unchanged — better to leak
89/// a Rust-flavored name into a debug path than to silently collapse
90/// two distinct architectures onto the same bucket.
91fn 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/// Result of a full hashing pass over a `LockfileGraph`.
103#[derive(Debug, Default, Clone)]
104pub struct GraphHashes {
105    /// Per-dep_path final hash used as the virtual-store subdir suffix.
106    pub node_hash: BTreeMap<String, String>,
107}
108
109impl GraphHashes {
110    /// Look up a hashed subdir name for `dep_path`, falling back to the
111    /// raw dep_path when the hash is unknown. Callers threading this
112    /// through the linker can use it as a drop-in for the bare
113    /// dep_path when constructing virtual-store paths.
114    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
122/// Append `-<hex>` to the final slash-separated component of `dep_path`.
123/// For scoped packages like `@scope/name@ver` this preserves the scope
124/// prefix and only decorates the leaf, so the existing 2-component
125/// directory layout carries through unchanged except for a longer leaf
126/// name.
127fn append_hex_to_leaf(dep_path: &str, hex: &str) -> String {
128    // 16 chars of sha256 hex = 64 bits, more than enough to avoid
129    // collisions inside one project's lockfile (which typically has a
130    // few thousand nodes at most). Using the full 64 would just make
131    // paths awkward to stare at in `ls`.
132    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
139/// Per-`(name, version)` patch fingerprint. Folded into `full_pkg_id`
140/// so a patched node hashes differently from the unpatched one — and
141/// because the recursive `calc_deps_hash` mixes child hashes into
142/// every ancestor, every dep that transitively pulls in the patched
143/// package also lands at a fresh virtual-store path.
144pub type PatchHashFn<'a> = &'a dyn Fn(&str, &str) -> Option<String>;
145
146/// Per-`dep_path` materialized-content fingerprint. Folded into
147/// `full_pkg_id` so a source-backed dependency (git / remote tarball)
148/// whose lockfile coordinate is identical to another's but whose
149/// on-disk bytes differ hashes to a distinct value.
150///
151/// The motivating case is a git dep installed once normally (its
152/// `prepare` built `dist/`) and once under `--ignore-scripts` (raw
153/// checkout): same `<url>#<commit>` coordinate, no integrity in the
154/// lockfile, but different trees. Keying the global virtual store by
155/// coordinate alone would let the first project's built tree leak into
156/// the second's scripts-free install; folding the content fingerprint
157/// in keeps them at separate paths. Returns `None` for packages whose
158/// content the caller doesn't fingerprint (registry packages already
159/// carry an integrity, so they need no extra disambiguation).
160pub type ContentHashFn<'a> = &'a dyn Fn(&str) -> Option<String>;
161
162/// Compute final hashes for every package in `graph`. When
163/// `engine` is `Some`, packages whose transitive subtree contains a
164/// build-allowed package fold the engine name into their hash; when
165/// `None` or when no package in the subtree is allowed to build, the
166/// hash is engine-agnostic.
167pub 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
175/// Variant of [`compute_graph_hashes`] that also folds per-package
176/// patch fingerprints into the hash, so patched packages live at
177/// distinct virtual-store paths.
178pub 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
187/// Variant of [`compute_graph_hashes_with_patches`] that additionally
188/// folds a per-`dep_path` materialized-content fingerprint into each
189/// node's identity. See [`ContentHashFn`] for why this is needed for
190/// source-backed (git / remote-tarball) dependencies under the global
191/// virtual store.
192pub 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    // Pass 1: identify every dep_path whose `(name, version)` is
200    // allowed to run its scripts. This is the "builds" set.
201    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    // Pass 2: per-package dep-graph hash (recursive, memoized).
209    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    // Pass 3: per-package "does the subtree transitively need engine
222    // tainting?" cache.
223    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    // Pass 4: final `node_hash(engine?, deps)` per package.
235    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
255/// Compute the recursive dep-graph hash for one package. Uses the
256/// node's `full_pkg_id` (its integrity when present, else a stringified
257/// fallback) plus a sorted map of `child_alias -> child_deps_hash`.
258///
259/// Cycle-safe: packages already on the current DFS stack return an
260/// empty string, matching pnpm's behavior (the hash loses a small bit
261/// of information for cyclic peer-dep contexts, but it stays stable
262/// and deterministic).
263fn 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        // Cycle: contribute an empty hash to break the recursion.
276        // (Pnpm's version of this fans out from `fullPkgId` → `deps:{}`
277        // when a node is already a parent; empty string here does the
278        // same job via the canonical serializer.)
279        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                // The child might not be in the graph if the lockfile
289                // has a dangling reference (e.g. after manual edits);
290                // skip rather than panic.
291                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
317/// Returns `true` if `dep_path` is allowed to build, or if any of its
318/// transitive children are. Mirrors pnpm's `transitivelyRequiresBuild`.
319fn 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
348/// The set of dep_paths whose final graph hash folds in a content
349/// fingerprint: every globally-shareable source dependency (git /
350/// remote tarball — see [`LocalSource::is_globally_shareable`]) plus
351/// every package that transitively depends on one.
352///
353/// This exists to keep the GVS-prewarm materializer honest. Prewarm
354/// runs *concurrently with fetch*, so it can't fingerprint source trees
355/// that haven't been imported yet — it hashes with [`ContentHashFn`]
356/// returning `None` for everything. The link phase runs after fetch and
357/// folds the real fingerprints in via [`compute_graph_hashes_full`]. For
358/// any package in this set the two passes compute *different* hashes, so
359/// a node the prewarm materializes lands at a content-less path the link
360/// phase never references — stranding a duplicate cohort in the global
361/// store. Worse, prewarm skips the shareable-source *leaves* themselves
362/// (it can't materialize an un-fetched tree), so that stranded cohort's
363/// sibling symlinks dangle and Node's module walk silently resolves a
364/// second copy of the package higher up the tree (a duplicate-singleton
365/// "Cannot find module" class of bug). Prewarm must skip exactly this set
366/// and defer it to the link phase, which materializes every node at its
367/// final content-ful path.
368///
369/// Computed by reverse reachability from the shareable-source seeds so
370/// it's order- and cycle-independent: a source-backed subtree can sit
371/// inside a self-referential peer-dependency cycle, which a forward DFS
372/// memo would mis-handle depending on traversal entry point.
373///
374/// [`LocalSource::is_globally_shareable`]: crate::LocalSource::is_globally_shareable
375pub 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
405/// `full_pkg_id` — pnpm uses `${pkgIdWithPatchHash}:${resolution}`; we
406/// use `${name}@${version}[:patch:<hex>]:${source?}[:content:<hex>]:${integrity}`.
407/// Source-backed packages fold in their stable specifier so two local
408/// or git dependencies with the same manifest version don't collapse
409/// onto the same graph hash when they point at different bytes.
410///
411/// `content` is the materialized-content fingerprint (see
412/// [`ContentHashFn`]). It disambiguates source-backed deps that share a
413/// coordinate but not bytes — e.g. a git dep whose `prepare` ran versus
414/// the same commit installed under `--ignore-scripts`.
415fn 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    // Resolve the patch fingerprint with the SAME precedence as
426    // `LockedPackage::lookup_patch` — alias-qualified `name@version`
427    // first, then `registry_name()@version` — so the virtual-store hash
428    // always reflects the exact patch the apply sites write to disk. An
429    // npm-aliased entry (`name` = alias) declares its patch against the
430    // registry identity in the common case, but a patch keyed by the
431    // alias must win here too; otherwise the patched node would share a
432    // dep_path with the unpatched one. The identity string keeps
433    // `pkg.name` so the alias retains its own node.
434    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
449/// BLAKE3 over a canonical JSON serialization. `serde_json` plus
450/// `BTreeMap` gives alphabetized keys; primitives serialize
451/// deterministically. Return the full hex digest so callers can pick
452/// whatever prefix length they want.
453fn 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        // `native` builds → engine-sensitive → different per engine
608        assert_ne!(h_a.node_hash["native@1.0.0"], h_b.node_hash["native@1.0.0"]);
609        // `consumer` depends on native → engine-sensitive
610        assert_ne!(
611            h_a.node_hash["consumer@1.0.0"],
612            h_b.node_hash["consumer@1.0.0"]
613        );
614        // `pure` has no build in its subtree → engine-agnostic → stable
615        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        // A git dep with no integrity: two installs share the same
621        // `(name, version, source)` coordinate but materialize
622        // different trees (prepare ran vs `--ignore-scripts`). Folding
623        // the content fingerprint in must split them onto distinct
624        // hashes; an absent fingerprint must leave the hash unchanged.
625        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        // A no-op content fn reproduces the with-patches result exactly,
648        // so existing GVS paths for the common case stay stable.
649        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        // A parent that depends on the fingerprinted git dep must also
656        // get a fresh hash, so its sibling symlink lands on the dep's
657        // content-disambiguated path rather than dangling.
658        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        // Real pnpm lockfiles record a git dependency by its *resolved
683        // URL* in the parent's `dependencies:` map, while the package is
684        // keyed under the hashed `name@git+<hash>` form. The hasher must
685        // canonicalize that URL-shaped value — a raw `name@<url>` lookup
686        // misses the child, so its content fingerprint never reaches the
687        // parent and two materially different trees collide on one GVS
688        // path. (Distinct from `content_hash_cascades_to_parent`, which
689        // feeds the already-canonical synthetic `git+abc` value.)
690        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        // The codeload-archive form pnpm records for a `github:` dep that
721        // resolves to a tarball. Keyed under `name@url+<hash>`; the raw
722        // `name@<url>` lookup would skip it just like the git case.
723        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        // An allowlisted (building) git child recorded by URL must make
754        // the parent engine-sensitive too; otherwise a parent installed
755        // under a different engine reuses a GVS path built for the wrong
756        // ABI. Requires the same canonical child lookup in
757        // `transitively_requires_build`.
758        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        // parent -> midware -> tarball(shareable); `pure` is an unrelated
807        // sibling whose subtree contains no source dep.
808        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        // host <-> srcdep(shareable) cycle, mirroring a real-world
845        // self-referential peer cycle. Both nodes must be flagged
846        // regardless of the back-edge; reverse reachability from the
847        // source seed makes this order-independent.
848        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        // A `file:` directory dep is not globally shareable: it gets no
870        // content fingerprint, so its hash is identical across prewarm
871        // and link and prewarm may safely materialize its ancestors.
872        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        // Scope prefix survives unchanged so the existing directory
900        // layout (`virtual_store/@scope/<leaf>`) still resolves.
901        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        // Unknown architectures pass through rather than getting
924        // silently remapped onto an adjacent bucket.
925        assert_eq!(node_arch("riscv64"), "riscv64");
926    }
927
928    #[test]
929    fn aliased_patch_hash_resolves_by_registry_identity() {
930        // `"odd-alias": "npm:is-odd@3.0.1"` with a patch declared against
931        // the registry identity `is-odd@3.0.1`. The graph hash must fold
932        // that patch in even though the node's `name` is the alias, so
933        // the patched node lands on a distinct dep_path from an unpatched
934        // one. Mirrors what `LockedPackage::lookup_patch` does at the
935        // apply sites.
936        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        // A patch declared against the alias identity must win over one
954        // declared against the registry identity — the same precedence
955        // `LockedPackage::lookup_patch` uses (`spec_key()` first). If the
956        // graph hash resolved registry-first, the on-disk bytes (patched
957        // via the alias key) and the dep_path would disagree.
958        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}