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.
This commit is contained in:
Zoltan Kochan
2026-05-21 21:13:06 +02:00
committed by GitHub
parent fd564a43b9
commit 9eb632bfbd
2 changed files with 123 additions and 0 deletions

View File

@@ -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<string, string>` 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

View File

@@ -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<HashMap<String, String>>,
#[serde(
default,
deserialize_with = "deserialize_dependency_map",
skip_serializing_if = "Option::is_none"
)]
pub dev_dependencies: Option<HashMap<String, String>>,
#[serde(
default,
deserialize_with = "deserialize_dependency_map",
skip_serializing_if = "Option::is_none"
)]
pub peer_dependencies: Option<HashMap<String, String>>,
/// npm registry's per-version publisher metadata. When
@@ -61,6 +76,56 @@ pub struct PackageVersion {
pub deprecated: Option<String>,
}
/// Deserialize a `Record<string, string>`-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<Option<HashMap<String, String>>, 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<HashMap<String, String>>;
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<Err: de::Error>(self) -> Result<Self::Value, Err> {
Ok(None)
}
fn visit_unit<Err: de::Error>(self) -> Result<Self::Value, Err> {
Ok(None)
}
fn visit_some<Nested: serde::Deserializer<'de>>(
self,
deserializer: Nested,
) -> Result<Self::Value, Nested::Error> {
deserializer.deserialize_any(DependencyMapVisitor)
}
fn visit_map<Map: MapAccess<'de>>(self, mut map: Map) -> Result<Self::Value, Map::Error> {
let mut out = HashMap::new();
while let Some(key) = map.next_key::<String>()? {
let value = map.next_value::<serde_json::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