From 9eb632bfbd5e074ba9350b023e65b7d2d8d34e71 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 21 May 2026 21:13:06 +0200 Subject: [PATCH] fix(registry): tolerate non-string entries in dependency maps (#11833) Historical npm registry entries sometimes carry object-valued `dependencies` / `devDependencies` / `peerDependencies` (e.g. `deep-diff@0.1.0`'s nested `devDependencies: { vows: { version, ... } }`). pnpm's JS path silently ignores these via later `typeof spec === 'string'` checks; pacquet's strict serde failed the whole packument with `invalid type: map, expected a string`, surfacing as a spurious `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` for an unrelated version pinned in the lockfile. Custom deserializer drops non-string entries while keeping the string ones, matching pnpm's tolerance. Issue #11829. --- pacquet/crates/registry/src/package/tests.rs | 58 +++++++++++++++++ .../crates/registry/src/package_version.rs | 65 +++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/pacquet/crates/registry/src/package/tests.rs b/pacquet/crates/registry/src/package/tests.rs index 42fc7d9ec9..5422d4442d 100644 --- a/pacquet/crates/registry/src/package/tests.rs +++ b/pacquet/crates/registry/src/package/tests.rs @@ -349,6 +349,64 @@ fn package_deserializes_without_time_field() { assert!(pkg.published_at("1.0.0").is_none(), "no per-version lookup possible"); } +/// Some historical npm packages (e.g. `deep-diff@0.1.0`) ship +/// `devDependencies` whose values are objects rather than the +/// declared `Record` shape. The full packument is +/// fetched by the verifier and includes every version's per-version +/// manifest, so a single malformed historical entry would otherwise +/// fail the whole deserialization and surface as +/// `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` for an unrelated +/// version pinned in the lockfile (issue #11829). Non-string entries +/// must be silently dropped, matching pnpm's JS-side tolerance. +#[test] +fn package_tolerates_object_valued_dependency_entries() { + let body = r#"{ + "name": "deep-diff", + "dist-tags": { "latest": "0.3.8" }, + "versions": { + "0.1.0": { + "name": "deep-diff", + "version": "0.1.0", + "devDependencies": { + "vows": { + "version": "0.6.4", + "dependencies": { + "diff": { "version": "1.0.4" }, + "eyes": { "version": "0.1.8" } + } + }, + "should": "1.2.1" + }, + "dist": { + "shasum": "0000000000000000000000000000000000000000", + "tarball": "https://registry/deep-diff-0.1.0.tgz" + } + }, + "0.3.8": { + "name": "deep-diff", + "version": "0.3.8", + "dependencies": {}, + "devDependencies": { "mocha": "^2.0.0" }, + "dist": { + "shasum": "1111111111111111111111111111111111111111", + "tarball": "https://registry/deep-diff-0.3.8.tgz" + } + } + } + }"#; + let pkg: Package = serde_json::from_str(body) + .expect("deserialize packument with object-valued devDependencies entries"); + + let old = pkg.versions.get("0.1.0").expect("0.1.0 deserialized"); + let old_dev = old.dev_dependencies.as_ref().expect("devDependencies present"); + assert_eq!(old_dev.get("should").map(String::as_str), Some("1.2.1")); + assert!(!old_dev.contains_key("vows"), "object-valued entries are dropped"); + + let current = pkg.versions.get("0.3.8").expect("0.3.8 deserialized"); + let current_dev = current.dev_dependencies.as_ref().expect("devDependencies present"); + assert_eq!(current_dev.get("mocha").map(String::as_str), Some("^2.0.0")); +} + /// The reserved `time.unpublished` key carries an object value /// (not a string). [`Package::published_at`] must ignore it /// instead of returning the object's serialized form. Mirrors the diff --git a/pacquet/crates/registry/src/package_version.rs b/pacquet/crates/registry/src/package_version.rs index 3d199419ea..01a512f79c 100644 --- a/pacquet/crates/registry/src/package_version.rs +++ b/pacquet/crates/registry/src/package_version.rs @@ -12,8 +12,23 @@ pub struct PackageVersion { pub name: String, pub version: node_semver::Version, pub dist: PackageDistribution, + #[serde( + default, + deserialize_with = "deserialize_dependency_map", + skip_serializing_if = "Option::is_none" + )] pub dependencies: Option>, + #[serde( + default, + deserialize_with = "deserialize_dependency_map", + skip_serializing_if = "Option::is_none" + )] pub dev_dependencies: Option>, + #[serde( + default, + deserialize_with = "deserialize_dependency_map", + skip_serializing_if = "Option::is_none" + )] pub peer_dependencies: Option>, /// npm registry's per-version publisher metadata. When @@ -61,6 +76,56 @@ pub struct PackageVersion { pub deprecated: Option, } +/// Deserialize a `Record`-shaped dependency map while +/// tolerating historical npm registry entries whose values are objects +/// or other non-string shapes. Non-string entries are silently dropped, +/// matching pnpm's JavaScript path which never validates the value +/// shape and relies on later `typeof spec === 'string'` checks to +/// ignore the bad rows (e.g. `deep-diff@0.1.0`'s nested +/// `devDependencies`). Missing field and JSON `null` both decode to +/// `None`; a present map (even one whose entries are all dropped) +/// decodes to `Some`. +fn deserialize_dependency_map<'de, Deser>( + deserializer: Deser, +) -> Result>, Deser::Error> +where + Deser: serde::Deserializer<'de>, +{ + use serde::de::{self, MapAccess, Visitor}; + use std::fmt; + + struct DependencyMapVisitor; + impl<'de> Visitor<'de> for DependencyMapVisitor { + type Value = Option>; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a map of dependency name to version-spec string, or null") + } + fn visit_none(self) -> Result { + Ok(None) + } + fn visit_unit(self) -> Result { + Ok(None) + } + fn visit_some>( + self, + deserializer: Nested, + ) -> Result { + deserializer.deserialize_any(DependencyMapVisitor) + } + fn visit_map>(self, mut map: Map) -> Result { + let mut out = HashMap::new(); + while let Some(key) = map.next_key::()? { + let value = map.next_value::()?; + if let serde_json::Value::String(spec) = value { + out.insert(key, spec); + } + } + Ok(Some(out)) + } + } + deserializer.deserialize_any(DependencyMapVisitor) +} + /// Accept either a string or a boolean for the `deprecated` field. /// A bool `true` becomes `Some("")`, a bool `false` becomes `None`; /// a string stays as `Some(s)`. Missing field defaults to `None` via