use crate::{LockedPackage, LockfileGraph};
use rustc_hash::{FxHashMap, FxHashSet};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub type AllowBuildFn<'a> = &'a dyn Fn(&str, &str) -> bool;
#[derive(Debug, Clone)]
pub struct EngineName(pub String);
pub fn engine_name_default(node_version: &str) -> EngineName {
let os = std::env::consts::OS;
let arch = node_arch(std::env::consts::ARCH);
let major = node_version
.trim_start_matches('v')
.split('.')
.next()
.unwrap_or("");
EngineName(format!("{os}-{arch}-node{major}"))
}
fn node_arch(rust_arch: &str) -> &str {
match rust_arch {
"x86_64" => "x64",
"aarch64" => "arm64",
"x86" => "ia32",
"powerpc64" => "ppc64",
"powerpc" => "ppc",
other => other,
}
}
#[derive(Debug, Default, Clone)]
pub struct GraphHashes {
pub node_hash: BTreeMap<String, String>,
}
impl GraphHashes {
pub fn hashed_dep_path(&self, dep_path: &str) -> String {
match self.node_hash.get(dep_path) {
Some(hex) => append_hex_to_leaf(dep_path, hex),
None => dep_path.to_string(),
}
}
}
fn append_hex_to_leaf(dep_path: &str, hex: &str) -> String {
let short = &hex[..hex.len().min(16)];
match dep_path.rfind('/') {
Some(i) => format!("{}/{}-{}", &dep_path[..i], &dep_path[i + 1..], short),
None => format!("{dep_path}-{short}"),
}
}
pub type PatchHashFn<'a> = &'a dyn Fn(&str, &str) -> Option<String>;
pub fn compute_graph_hashes(
graph: &LockfileGraph,
allow_build: AllowBuildFn<'_>,
engine: Option<&EngineName>,
) -> GraphHashes {
compute_graph_hashes_with_patches(graph, allow_build, engine, &|_, _| None)
}
pub fn compute_graph_hashes_with_patches(
graph: &LockfileGraph,
allow_build: AllowBuildFn<'_>,
engine: Option<&EngineName>,
patch_hash: PatchHashFn<'_>,
) -> GraphHashes {
let mut builds: FxHashSet<String> = FxHashSet::default();
for (dep_path, pkg) in &graph.packages {
if allow_build(pkg.registry_name(), &pkg.version) {
builds.insert(dep_path.clone());
}
}
let mut deps_hash_cache: FxHashMap<String, String> = FxHashMap::default();
for dep_path in graph.packages.keys() {
let _ = calc_deps_hash(
graph,
dep_path,
&mut deps_hash_cache,
&mut FxHashSet::default(),
patch_hash,
);
}
let mut requires_build_cache: FxHashMap<String, bool> = FxHashMap::default();
for dep_path in graph.packages.keys() {
transitively_requires_build(
graph,
&builds,
dep_path,
&mut requires_build_cache,
&mut FxHashSet::default(),
);
}
let mut node_hash: BTreeMap<String, String> = BTreeMap::new();
for dep_path in graph.packages.keys() {
let include_engine =
engine.is_some() && *requires_build_cache.get(dep_path).unwrap_or(&false);
let engine_str = if include_engine {
Some(engine.unwrap().0.as_str())
} else {
None
};
let deps_hash = deps_hash_cache.get(dep_path).cloned().unwrap_or_default();
let hex = hash_canonical(&NodeHashInput {
engine: engine_str,
deps: &deps_hash,
});
node_hash.insert(dep_path.clone(), hex);
}
GraphHashes { node_hash }
}
fn calc_deps_hash(
graph: &LockfileGraph,
dep_path: &str,
cache: &mut FxHashMap<String, String>,
parents: &mut FxHashSet<String>,
patch_hash: PatchHashFn<'_>,
) -> String {
if let Some(cached) = cache.get(dep_path) {
return cached.clone();
}
if !parents.insert(dep_path.to_string()) {
return String::new();
}
let hash = match graph.packages.get(dep_path) {
Some(pkg) => {
let id = full_pkg_id(pkg, patch_hash);
let mut deps: BTreeMap<String, String> = BTreeMap::new();
for (alias, child_tail) in &pkg.dependencies {
let child_dep_path = format!("{alias}@{child_tail}");
if !graph.packages.contains_key(&child_dep_path) {
continue;
}
let child_hash = calc_deps_hash(graph, &child_dep_path, cache, parents, patch_hash);
deps.insert(alias.clone(), child_hash);
}
hash_canonical(&DepsHashInput {
id: &id,
deps: &deps,
})
}
None => String::new(),
};
parents.remove(dep_path);
cache.insert(dep_path.to_string(), hash.clone());
hash
}
fn transitively_requires_build(
graph: &LockfileGraph,
builds: &FxHashSet<String>,
dep_path: &str,
cache: &mut FxHashMap<String, bool>,
parents: &mut FxHashSet<String>,
) -> bool {
if let Some(&cached) = cache.get(dep_path) {
return cached;
}
if builds.contains(dep_path) {
cache.insert(dep_path.to_string(), true);
return true;
}
if !parents.insert(dep_path.to_string()) {
return false;
}
let result = match graph.packages.get(dep_path) {
Some(pkg) => pkg.dependencies.iter().any(|(alias, tail)| {
let child_dep_path = format!("{alias}@{tail}");
transitively_requires_build(graph, builds, &child_dep_path, cache, parents)
}),
None => false,
};
parents.remove(dep_path);
cache.insert(dep_path.to_string(), result);
result
}
fn full_pkg_id(pkg: &LockedPackage, patch_hash: PatchHashFn<'_>) -> String {
let integrity = pkg.integrity.as_deref().unwrap_or("<no-integrity>");
match patch_hash(&pkg.name, &pkg.version) {
Some(hex) => format!("{}@{}:patch:{hex}:{integrity}", pkg.name, pkg.version),
None => format!("{}@{}:{}", pkg.name, pkg.version, integrity),
}
}
fn hash_canonical<T: Serialize>(value: &T) -> String {
let json = serde_json::to_vec(value).expect("graph hash input must serialize");
let digest = Sha256::digest(&json);
hex::encode(digest)
}
#[derive(Serialize)]
struct NodeHashInput<'a> {
engine: Option<&'a str>,
deps: &'a str,
}
#[derive(Serialize)]
struct DepsHashInput<'a> {
id: &'a str,
deps: &'a BTreeMap<String, String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DirectDep, LockedPackage, LockfileGraph};
fn mk_pkg(name: &str, ver: &str, integrity: Option<&str>) -> LockedPackage {
LockedPackage {
name: name.into(),
version: ver.into(),
integrity: integrity.map(str::to_string),
dependencies: BTreeMap::new(),
peer_dependencies: BTreeMap::new(),
peer_dependencies_meta: BTreeMap::new(),
dep_path: format!("{name}@{ver}"),
..Default::default()
}
}
fn empty_graph() -> LockfileGraph {
let mut importers = BTreeMap::new();
importers.insert(".".into(), Vec::<DirectDep>::new());
LockfileGraph {
importers,
packages: BTreeMap::new(),
..Default::default()
}
}
#[test]
fn hash_is_deterministic_across_runs() {
let mut g = empty_graph();
g.packages.insert(
"foo@1.0.0".into(),
mk_pkg("foo", "1.0.0", Some("sha512-ABC")),
);
let h1 = compute_graph_hashes(&g, &|_, _| false, None);
let h2 = compute_graph_hashes(&g, &|_, _| false, None);
assert_eq!(h1.node_hash, h2.node_hash);
}
#[test]
fn different_integrity_produces_different_hash() {
let mut g1 = empty_graph();
g1.packages
.insert("foo@1.0.0".into(), mk_pkg("foo", "1.0.0", Some("sha512-A")));
let mut g2 = empty_graph();
g2.packages
.insert("foo@1.0.0".into(), mk_pkg("foo", "1.0.0", Some("sha512-B")));
let h1 = compute_graph_hashes(&g1, &|_, _| false, None);
let h2 = compute_graph_hashes(&g2, &|_, _| false, None);
assert_ne!(h1.node_hash["foo@1.0.0"], h2.node_hash["foo@1.0.0"]);
}
#[test]
fn child_change_cascades_to_parent() {
let mut g1 = empty_graph();
g1.packages
.insert("foo@1.0.0".into(), mk_pkg("foo", "1.0.0", Some("sha512-F")));
let mut foo = mk_pkg("foo", "1.0.0", Some("sha512-F"));
foo.dependencies.insert("bar".into(), "1.0.0".into());
g1.packages.insert("foo@1.0.0".into(), foo);
g1.packages.insert(
"bar@1.0.0".into(),
mk_pkg("bar", "1.0.0", Some("sha512-B1")),
);
let mut g2 = g1.clone();
g2.packages.insert(
"bar@1.0.0".into(),
mk_pkg("bar", "1.0.0", Some("sha512-B2")),
);
let h1 = compute_graph_hashes(&g1, &|_, _| false, None);
let h2 = compute_graph_hashes(&g2, &|_, _| false, None);
assert_ne!(h1.node_hash["foo@1.0.0"], h2.node_hash["foo@1.0.0"]);
assert_ne!(h1.node_hash["bar@1.0.0"], h2.node_hash["bar@1.0.0"]);
}
#[test]
fn engine_only_affects_packages_transitively_requiring_build() {
let mut g = empty_graph();
g.packages.insert(
"pure@1.0.0".into(),
mk_pkg("pure", "1.0.0", Some("sha512-P")),
);
g.packages.insert(
"native@1.0.0".into(),
mk_pkg("native", "1.0.0", Some("sha512-N")),
);
let mut consumer = mk_pkg("consumer", "1.0.0", Some("sha512-C"));
consumer
.dependencies
.insert("native".into(), "1.0.0".into());
g.packages.insert("consumer@1.0.0".into(), consumer);
let allow_native = |name: &str, _v: &str| name == "native";
let engine_a = EngineName("linux-x64-node20".into());
let engine_b = EngineName("linux-x64-node22".into());
let h_a = compute_graph_hashes(&g, &allow_native, Some(&engine_a));
let h_b = compute_graph_hashes(&g, &allow_native, Some(&engine_b));
assert_ne!(h_a.node_hash["native@1.0.0"], h_b.node_hash["native@1.0.0"]);
assert_ne!(
h_a.node_hash["consumer@1.0.0"],
h_b.node_hash["consumer@1.0.0"]
);
assert_eq!(h_a.node_hash["pure@1.0.0"], h_b.node_hash["pure@1.0.0"]);
}
#[test]
fn cycles_do_not_panic() {
let mut g = empty_graph();
let mut a = mk_pkg("a", "1.0.0", Some("sha512-A"));
a.dependencies.insert("b".into(), "1.0.0".into());
let mut b = mk_pkg("b", "1.0.0", Some("sha512-B"));
b.dependencies.insert("a".into(), "1.0.0".into());
g.packages.insert("a@1.0.0".into(), a);
g.packages.insert("b@1.0.0".into(), b);
let h = compute_graph_hashes(&g, &|_, _| false, None);
assert!(h.node_hash.contains_key("a@1.0.0"));
assert!(h.node_hash.contains_key("b@1.0.0"));
}
#[test]
fn hashed_dep_path_appends_to_leaf() {
let mut h = GraphHashes::default();
h.node_hash.insert("foo@1.0.0".into(), "a".repeat(64));
assert!(h.hashed_dep_path("foo@1.0.0").starts_with("foo@1.0.0-aa"));
}
#[test]
fn hashed_dep_path_preserves_scope() {
let mut h = GraphHashes::default();
h.node_hash.insert("@swc/core@1.3.0".into(), "b".repeat(64));
let got = h.hashed_dep_path("@swc/core@1.3.0");
assert!(got.starts_with("@swc/core@1.3.0-bb"), "got: {got}");
assert!(got.starts_with("@swc/"));
}
#[test]
fn hashed_dep_path_falls_back_to_raw_when_absent() {
let h = GraphHashes::default();
assert_eq!(h.hashed_dep_path("foo@1.0.0"), "foo@1.0.0");
}
#[test]
fn engine_name_parses_node_version() {
let e = engine_name_default("v20.10.0");
assert!(e.0.ends_with("-node20"));
let e = engine_name_default("22.0.0");
assert!(e.0.ends_with("-node22"));
}
#[test]
fn node_arch_maps_to_node_conventions() {
assert_eq!(node_arch("x86_64"), "x64");
assert_eq!(node_arch("aarch64"), "arm64");
assert_eq!(node_arch("x86"), "ia32");
assert_eq!(node_arch("riscv64"), "riscv64");
}
}