mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-24 08:35:19 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user