From 5c99ffcb8ed3bb466477d0aa35674adde1abe374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Sat, 13 Dec 2025 17:22:17 +0100 Subject: [PATCH 01/11] prefetch-npm-deps: add cacheVersion for packument support Add a cacheVersion parameter to fetchNpmDeps and npmDepsCacheVersion to buildNpmPackage. When set to 2, prefetch-npm-deps will also fetch and cache packuments (package metadata) in addition to tarballs. npm can request packuments with two different Accept headers: - corgiDoc: abbreviated metadata (default) - fullDoc: full metadata (used for workspaces) npm's cache policy requires headers to match, so we cache both versions. This is opt-in via cacheVersion to avoid breaking existing hashes. Set npmDepsCacheVersion = 2 for projects using npm workspaces. Also fix cacache index format to properly separate multiple entries with newlines, and update map_cache() to parse multi-line index files. --- .../node/build-npm-package/default.nix | 4 + .../node/prefetch-npm-deps/default.nix | 8 + .../node/prefetch-npm-deps/src/cacache.rs | 26 ++- .../node/prefetch-npm-deps/src/main.rs | 168 +++++++++++++++++- 4 files changed, 200 insertions(+), 6 deletions(-) diff --git a/pkgs/build-support/node/build-npm-package/default.nix b/pkgs/build-support/node/build-npm-package/default.nix index 747043e3d12a..14275ca5cbd6 100644 --- a/pkgs/build-support/node/build-npm-package/default.nix +++ b/pkgs/build-support/node/build-npm-package/default.nix @@ -26,6 +26,9 @@ lib.extendMkDerivation { # The output hash of the dependencies for this project. # Can be calculated in advance with prefetch-npm-deps. npmDepsHash ? "", + # Cache format version for npmDeps. Set to 2 to enable packument caching + # for workspace support. Changing this will invalidate npmDepsHash. + npmDepsCacheVersion ? 1, # Whether to force the usage of Git dependencies that have install scripts, but not a lockfile. # Use with care. forceGitDeps ? false, @@ -66,6 +69,7 @@ lib.extendMkDerivation { ; name = "${name}-npm-deps"; hash = npmDepsHash; + cacheVersion = npmDepsCacheVersion; }, # Custom npmConfigHook npmConfigHook ? null, diff --git a/pkgs/build-support/node/prefetch-npm-deps/default.nix b/pkgs/build-support/node/prefetch-npm-deps/default.nix index d5c67dbca65e..ecf4e36a982f 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/default.nix +++ b/pkgs/build-support/node/prefetch-npm-deps/default.nix @@ -215,6 +215,10 @@ # A string with a JSON attrset specifying registry mirrors, for example # {"registry.example.org": "my-mirror.local/registry.example.org"} npmRegistryOverridesString ? config.npmRegistryOverridesString, + # Cache format version. Bump this to invalidate all existing hashes. + # Version 1: original format (tarballs only) + # Version 2: includes packuments for workspace support + cacheVersion ? 1, ... }@args: let @@ -272,6 +276,10 @@ NIX_NPM_REGISTRY_OVERRIDES = npmRegistryOverridesString; + # Cache version controls which features are enabled in prefetch-npm-deps + # Version 2+ enables packument fetching for workspace support + NPM_CACHE_VERSION = toString cacheVersion; + SSL_CERT_FILE = if ( diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/cacache.rs b/pkgs/build-support/node/prefetch-npm-deps/src/cacache.rs index 403c909dee11..19f330768f44 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/cacache.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/cacache.rs @@ -24,9 +24,16 @@ pub(super) struct Key { #[derive(Serialize, Deserialize)] pub(super) struct Metadata { pub(super) url: Url, + #[serde(rename = "reqHeaders", skip_serializing_if = "Option::is_none")] + pub(super) req_headers: Option, pub(super) options: Options, } +#[derive(Serialize, Deserialize)] +pub struct ReqHeaders { + pub accept: String, +} + #[derive(Serialize, Deserialize)] pub(super) struct Options { pub(super) compress: bool, @@ -58,6 +65,7 @@ impl Cache { url: Url, data: &[u8], integrity: Option, + req_headers: Option, ) -> anyhow::Result<()> { let (algo, hash, integrity) = if let Some(integrity) = integrity { let (algo, hash) = integrity @@ -115,13 +123,27 @@ impl Cache { size: data.len(), metadata: Metadata { url, + req_headers, options: Options { compress: true }, }, })?; - let mut file = File::options().append(true).create(true).open(index_path)?; + let mut file = File::options() + .append(true) + .create(true) + .open(&index_path)?; - write!(file, "{:x}\t{data}", Sha1::new().chain(&data).finalize())?; + // cacache format uses newline as entry separator (see cacache entry-index.js) + // Only add newline prefix if file already has content, to maintain backwards compatibility + let needs_newline = fs::metadata(&index_path) + .map(|m| m.len() > 0) + .unwrap_or(false); + let prefix = if needs_newline { "\n" } else { "" }; + write!( + file, + "{prefix}{:x}\t{data}", + Sha1::new().chain(&data).finalize() + )?; Ok(()) } diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs index cdac7ef84c0b..03db14740aa5 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs @@ -1,11 +1,12 @@ #![warn(clippy::pedantic)] -use crate::cacache::{Cache, Key}; +use crate::cacache::{Cache, Key, ReqHeaders}; use anyhow::{anyhow, bail}; +use log::info; use rayon::prelude::*; use serde_json::{Map, Value}; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, env, fs, path::{Path, PathBuf}, process, @@ -22,6 +23,104 @@ fn cache_map_path() -> Option { env::var_os("CACHE_MAP_PATH").map(PathBuf::from) } +/// Extract the package name from an npm registry tarball URL. +/// e.g., "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz" +/// -> Some("@types/react-dom") +fn extract_package_name_from_url(url: &Url) -> Option { + // Only handle npm registry URLs + let host = url.host_str()?; + if !host.contains("npmjs.org") && !host.contains("npm.") { + return None; + } + + let path = url.path(); + // npm tarball URLs look like: + // /@scope/name/-/name-version.tgz + // /name/-/name-version.tgz + + // Find the "/-/" separator which precedes the tarball filename + let separator_idx = path.find("/-/")?; + let package_path = &path[1..separator_idx]; // Skip leading / + + Some(package_path.to_string()) +} + +/// Get the packument URL for a package name +fn get_packument_url(registry: &str, package_name: &str) -> anyhow::Result { + // URL-encode the package name for scoped packages + let encoded_name = package_name.replace('/', "%2f"); + Url::parse(&format!("{registry}/{encoded_name}")) + .map_err(|e| anyhow!("failed to construct packument URL: {e}")) +} + +/// Fetch and cache packuments (package metadata) for all packages. +/// +/// This is needed because npm may query package metadata for optional peer dependencies +/// and for workspace packages. +/// +/// npm's cache policy checks that the Accept header matches between the cached +/// request and the new request. npm can request packuments with two different headers: +/// 1. "corgiDoc" (abbreviated metadata) - used initially +/// 2. "fullDoc" (full metadata) - used when npm needs full package info (e.g., workspaces) +/// +/// We cache both versions to ensure cache hits regardless of which header npm uses. +/// See: pacote/lib/registry.js and @npmcli/arborist/lib/arborist/build-ideal-tree.js +fn fetch_packuments(cache: &Cache, package_names: HashSet) -> anyhow::Result<()> { + const CORGI_DOC: &str = + "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"; + const FULL_DOC: &str = "application/json"; + + info!("Fetching {} packuments", package_names.len()); + + package_names.into_par_iter().try_for_each(|package_name| { + let packument_url = get_packument_url("https://registry.npmjs.org", &package_name)?; + + match util::get_url_body_with_retry(&packument_url) { + Ok(packument_data) => { + // npm's make-fetch-happen uses the URL-encoded form for cache keys + // e.g., "https://registry.npmjs.org/@types%2freact-dom" not "@types/react-dom" + // We must use the encoded form in both the cache key string AND the metadata URL + + // Cache with corgiDoc header (for initial requests) + cache + .put( + format!("make-fetch-happen:request-cache:{packument_url}"), + packument_url.clone(), + &packument_data, + None, // Packuments don't have integrity hashes + Some(ReqHeaders { + accept: String::from(CORGI_DOC), + }), + ) + .map_err(|e| { + anyhow!("couldn't insert packument cache entry (corgi) for {package_name}: {e:?}") + })?; + + // Cache with fullDoc header (for workspace/full metadata requests) + cache + .put( + format!("make-fetch-happen:request-cache:{packument_url}"), + packument_url.clone(), + &packument_data, + None, + Some(ReqHeaders { + accept: String::from(FULL_DOC), + }), + ) + .map_err(|e| { + anyhow!("couldn't insert packument cache entry (full) for {package_name}: {e:?}") + })?; + } + Err(e) => { + // Log but don't fail - some packages might not need packuments + info!("Warning: couldn't fetch packument for {package_name}: {e}"); + } + } + + Ok::<_, anyhow::Error>(()) + }) +} + /// `fixup_lockfile` rewrites `integrity` hashes to match cache and removes the `integrity` field from Git dependencies. /// /// Sometimes npm has multiple instances of a given `resolved` URL that have different types of `integrity` hashes (e.g. SHA-1 @@ -157,9 +256,21 @@ fn map_cache() -> anyhow::Result> { if entry.file_type().is_file() { let content = fs::read_to_string(entry.path())?; - let key: Key = serde_json::from_str(content.split_ascii_whitespace().nth(1).unwrap())?; + // cacache index format: each line is \t + // Multiple entries can exist in the same file (e.g., same URL with different headers) + for line in content.lines() { + if line.is_empty() { + continue; + } + // Split on tab, not whitespace, because JSON values may contain spaces + let json_part = line + .split_once('\t') + .map(|(_, json)| json) + .ok_or_else(|| anyhow!("invalid cache index entry: missing tab separator"))?; + let key: Key = serde_json::from_str(json_part)?; - hashes.insert(key.metadata.url, key.integrity); + hashes.insert(key.metadata.url, key.integrity); + } } } @@ -241,6 +352,13 @@ fn main() -> anyhow::Result<()> { let cache = Cache::new(out.join("_cacache")); cache.init()?; + // Collect unique package names for packument fetching + let package_names: HashSet = packages + .iter() + .filter_map(|p| extract_package_name_from_url(&p.url)) + .collect(); + + // Fetch and cache tarballs packages.into_par_iter().try_for_each(|package| { let tarball = package .tarball() @@ -253,12 +371,23 @@ fn main() -> anyhow::Result<()> { package.url, &tarball, integrity, + None, // tarballs don't need special request headers ) .map_err(|e| anyhow!("couldn't insert cache entry for {}: {e:?}", package.name))?; Ok::<_, anyhow::Error>(()) })?; + // Fetch and cache packuments (package metadata) - only for cache version 2+ + let cache_version: u32 = env::var("NPM_CACHE_VERSION") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1); + + if cache_version >= 2 { + fetch_packuments(&cache, package_names)?; + } + fs::write(out.join("package-lock.json"), lock_content)?; if print_hash { @@ -422,4 +551,35 @@ mod tests { Ok(()) } + + #[test] + fn test_extract_package_name_from_url() { + use super::extract_package_name_from_url; + use url::Url; + + // Regular package + assert_eq!( + extract_package_name_from_url( + &Url::parse("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz").unwrap() + ), + Some("lodash".to_string()) + ); + + // Scoped package + assert_eq!( + extract_package_name_from_url( + &Url::parse("https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz") + .unwrap() + ), + Some("@types/react-dom".to_string()) + ); + + // Non-npm URL should return None + assert_eq!( + extract_package_name_from_url( + &Url::parse("https://github.com/foo/bar/archive/main.tar.gz").unwrap() + ), + None + ); + } } From cd638a9529e92427b8af078bfd5f6449edc4e346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Thu, 25 Dec 2025 06:20:10 +0000 Subject: [PATCH 02/11] doc: add release note for buildNpmPackage packument support Document the new npmDepsCacheVersion option that enables packument caching for npm workspaces support. --- doc/release-notes/rl-2605.section.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/release-notes/rl-2605.section.md b/doc/release-notes/rl-2605.section.md index b4d218f8fd91..60735b4a57f5 100644 --- a/doc/release-notes/rl-2605.section.md +++ b/doc/release-notes/rl-2605.section.md @@ -71,6 +71,8 @@ - `fetchPnpmDeps` and `pnpmConfigHook` were added as top-level attributes, replacing the now deprecated `pnpm.fetchDeps` and `pnpm.configHook` attributes. +- `buildNpmPackage` now supports `npmDepsCacheVersion`. Set to `2` to enable packument caching, which fixes builds for projects using npm workspaces. + - Added `dell-bios-fan-control` package and service. - We now use the upstream wrapper script for Gradle, supporting both the `JAVA_HOME` and `GRADLE_OPTS` environment variables. From 0b66fed9d7a4ee6f48a3f9ee68b7f71e00cbc896 Mon Sep 17 00:00:00 2001 From: Winter Date: Wed, 31 Dec 2025 00:03:35 -0500 Subject: [PATCH 03/11] buildNpmPackage: add diagnostic for cache version mismatch --- .../node/build-npm-package/default.nix | 4 +++ .../hooks/npm-config-hook.sh | 25 +++++++++++++++++++ .../node/prefetch-npm-deps/src/main.rs | 1 + 3 files changed, 30 insertions(+) diff --git a/pkgs/build-support/node/build-npm-package/default.nix b/pkgs/build-support/node/build-npm-package/default.nix index 14275ca5cbd6..9916826746f0 100644 --- a/pkgs/build-support/node/build-npm-package/default.nix +++ b/pkgs/build-support/node/build-npm-package/default.nix @@ -89,6 +89,10 @@ lib.extendMkDerivation { { inherit npmDeps npmBuildScript; + env = (args.env or { }) // { + NIX_NPM_CACHE_VERSION = npmDepsCacheVersion; + }; + nativeBuildInputs = nativeBuildInputs ++ [ diff --git a/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh b/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh index d954bb010df2..a28b346dac84 100644 --- a/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh +++ b/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh @@ -28,6 +28,31 @@ npmConfigHook() { exit 1 fi + # Only run this in buildNpmPackage, this is just for a nicer error message; we trust that + # people using the setup hook directly also know how FODs work. ;) + if [[ -n ${NIX_NPM_CACHE_VERSION+x} ]]; then + if [[ -e "$npmDeps/cache_version" ]]; then + local -r cacheVersion=$(cat "$npmDeps/cache_version") + else + local -r cacheVersion="1" + fi + + if [[ $NIX_NPM_CACHE_VERSION != $cacheVersion ]]; then + echo + echo "ERROR: npmDepsHash is out of date" + echo + echo "The cache version in the arguments to buildNpmPackage ($NIX_NPM_CACHE_VERSION) is not the same as the one in $npmDeps ($cacheVersion)." + echo + echo "To fix the issue:" + echo '1. Use `lib.fakeHash` as the npmDepsHash value' + echo "2. Build the derivation and wait for it to fail with a hash mismatch" + echo "3. Copy the 'got: sha256-' value back into the npmDepsHash field" + echo + + exit 1 + fi + fi + local -r cacheLockfile="$npmDeps/package-lock.json" if [[ -f npm-shrinkwrap.json ]]; then local -r srcLockfile="$PWD/npm-shrinkwrap.json" diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs index 03db14740aa5..bd275fe63e4c 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs @@ -386,6 +386,7 @@ fn main() -> anyhow::Result<()> { if cache_version >= 2 { fetch_packuments(&cache, package_names)?; + fs::write(out.join("cache_version"), format!("{cache_version}"))?; } fs::write(out.join("package-lock.json"), lock_content)?; From f297c444c22845de73a5cc555448630dffb5c6d6 Mon Sep 17 00:00:00 2001 From: Winter Date: Wed, 31 Dec 2025 00:23:18 -0500 Subject: [PATCH 04/11] npmConfigHook: always make cache writable when using cache v2 --- .../hooks/npm-config-hook.sh | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh b/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh index a28b346dac84..18937c85c692 100644 --- a/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh +++ b/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh @@ -28,29 +28,27 @@ npmConfigHook() { exit 1 fi + if [[ -e "$npmDeps/cache_version" ]]; then + local -r cacheVersion=$(cat "$npmDeps/cache_version") + else + local -r cacheVersion="1" + fi + # Only run this in buildNpmPackage, this is just for a nicer error message; we trust that # people using the setup hook directly also know how FODs work. ;) - if [[ -n ${NIX_NPM_CACHE_VERSION+x} ]]; then - if [[ -e "$npmDeps/cache_version" ]]; then - local -r cacheVersion=$(cat "$npmDeps/cache_version") - else - local -r cacheVersion="1" - fi + if [[ -n ${NIX_NPM_CACHE_VERSION+x} ]] && [[ $NIX_NPM_CACHE_VERSION != $cacheVersion ]]; then + echo + echo "ERROR: npmDepsHash is out of date" + echo + echo "The cache version in the arguments to buildNpmPackage ($NIX_NPM_CACHE_VERSION) is not the same as the one in $npmDeps ($cacheVersion)." + echo + echo "To fix the issue:" + echo '1. Use `lib.fakeHash` as the npmDepsHash value' + echo "2. Build the derivation and wait for it to fail with a hash mismatch" + echo "3. Copy the 'got: sha256-' value back into the npmDepsHash field" + echo - if [[ $NIX_NPM_CACHE_VERSION != $cacheVersion ]]; then - echo - echo "ERROR: npmDepsHash is out of date" - echo - echo "The cache version in the arguments to buildNpmPackage ($NIX_NPM_CACHE_VERSION) is not the same as the one in $npmDeps ($cacheVersion)." - echo - echo "To fix the issue:" - echo '1. Use `lib.fakeHash` as the npmDepsHash value' - echo "2. Build the derivation and wait for it to fail with a hash mismatch" - echo "3. Copy the 'got: sha256-' value back into the npmDepsHash field" - echo - - exit 1 - fi + exit 1 fi local -r cacheLockfile="$npmDeps/package-lock.json" @@ -103,7 +101,11 @@ npmConfigHook() { local cachePath - if [ -z "${makeCacheWritable-}" ]; then + # When a given cache key has multiple entries (which is the case with + # cache version 2), npm always needs to write to the cache. + # + # TODO(winter): report upstream? + if [ -z "${makeCacheWritable-}" ] && (( cacheVersion == 1 )); then cachePath="$npmDeps" else echo "Making cache writable" From d028ea9cd8a4abf92d69b993467e702c0e778e55 Mon Sep 17 00:00:00 2001 From: Winter Date: Wed, 31 Dec 2025 00:35:11 -0500 Subject: [PATCH 05/11] npmConfigHook: suggest cache v2 when `npm install` fails --- .../node/build-npm-package/hooks/npm-config-hook.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh b/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh index 18937c85c692..c9e8dac4c78d 100644 --- a/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh +++ b/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh @@ -127,9 +127,10 @@ npmConfigHook() { echo "ERROR: npm failed to install dependencies" echo echo "Here are a few things you can try, depending on the error:" - echo '1. Set `makeCacheWritable = true`' + echo '1. Set `npmDepsCacheVersion = 2` (and update `npmDepsHash`)' + echo '2. Set `makeCacheWritable = true`' echo " Note that this won't help if npm is complaining about not being able to write to the logs directory -- look above that for the actual error." - echo '2. Set `npmFlags = [ "--legacy-peer-deps" ]`' + echo '3. Set `npmFlags = [ "--legacy-peer-deps" ]`' echo exit 1 From 3fb4fe294bf77a2916009090fe1ea939ab958fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 31 Dec 2025 06:36:29 +0000 Subject: [PATCH 06/11] prefetch-npm-deps: extract package names from lockfile keys The current approach parses tarball URLs to extract package names for packument fetching. This is fragile as it only handles npmjs.org URLs and requires special-casing other registries. Use lockfile keys directly instead. The lockfile already contains the canonical package names in the form "node_modules/@scope/name", so we can simply strip the prefix rather than parsing URLs. This handles all registries uniformly and eliminates the URL parsing code along with its tests. --- .../node/prefetch-npm-deps/src/main.rs | 55 +------------------ 1 file changed, 2 insertions(+), 53 deletions(-) diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs index bd275fe63e4c..4c09b0cdbbf8 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs @@ -23,28 +23,6 @@ fn cache_map_path() -> Option { env::var_os("CACHE_MAP_PATH").map(PathBuf::from) } -/// Extract the package name from an npm registry tarball URL. -/// e.g., "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz" -/// -> Some("@types/react-dom") -fn extract_package_name_from_url(url: &Url) -> Option { - // Only handle npm registry URLs - let host = url.host_str()?; - if !host.contains("npmjs.org") && !host.contains("npm.") { - return None; - } - - let path = url.path(); - // npm tarball URLs look like: - // /@scope/name/-/name-version.tgz - // /name/-/name-version.tgz - - // Find the "/-/" separator which precedes the tarball filename - let separator_idx = path.find("/-/")?; - let package_path = &path[1..separator_idx]; // Skip leading / - - Some(package_path.to_string()) -} - /// Get the packument URL for a package name fn get_packument_url(registry: &str, package_name: &str) -> anyhow::Result { // URL-encode the package name for scoped packages @@ -353,9 +331,10 @@ fn main() -> anyhow::Result<()> { cache.init()?; // Collect unique package names for packument fetching + // Extract from lockfile keys like "node_modules/@scope/name" -> "@scope/name" let package_names: HashSet = packages .iter() - .filter_map(|p| extract_package_name_from_url(&p.url)) + .filter_map(|p| p.name.rsplit_once("node_modules/").map(|(_, n)| n.to_string())) .collect(); // Fetch and cache tarballs @@ -553,34 +532,4 @@ mod tests { Ok(()) } - #[test] - fn test_extract_package_name_from_url() { - use super::extract_package_name_from_url; - use url::Url; - - // Regular package - assert_eq!( - extract_package_name_from_url( - &Url::parse("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz").unwrap() - ), - Some("lodash".to_string()) - ); - - // Scoped package - assert_eq!( - extract_package_name_from_url( - &Url::parse("https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz") - .unwrap() - ), - Some("@types/react-dom".to_string()) - ); - - // Non-npm URL should return None - assert_eq!( - extract_package_name_from_url( - &Url::parse("https://github.com/foo/bar/archive/main.tar.gz").unwrap() - ), - None - ); - } } From 40f1b80ac713e6f09df5aafc386525c15769c0c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 31 Dec 2025 06:57:01 +0000 Subject: [PATCH 07/11] prefetch-npm-deps: clarify backwards compatibility comment --- pkgs/build-support/node/prefetch-npm-deps/src/cacache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/cacache.rs b/pkgs/build-support/node/prefetch-npm-deps/src/cacache.rs index 19f330768f44..648a9ae1a828 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/cacache.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/cacache.rs @@ -134,7 +134,7 @@ impl Cache { .open(&index_path)?; // cacache format uses newline as entry separator (see cacache entry-index.js) - // Only add newline prefix if file already has content, to maintain backwards compatibility + // Only add newline prefix if file already has content, to maintain backwards compatibility with previous versions of prefetch-npm-deps, which handled this case incorrectly. let needs_newline = fs::metadata(&index_path) .map(|m| m.len() > 0) .unwrap_or(false); From 0cb5de2d62c6e80d209c7d0e02f130210e5fba94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Wed, 31 Dec 2025 07:53:32 +0000 Subject: [PATCH 08/11] prefetch-npm-deps: use package name field for aliases npm lockfiles can contain package aliases where the lockfile key differs from the actual package name (e.g., "string-width-cjs" aliasing "string-width"). Previously we always used the lockfile key, causing us to fetch packuments for the wrong package. Use the package's own "name" field when present, falling back to the lockfile key. This ensures we fetch the correct packument for aliased packages, fixing non-deterministic builds where the wrong packument fetch could succeed or fail depending on network timing. --- .../build-support/node/prefetch-npm-deps/src/parse/lock.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/parse/lock.rs b/pkgs/build-support/node/prefetch-npm-deps/src/parse/lock.rs index 17c6c633ee8f..266ba2585913 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/parse/lock.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/parse/lock.rs @@ -25,7 +25,12 @@ pub(super) fn packages(content: &str) -> anyhow::Result> { .unwrap_or_default() .into_iter() .filter(|(n, p)| !n.is_empty() && matches!(p.resolved, Some(UrlOrString::Url(_)))) - .map(|(n, p)| Package { name: Some(n), ..p }) + .map(|(n, p)| Package { + // Use the package's own name if present (for aliases like string-width-cjs -> string-width), + // otherwise extract from the lockfile key + name: Some(p.name.unwrap_or(n)), + ..p + }) .collect(), _ => bail!( "We don't support lockfile version {}, please file an issue.", From ab89ffc4b119c610bdea857a5c245741de8bf581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Sat, 3 Jan 2026 18:45:33 +0000 Subject: [PATCH 09/11] fetchNpmDeps: rename cacheVersion to fetcherVersion To align with pnpm tooling. --- .../node/build-npm-package/default.nix | 8 ++++---- .../build-npm-package/hooks/npm-config-hook.sh | 16 ++++++++-------- .../node/prefetch-npm-deps/default.nix | 8 ++++---- .../node/prefetch-npm-deps/src/main.rs | 8 ++++---- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/pkgs/build-support/node/build-npm-package/default.nix b/pkgs/build-support/node/build-npm-package/default.nix index 9916826746f0..1e316d1be9c6 100644 --- a/pkgs/build-support/node/build-npm-package/default.nix +++ b/pkgs/build-support/node/build-npm-package/default.nix @@ -26,9 +26,9 @@ lib.extendMkDerivation { # The output hash of the dependencies for this project. # Can be calculated in advance with prefetch-npm-deps. npmDepsHash ? "", - # Cache format version for npmDeps. Set to 2 to enable packument caching + # Fetcher format version for npmDeps. Set to 2 to enable packument caching # for workspace support. Changing this will invalidate npmDepsHash. - npmDepsCacheVersion ? 1, + npmDepsFetcherVersion ? 1, # Whether to force the usage of Git dependencies that have install scripts, but not a lockfile. # Use with care. forceGitDeps ? false, @@ -69,7 +69,7 @@ lib.extendMkDerivation { ; name = "${name}-npm-deps"; hash = npmDepsHash; - cacheVersion = npmDepsCacheVersion; + fetcherVersion = npmDepsFetcherVersion; }, # Custom npmConfigHook npmConfigHook ? null, @@ -90,7 +90,7 @@ lib.extendMkDerivation { inherit npmDeps npmBuildScript; env = (args.env or { }) // { - NIX_NPM_CACHE_VERSION = npmDepsCacheVersion; + NIX_NPM_FETCHER_VERSION = npmDepsFetcherVersion; }; nativeBuildInputs = diff --git a/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh b/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh index c9e8dac4c78d..3e7fcbd69133 100644 --- a/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh +++ b/pkgs/build-support/node/build-npm-package/hooks/npm-config-hook.sh @@ -28,19 +28,19 @@ npmConfigHook() { exit 1 fi - if [[ -e "$npmDeps/cache_version" ]]; then - local -r cacheVersion=$(cat "$npmDeps/cache_version") + if [[ -e "$npmDeps/.fetcher-version" ]]; then + local -r fetcherVersion=$(cat "$npmDeps/.fetcher-version") else - local -r cacheVersion="1" + local -r fetcherVersion="1" fi # Only run this in buildNpmPackage, this is just for a nicer error message; we trust that # people using the setup hook directly also know how FODs work. ;) - if [[ -n ${NIX_NPM_CACHE_VERSION+x} ]] && [[ $NIX_NPM_CACHE_VERSION != $cacheVersion ]]; then + if [[ -n ${NIX_NPM_FETCHER_VERSION+x} ]] && [[ $NIX_NPM_FETCHER_VERSION != $fetcherVersion ]]; then echo echo "ERROR: npmDepsHash is out of date" echo - echo "The cache version in the arguments to buildNpmPackage ($NIX_NPM_CACHE_VERSION) is not the same as the one in $npmDeps ($cacheVersion)." + echo "The fetcher version in the arguments to buildNpmPackage ($NIX_NPM_FETCHER_VERSION) is not the same as the one in $npmDeps ($fetcherVersion)." echo echo "To fix the issue:" echo '1. Use `lib.fakeHash` as the npmDepsHash value' @@ -102,10 +102,10 @@ npmConfigHook() { local cachePath # When a given cache key has multiple entries (which is the case with - # cache version 2), npm always needs to write to the cache. + # fetcher version 2), npm always needs to write to the cache. # # TODO(winter): report upstream? - if [ -z "${makeCacheWritable-}" ] && (( cacheVersion == 1 )); then + if [ -z "${makeCacheWritable-}" ] && (( fetcherVersion == 1 )); then cachePath="$npmDeps" else echo "Making cache writable" @@ -127,7 +127,7 @@ npmConfigHook() { echo "ERROR: npm failed to install dependencies" echo echo "Here are a few things you can try, depending on the error:" - echo '1. Set `npmDepsCacheVersion = 2` (and update `npmDepsHash`)' + echo '1. Set `npmDepsFetcherVersion = 2` (and update `npmDepsHash`)' echo '2. Set `makeCacheWritable = true`' echo " Note that this won't help if npm is complaining about not being able to write to the logs directory -- look above that for the actual error." echo '3. Set `npmFlags = [ "--legacy-peer-deps" ]`' diff --git a/pkgs/build-support/node/prefetch-npm-deps/default.nix b/pkgs/build-support/node/prefetch-npm-deps/default.nix index ecf4e36a982f..bfa8fb863e4b 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/default.nix +++ b/pkgs/build-support/node/prefetch-npm-deps/default.nix @@ -215,10 +215,10 @@ # A string with a JSON attrset specifying registry mirrors, for example # {"registry.example.org": "my-mirror.local/registry.example.org"} npmRegistryOverridesString ? config.npmRegistryOverridesString, - # Cache format version. Bump this to invalidate all existing hashes. + # Fetcher format version. Bump this to invalidate all existing hashes. # Version 1: original format (tarballs only) # Version 2: includes packuments for workspace support - cacheVersion ? 1, + fetcherVersion ? 1, ... }@args: let @@ -276,9 +276,9 @@ NIX_NPM_REGISTRY_OVERRIDES = npmRegistryOverridesString; - # Cache version controls which features are enabled in prefetch-npm-deps + # Fetcher version controls which features are enabled in prefetch-npm-deps # Version 2+ enables packument fetching for workspace support - NPM_CACHE_VERSION = toString cacheVersion; + NPM_FETCHER_VERSION = toString fetcherVersion; SSL_CERT_FILE = if diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs index 4c09b0cdbbf8..fa594ce5b2af 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs @@ -357,15 +357,15 @@ fn main() -> anyhow::Result<()> { Ok::<_, anyhow::Error>(()) })?; - // Fetch and cache packuments (package metadata) - only for cache version 2+ - let cache_version: u32 = env::var("NPM_CACHE_VERSION") + // Fetch and cache packuments (package metadata) - only for fetcher version 2+ + let fetcher_version: u32 = env::var("NPM_FETCHER_VERSION") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(1); - if cache_version >= 2 { + if fetcher_version >= 2 { fetch_packuments(&cache, package_names)?; - fs::write(out.join("cache_version"), format!("{cache_version}"))?; + fs::write(out.join(".fetcher-version"), format!("{fetcher_version}"))?; } fs::write(out.join("package-lock.json"), lock_content)?; From dbc8a3bc5c2bb1458acab7cbcf424fcb5d6af7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Sun, 4 Jan 2026 13:57:33 +0000 Subject: [PATCH 10/11] prefetch-npm-deps: normalize packuments for deterministic hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed-output derivations for npm dependencies fail with hash mismatches when rebuilt on different days. The root cause is that npm registry metadata (packuments) contain volatile fields that change whenever upstream publishes a new version. For example, the TypeScript packument changes daily due to nightly releases, even though the lockfile pins a specific version. The cached packument includes _rev, time, modified, and the full versions list - all of which drift over time. Strip these volatile fields and filter the versions map to only include versions actually referenced in the lockfile. Signed-off-by: Jörg Thalheim --- .../node/prefetch-npm-deps/src/main.rs | 173 ++++++++++++------ .../node/prefetch-npm-deps/src/parse/lock.rs | 3 + .../node/prefetch-npm-deps/src/parse/mod.rs | 2 + 3 files changed, 127 insertions(+), 51 deletions(-) diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs index fa594ce5b2af..7666474f9fe1 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs @@ -31,6 +31,57 @@ fn get_packument_url(registry: &str, package_name: &str) -> anyhow::Result .map_err(|e| anyhow!("failed to construct packument URL: {e}")) } +/// Normalize packument data to ensure determinism. +/// +/// Strips volatile fields like `_rev`, `time`, and `modified`. +/// Filters the `versions` map to only include versions requested in the lockfile. +fn normalize_packument( + package_name: &str, + data: &[u8], + requested_versions: &HashSet, +) -> anyhow::Result> { + let mut json: Value = serde_json::from_slice(data) + .map_err(|e| anyhow!("failed to parse packument JSON for {package_name}: {e}"))?; + + let obj = json + .as_object_mut() + .ok_or_else(|| anyhow!("packument for {package_name} is not a JSON object"))?; + + // Strip volatile top-level fields + obj.remove("_rev"); + obj.remove("time"); + obj.remove("modified"); + + // Filter versions to only those in lockfile + if let Some(Value::Object(versions)) = obj.get_mut("versions") { + versions.retain(|version, _| requested_versions.contains(version)); + + // Normalize each version object + for version_val in versions.values_mut() { + if let Some(version_obj) = version_val.as_object_mut() { + // Strip fields starting with underscore (volatile/internal) + version_obj.retain(|key, _| !key.starts_with('_')); + // Strip other often-volatile fields + version_obj.remove("gitHead"); + } + } + } + + // Filter dist-tags to only point to versions we kept + if let Some(Value::Object(tags)) = obj.get_mut("dist-tags") { + tags.retain(|_, version_val| { + if let Some(version) = version_val.as_str() { + requested_versions.contains(version) + } else { + false + } + }); + } + + serde_json::to_vec(&json) + .map_err(|e| anyhow!("failed to re-serialize packument for {package_name}: {e}")) +} + /// Fetch and cache packuments (package metadata) for all packages. /// /// This is needed because npm may query package metadata for optional peer dependencies @@ -43,60 +94,68 @@ fn get_packument_url(registry: &str, package_name: &str) -> anyhow::Result /// /// We cache both versions to ensure cache hits regardless of which header npm uses. /// See: pacote/lib/registry.js and @npmcli/arborist/lib/arborist/build-ideal-tree.js -fn fetch_packuments(cache: &Cache, package_names: HashSet) -> anyhow::Result<()> { +fn fetch_packuments( + cache: &Cache, + package_versions: HashMap>, +) -> anyhow::Result<()> { const CORGI_DOC: &str = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"; const FULL_DOC: &str = "application/json"; - info!("Fetching {} packuments", package_names.len()); + info!("Fetching {} packuments", package_versions.len()); - package_names.into_par_iter().try_for_each(|package_name| { - let packument_url = get_packument_url("https://registry.npmjs.org", &package_name)?; + package_versions + .into_par_iter() + .try_for_each(|(package_name, requested_versions)| { + let packument_url = get_packument_url("https://registry.npmjs.org", &package_name)?; - match util::get_url_body_with_retry(&packument_url) { - Ok(packument_data) => { - // npm's make-fetch-happen uses the URL-encoded form for cache keys - // e.g., "https://registry.npmjs.org/@types%2freact-dom" not "@types/react-dom" - // We must use the encoded form in both the cache key string AND the metadata URL + match util::get_url_body_with_retry(&packument_url) { + Ok(packument_data) => { + let normalized_data = + normalize_packument(&package_name, &packument_data, &requested_versions)?; - // Cache with corgiDoc header (for initial requests) - cache - .put( - format!("make-fetch-happen:request-cache:{packument_url}"), - packument_url.clone(), - &packument_data, - None, // Packuments don't have integrity hashes - Some(ReqHeaders { - accept: String::from(CORGI_DOC), - }), - ) - .map_err(|e| { - anyhow!("couldn't insert packument cache entry (corgi) for {package_name}: {e:?}") - })?; + // npm's make-fetch-happen uses the URL-encoded form for cache keys + // e.g., "https://registry.npmjs.org/@types%2freact-dom" not "@types/react-dom" + // We must use the encoded form in both the cache key string AND the metadata URL - // Cache with fullDoc header (for workspace/full metadata requests) - cache - .put( - format!("make-fetch-happen:request-cache:{packument_url}"), - packument_url.clone(), - &packument_data, - None, - Some(ReqHeaders { - accept: String::from(FULL_DOC), - }), - ) - .map_err(|e| { - anyhow!("couldn't insert packument cache entry (full) for {package_name}: {e:?}") - })?; + // Cache with corgiDoc header (for initial requests) + cache + .put( + format!("make-fetch-happen:request-cache:{packument_url}"), + packument_url.clone(), + &normalized_data, + None, // Packuments don't have integrity hashes + Some(ReqHeaders { + accept: String::from(CORGI_DOC), + }), + ) + .map_err(|e| { + anyhow!("couldn't insert packument cache entry (corgi) for {package_name}: {e:?}") + })?; + + // Cache with fullDoc header (for workspace/full metadata requests) + cache + .put( + format!("make-fetch-happen:request-cache:{packument_url}"), + packument_url.clone(), + &normalized_data, + None, + Some(ReqHeaders { + accept: String::from(FULL_DOC), + }), + ) + .map_err(|e| { + anyhow!("couldn't insert packument cache entry (full) for {package_name}: {e:?}") + })?; + } + Err(e) => { + // Log but don't fail - some packages might not need packuments + info!("Warning: couldn't fetch packument for {package_name}: {e}"); + } } - Err(e) => { - // Log but don't fail - some packages might not need packuments - info!("Warning: couldn't fetch packument for {package_name}: {e}"); - } - } - Ok::<_, anyhow::Error>(()) - }) + Ok::<_, anyhow::Error>(()) + }) } /// `fixup_lockfile` rewrites `integrity` hashes to match cache and removes the `integrity` field from Git dependencies. @@ -330,12 +389,24 @@ fn main() -> anyhow::Result<()> { let cache = Cache::new(out.join("_cacache")); cache.init()?; - // Collect unique package names for packument fetching - // Extract from lockfile keys like "node_modules/@scope/name" -> "@scope/name" - let package_names: HashSet = packages - .iter() - .filter_map(|p| p.name.rsplit_once("node_modules/").map(|(_, n)| n.to_string())) - .collect(); + // Collect package names and their versions from the lockfile for packument filtering. + // For non-aliased packages: extract from lockfile keys like "node_modules/@scope/name" -> "@scope/name" + // For aliased packages: the name is already the real package name (e.g., "string-width" not "string-width-cjs") + // We only care about packages that have a version string (registry packages). + let mut package_versions: HashMap> = HashMap::new(); + for p in &packages { + if let Some(version) = &p.version { + let pkg_name = p + .name + .rsplit_once("node_modules/") + .map(|(_, name)| name.to_string()) + .unwrap_or_else(|| p.name.clone()); + package_versions + .entry(pkg_name) + .or_default() + .insert(version.to_string()); + } + } // Fetch and cache tarballs packages.into_par_iter().try_for_each(|package| { @@ -364,7 +435,7 @@ fn main() -> anyhow::Result<()> { .unwrap_or(1); if fetcher_version >= 2 { - fetch_packuments(&cache, package_names)?; + fetch_packuments(&cache, package_versions)?; fs::write(out.join(".fetcher-version"), format!("{fetcher_version}"))?; } diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/parse/lock.rs b/pkgs/build-support/node/prefetch-npm-deps/src/parse/lock.rs index 266ba2585913..c420fb1e3ede 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/parse/lock.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/parse/lock.rs @@ -78,6 +78,7 @@ struct OldPackage { pub(super) struct Package { #[serde(default)] pub(super) name: Option, + pub(super) version: Option, pub(super) resolved: Option, pub(super) integrity: Option, } @@ -254,6 +255,7 @@ fn to_new_packages( new.push(Package { name: Some(name), + version: None, // v1 lockfiles don't include version in the same structure resolved: if matches!(package.version, UrlOrString::Url(_)) { Some(package.version) } else { @@ -315,6 +317,7 @@ mod tests { assert_eq!(new.len(), 1, "new packages map should contain 1 value"); assert_eq!(new[0], Package { name: Some(String::from("sqlite3")), + version: None, resolved: Some(UrlOrString::Url(Url::parse("git+ssh://git@github.com/mapbox/node-sqlite3.git#593c9d498be2510d286349134537e3bf89401c4a").unwrap())), integrity: None }); diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/parse/mod.rs b/pkgs/build-support/node/prefetch-npm-deps/src/parse/mod.rs index 6c54d404f3cb..3e6f5019ea33 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/parse/mod.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/parse/mod.rs @@ -112,6 +112,7 @@ pub fn lockfile( #[derive(Debug)] pub struct Package { pub name: String, + pub version: Option, pub url: Url, specifics: Specifics, } @@ -175,6 +176,7 @@ impl Package { Ok(Package { name: pkg.name.unwrap(), + version: pkg.version, url: resolved, specifics, }) From 08e805c5504e124b1e9dbac6a1d6a5b42f542c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Sun, 11 Jan 2026 21:20:39 +0100 Subject: [PATCH 11/11] prefetch-npm-deps: normalize packuments using whitelist approach Switch from stripping known volatile fields to using an explicit whitelist of allowed fields. This is more robust against upstream changes that add new fields which could affect hash stability. Top-level: only name and versions (dist-tags and time not needed for lockfile installs where versions are already resolved) Version-level: identity, all dependency types, dist, bin, platform constraints (engines/os/cpu), scripts, and deprecated flag. Based on analysis of pacote, npm-pick-manifest, npm-install-checks, and arborist - only fields actually read during npm install are included. --- .../node/prefetch-npm-deps/src/main.rs | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs index 7666474f9fe1..53cc075bfdc0 100644 --- a/pkgs/build-support/node/prefetch-npm-deps/src/main.rs +++ b/pkgs/build-support/node/prefetch-npm-deps/src/main.rs @@ -33,8 +33,42 @@ fn get_packument_url(registry: &str, package_name: &str) -> anyhow::Result /// Normalize packument data to ensure determinism. /// -/// Strips volatile fields like `_rev`, `time`, and `modified`. -/// Filters the `versions` map to only include versions requested in the lockfile. +/// Filters to whitelisted fields and requested versions only. +/// Allowed top-level fields in normalized packuments. +/// +/// For lockfile-based installs, versions are exact (e.g., "4.17.21") so npm-pick-manifest +/// just does a direct `versions[ver]` lookup. Tarballs are fetched via the resolved URL. +const ALLOWED_TOP_LEVEL_FIELDS: &[&str] = &["name", "versions"]; + +/// Allowed fields in version objects. +/// +/// Based on analysis of pacote, npm-pick-manifest, npm-install-checks, and arborist. +/// Only fields actually read during `npm install` are included. +const ALLOWED_VERSION_FIELDS: &[&str] = &[ + "name", + "version", + // Dependencies + "dependencies", + "devDependencies", + "peerDependencies", + "peerDependenciesMeta", + "optionalDependencies", + "bundleDependencies", + "bundledDependencies", + // Distribution (tarball URL and integrity) + "dist", + // Executables + "bin", + // Platform constraints (npm-install-checks) + "engines", + "os", + "cpu", + // Lifecycle scripts + "scripts", + // Version selection hint (npm-pick-manifest) + "deprecated", +]; + fn normalize_packument( package_name: &str, data: &[u8], @@ -47,37 +81,22 @@ fn normalize_packument( .as_object_mut() .ok_or_else(|| anyhow!("packument for {package_name} is not a JSON object"))?; - // Strip volatile top-level fields - obj.remove("_rev"); - obj.remove("time"); - obj.remove("modified"); + // Keep only whitelisted top-level fields to ensure determinism + obj.retain(|key, _| ALLOWED_TOP_LEVEL_FIELDS.contains(&key.as_str())); - // Filter versions to only those in lockfile + // Filter and normalize versions if let Some(Value::Object(versions)) = obj.get_mut("versions") { + // Only keep versions that are in the lockfile versions.retain(|version, _| requested_versions.contains(version)); - // Normalize each version object + // Normalize each version object to only include necessary fields for version_val in versions.values_mut() { if let Some(version_obj) = version_val.as_object_mut() { - // Strip fields starting with underscore (volatile/internal) - version_obj.retain(|key, _| !key.starts_with('_')); - // Strip other often-volatile fields - version_obj.remove("gitHead"); + version_obj.retain(|key, _| ALLOWED_VERSION_FIELDS.contains(&key.as_str())); } } } - // Filter dist-tags to only point to versions we kept - if let Some(Value::Object(tags)) = obj.get_mut("dist-tags") { - tags.retain(|_, version_val| { - if let Some(version) = version_val.as_str() { - requested_versions.contains(version) - } else { - false - } - }); - } - serde_json::to_vec(&json) .map_err(|e| anyhow!("failed to re-serialize packument for {package_name}: {e}")) }