use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn sha256_base64_prefixed(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
format!("sha256-{}", STANDARD.encode(digest))
}
pub fn package_extensions_checksum(extensions: &BTreeMap<String, Value>) -> Option<String> {
if extensions.is_empty() {
return None;
}
let mut buf = Vec::new();
write_object_entries(extensions.iter().map(|(k, v)| (k.as_str(), v)), &mut buf);
Some(sha256_base64_prefixed(&buf))
}
pub fn pnpmfile_checksum(paths: &[PathBuf]) -> std::io::Result<Option<String>> {
if paths.is_empty() {
return Ok(None);
}
let mut sorted: Vec<&PathBuf> = paths.iter().collect();
sorted.sort();
if sorted.len() == 1 {
return Ok(Some(hash_pnpmfile(sorted[0])?));
}
let hashes = sorted
.iter()
.map(|p| hash_pnpmfile(p))
.collect::<std::io::Result<Vec<String>>>()?;
Ok(Some(sha256_base64_prefixed(hashes.join(",").as_bytes())))
}
fn hash_pnpmfile(path: &Path) -> std::io::Result<String> {
let content = std::fs::read_to_string(path)?;
let normalized = content.replace("\r\n", "\n");
Ok(sha256_base64_prefixed(normalized.as_bytes()))
}
fn object_hash_dispatch(value: &Value, out: &mut Vec<u8>) {
match value {
Value::Null => out.extend_from_slice(b"Null"),
Value::Bool(b) => {
out.extend_from_slice(b"bool:");
out.extend_from_slice(if *b { b"true" } else { b"false" });
}
Value::Number(n) => {
out.extend_from_slice(b"number:");
out.extend_from_slice(js_number_string(n).as_bytes());
}
Value::String(s) => write_hashed_string(s, out),
Value::Array(items) => write_array(items, out),
Value::Object(map) => {
write_object_entries(map.iter().map(|(k, v)| (k.as_str(), v)), out);
}
}
}
fn write_hashed_string(s: &str, out: &mut Vec<u8>) {
let len = s.encode_utf16().count();
out.extend_from_slice(format!("string:{len}:").as_bytes());
out.extend_from_slice(s.as_bytes());
}
fn write_object_entries<'a, I>(entries: I, out: &mut Vec<u8>)
where
I: Iterator<Item = (&'a str, &'a Value)>,
{
let mut pairs: Vec<(&str, &Value)> = entries.collect();
pairs.sort_by(|a, b| a.0.cmp(b.0));
out.extend_from_slice(format!("object:{}:", pairs.len()).as_bytes());
for (key, value) in pairs {
write_hashed_string(key, out);
out.push(b':');
object_hash_dispatch(value, out);
out.push(b',');
}
}
fn write_array(items: &[Value], out: &mut Vec<u8>) {
out.extend_from_slice(format!("array:{}:", items.len()).as_bytes());
if items.len() <= 1 {
for item in items {
object_hash_dispatch(item, out);
}
return;
}
let mut serialized: Vec<Vec<u8>> = items
.iter()
.map(|item| {
let mut b = Vec::new();
object_hash_dispatch(item, &mut b);
b
})
.collect();
serialized.sort();
out.extend_from_slice(format!("array:{}:", serialized.len()).as_bytes());
for entry in &serialized {
let entry_str =
std::str::from_utf8(entry).expect("object-hash element serialization is valid UTF-8");
write_hashed_string(entry_str, out);
}
}
fn js_number_string(n: &serde_json::Number) -> String {
if let Some(u) = n.as_u64() {
return u.to_string();
}
if let Some(i) = n.as_i64() {
return i.to_string();
}
match n.as_f64() {
Some(f) => js_f64_to_string(f),
None => "0".to_string(),
}
}
fn js_f64_to_string(f: f64) -> String {
if f == 0.0 {
return "0".to_string();
}
let exp_form = format!("{:e}", f.abs());
let (mantissa, exp) = exp_form
.split_once('e')
.expect("{:e} always emits an exponent separator");
let exp: i64 = exp.parse().expect("{:e} exponent is a base-10 integer");
let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
let k = digits.len() as i64; let n = exp + 1;
let mut out = String::new();
if f < 0.0 {
out.push('-');
}
if k <= n && n <= 21 {
out.push_str(&digits);
out.push_str(&"0".repeat((n - k) as usize));
} else if 0 < n && n <= 21 {
out.push_str(&digits[..n as usize]);
out.push('.');
out.push_str(&digits[n as usize..]);
} else if -6 < n && n <= 0 {
out.push_str("0.");
out.push_str(&"0".repeat((-n) as usize));
out.push_str(&digits);
} else {
out.push_str(&digits[..1]);
if k > 1 {
out.push('.');
out.push_str(&digits[1..]);
}
let e10 = n - 1;
out.push('e');
out.push(if e10 < 0 { '-' } else { '+' });
out.push_str(&e10.abs().to_string());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn pe(json: &str) -> BTreeMap<String, Value> {
match serde_json::from_str::<Value>(json).expect("valid JSON") {
Value::Object(m) => m.into_iter().collect(),
other => panic!("expected JSON object, got {other:?}"),
}
}
#[test]
fn package_extensions_empty_is_none() {
assert_eq!(package_extensions_checksum(&BTreeMap::new()), None);
}
#[test]
fn package_extensions_simple_matches_pnpm() {
let got = package_extensions_checksum(&pe(r#"{"a":{"dependencies":{"b":"1.0.0"}}}"#));
assert_eq!(
got.as_deref(),
Some("sha256-9yDK//Ix13a8CrWmJGIeVC0z1tCnQxNHOLTw47oh10s=")
);
}
#[test]
fn package_extensions_bool_and_number_match_pnpm() {
let got = package_extensions_checksum(&pe(r#"{"k":{"optional":true,"count":3}}"#));
assert_eq!(
got.as_deref(),
Some("sha256-EOT4Rq2KGdwdUwAI9FuL2HmoawSWgN2C+QLiGsRhY20=")
);
}
#[test]
fn js_f64_to_string_matches_v8_notation() {
let cases = [
(0.0_f64, "0"),
(-0.0_f64, "0"),
(3.5, "3.5"),
(0.5, "0.5"),
(0.0001, "0.0001"),
(1e-6, "0.000001"), (1e-7, "1e-7"), (1e-8, "1e-8"),
(-1.5e-7, "-1.5e-7"),
(123.456, "123.456"),
(1e20, "100000000000000000000"), (1e21, "1e+21"), (1.5e21, "1.5e+21"),
(-1e21, "-1e+21"),
];
for (input, want) in cases {
assert_eq!(js_f64_to_string(input), want, "f64 {input:?}");
}
}
#[test]
fn js_number_string_routes_ints_and_floats() {
let int = match serde_json::from_str::<Value>("42").expect("valid JSON") {
Value::Number(n) => n,
other => panic!("expected number, got {other:?}"),
};
let big = match serde_json::from_str::<Value>("1e21").expect("valid JSON") {
Value::Number(n) => n,
other => panic!("expected number, got {other:?}"),
};
assert_eq!(js_number_string(&int), "42");
assert_eq!(js_number_string(&big), "1e+21");
}
#[test]
fn package_extensions_unordered_array_matches_pnpm() {
let got = package_extensions_checksum(&pe(
r#"{"pkg":{"bundledDependencies":["z","a","m"],"dependencies":{"x":"1"}}}"#,
));
assert_eq!(
got.as_deref(),
Some("sha256-9nkLQlH+XcJg38ygPgoq2a+Lz8cfE7PtUaAbUzni6oA=")
);
}
#[test]
fn package_extensions_key_order_is_irrelevant() {
let a = package_extensions_checksum(&pe(r#"{"a":{"x":"1","y":"2"}}"#));
let b = package_extensions_checksum(&pe(r#"{"a":{"y":"2","x":"1"}}"#));
assert!(a.is_some());
assert_eq!(a, b);
}
#[test]
fn package_extensions_realistic_object_is_order_independent() {
let canonical = package_extensions_checksum(&pe(r#"{
"express@*": {"dependencies": {"body-parser": "1.20.2", "compression": "1.7.4"}},
"react-dom@*": {
"dependencies": {"scheduler": "0.23.0"},
"peerDependencies": {"react": "^18.0.0"}
},
"request@*": {
"dependencies": {
"tough-cookie": "github:salesforce/tough-cookie#v4.1.3",
"form-data": "4.0.0"
},
"bundledDependencies": ["zlib", "abbrev", "minimist"]
},
"zod@*": {"dependencies": {"@types/node": "20.11.0"}},
"lodash@*": {"dependencies": {"just-extend": "6.2.0"}}
}"#));
assert!(canonical.is_some());
let reordered = package_extensions_checksum(&pe(r#"{
"lodash@*": {"dependencies": {"just-extend": "6.2.0"}},
"zod@*": {"dependencies": {"@types/node": "20.11.0"}},
"request@*": {
"bundledDependencies": ["minimist", "zlib", "abbrev"],
"dependencies": {
"form-data": "4.0.0",
"tough-cookie": "github:salesforce/tough-cookie#v4.1.3"
}
},
"react-dom@*": {
"peerDependencies": {"react": "^18.0.0"},
"dependencies": {"scheduler": "0.23.0"}
},
"express@*": {"dependencies": {"compression": "1.7.4", "body-parser": "1.20.2"}}
}"#));
assert_eq!(canonical, reordered);
}
#[test]
fn pnpmfile_checksum_empty_is_none() {
assert_eq!(pnpmfile_checksum(&[]).unwrap(), None);
}
#[test]
fn pnpmfile_checksum_single_file_matches_sha256() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".pnpmfile.cjs");
std::fs::write(&path, "module.exports = { hooks: {} };\n").unwrap();
let expected = sha256_base64_prefixed(b"module.exports = { hooks: {} };\n");
assert_eq!(
pnpmfile_checksum(std::slice::from_ref(&path)).unwrap(),
Some(expected)
);
}
#[test]
fn pnpmfile_checksum_normalizes_crlf() {
let dir = tempfile::tempdir().unwrap();
let lf = dir.path().join("lf.cjs");
let crlf = dir.path().join("crlf.cjs");
std::fs::write(&lf, "a\nb\nc\n").unwrap();
std::fs::write(&crlf, "a\r\nb\r\nc\r\n").unwrap();
assert_eq!(
pnpmfile_checksum(std::slice::from_ref(&lf)).unwrap(),
pnpmfile_checksum(std::slice::from_ref(&crlf)).unwrap(),
);
}
#[test]
fn pnpmfile_checksum_multiple_files_hashes_joined_hashes() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.cjs");
let b = dir.path().join("b.cjs");
std::fs::write(&a, "first\n").unwrap();
std::fs::write(&b, "second\n").unwrap();
let ha = sha256_base64_prefixed(b"first\n");
let hb = sha256_base64_prefixed(b"second\n");
let expected = sha256_base64_prefixed(format!("{ha},{hb}").as_bytes());
assert_eq!(
pnpmfile_checksum(&[b.clone(), a.clone()]).unwrap(),
Some(expected)
);
}
}