diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-22 12:05:17 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-22 12:05:17 +0900 |
| commit | 92fdff257d2c64f94600ba70bf17e429d46474b2 (patch) | |
| tree | d5262aaa71f54f4445e88459468d64262c0a4dee /crates/mozart-registry/src/packagist.rs | |
| parent | 8071553d6c706cffda6fdb4391b561329e949014 (diff) | |
| download | php-mozart-92fdff257d2c64f94600ba70bf17e429d46474b2.tar.gz php-mozart-92fdff257d2c64f94600ba70bf17e429d46474b2.tar.zst php-mozart-92fdff257d2c64f94600ba70bf17e429d46474b2.zip | |
feat(metadata-minifier): add mozart-metadata-minifier crate
Port composer/metadata-minifier to Rust as an independent workspace
crate. Implements expand() and minify() for Packagist's delta-encoded
version metadata. Update mozart-registry to use the new crate for
transparent minified response handling, and add __unset sentinel
support to PackagistVersion deserialization.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src/packagist.rs')
| -rw-r--r-- | crates/mozart-registry/src/packagist.rs | 324 |
1 files changed, 304 insertions, 20 deletions
diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs index 95d02ad..e851955 100644 --- a/crates/mozart-registry/src/packagist.rs +++ b/crates/mozart-registry/src/packagist.rs @@ -1,7 +1,38 @@ use crate::cache::Cache; +use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +/// Deserialize a field that may contain the Packagist minifier sentinel `"__unset"`. +/// +/// Packagist's metadata minifier (see `composer/metadata-minifier`) encodes +/// deleted fields as the literal string `"__unset"` in version diffs. When we +/// encounter this sentinel we treat the field as absent (`None` / default). +fn deserialize_unset_as_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error> +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned, +{ + let value = serde_json::Value::deserialize(deserializer)?; + if value.as_str() == Some("__unset") { + return Ok(None); + } + serde_json::from_value(value).map_err(serde::de::Error::custom) +} + +/// Like [`deserialize_unset_as_none`] but returns a default `T` instead of `Option`. +fn deserialize_unset_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error> +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned + Default, +{ + let value = serde_json::Value::deserialize(deserializer)?; + if value.as_str() == Some("__unset") { + return Ok(T::default()); + } + serde_json::from_value(value).map_err(serde::de::Error::custom) +} + #[derive(Debug, Clone, Deserialize)] pub struct PackagistDist { #[serde(rename = "type")] @@ -23,50 +54,62 @@ pub struct PackagistSource { pub struct PackagistVersion { pub version: String, pub version_normalized: String, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_unset_as_default")] pub require: BTreeMap<String, String>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_unset_as_default")] pub replace: BTreeMap<String, String>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_unset_as_default")] pub provide: BTreeMap<String, String>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_unset_as_default")] pub conflict: BTreeMap<String, String>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub dist: Option<PackagistDist>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub source: Option<PackagistSource>, - #[serde(rename = "require-dev", default)] + #[serde(rename = "require-dev", default, deserialize_with = "deserialize_unset_as_default")] pub require_dev: BTreeMap<String, String>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub suggest: Option<BTreeMap<String, String>>, - #[serde(rename = "type")] + #[serde(rename = "type", default, deserialize_with = "deserialize_unset_as_none")] pub package_type: Option<String>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub autoload: Option<serde_json::Value>, - #[serde(rename = "autoload-dev")] + #[serde(rename = "autoload-dev", default, deserialize_with = "deserialize_unset_as_none")] pub autoload_dev: Option<serde_json::Value>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub license: Option<Vec<String>>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub description: Option<String>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub homepage: Option<String>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub keywords: Option<Vec<String>>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub authors: Option<Vec<serde_json::Value>>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub support: Option<serde_json::Value>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub funding: Option<Vec<serde_json::Value>>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub time: Option<String>, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] pub extra: Option<serde_json::Value>, - #[serde(rename = "notification-url")] + #[serde(rename = "notification-url", default, deserialize_with = "deserialize_unset_as_none")] pub notification_url: Option<String>, } @@ -107,20 +150,48 @@ impl PackagistVersion { /// Parse a Packagist p2 API JSON response. /// -/// The response format is: `{"packages": {"vendor/package": [...]}}`. +/// The response format is: +/// ```json +/// { +/// "packages": {"vendor/package": [...]}, +/// "minified": "composer/2.0" // optional +/// } +/// ``` +/// +/// When the `"minified"` key is present the version list is delta-encoded by +/// Composer's `MetadataMinifier`. This function transparently expands the +/// minified data before deserializing into [`PackagistVersion`] structs. pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result<Vec<PackagistVersion>> { - #[derive(Deserialize)] - struct P2Response { - packages: BTreeMap<String, Vec<PackagistVersion>>, - } + let raw: serde_json::Value = serde_json::from_str(json)?; - let response: P2Response = serde_json::from_str(json)?; - response - .packages + // Check whether the response is minified. + let is_minified = raw + .get("minified") + .and_then(|v| v.as_str()) + .is_some_and(|s| s == "composer/2.0"); + + // Extract the version array for the requested package. + let versions_value = raw + .get("packages") + .and_then(|p| p.get(package_name)) + .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))?; + + let versions_array = versions_value + .as_array() + .ok_or_else(|| anyhow::anyhow!("Expected array for package \"{package_name}\""))?; + + // Expand minified diffs into full version objects if necessary. + let versions: Vec<serde_json::Value> = if is_minified { + mozart_metadata_minifier::expand(versions_array) + } else { + versions_array.clone() + }; + + // Deserialize the (possibly expanded) version objects. + versions .into_iter() - .find(|(key, _)| key == package_name) - .map(|(_, versions)| versions) - .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response")) + .map(|v| serde_json::from_value(v).map_err(Into::into)) + .collect() } /// Fetch package version metadata from the Packagist p2 API. @@ -540,6 +611,219 @@ mod tests { assert!(aliases.is_empty()); } + // ──────────── __unset sentinel handling ──────────────────────────────── + + #[test] + fn parse_p2_response_unset_fields() { + // Packagist metadata minifier uses "__unset" to mark deleted fields. + let json = r#"{ + "packages": { + "test/pkg": [ + { + "version": "2.0.0", + "version_normalized": "2.0.0.0", + "require": {"php": ">=8.1"}, + "license": ["MIT"], + "keywords": ["framework"], + "authors": [{"name": "Alice"}], + "funding": [{"type": "github", "url": "https://github.com/sponsors/alice"}] + }, + { + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "license": "__unset", + "keywords": "__unset", + "authors": "__unset", + "funding": "__unset", + "require": "__unset", + "homepage": "__unset", + "description": "__unset", + "extra": "__unset", + "suggest": "__unset" + } + ] + } + }"#; + + let versions = parse_p2_response(json, "test/pkg").unwrap(); + assert_eq!(versions.len(), 2); + + // First version has normal values + assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); + assert_eq!(versions[0].keywords.as_ref().unwrap(), &["framework"]); + + // Second version has __unset → treated as absent + assert!(versions[1].license.is_none()); + assert!(versions[1].keywords.is_none()); + assert!(versions[1].authors.is_none()); + assert!(versions[1].funding.is_none()); + assert!(versions[1].require.is_empty()); + assert!(versions[1].homepage.is_none()); + assert!(versions[1].description.is_none()); + assert!(versions[1].extra.is_none()); + assert!(versions[1].suggest.is_none()); + } + + // ──────────── minified metadata expansion ────────────────────────────── + + #[test] + fn parse_p2_response_minified_expand() { + // Mirrors the Composer MetadataMinifierTest: 3 versions where only + // the first carries all fields and subsequent entries are diffs. + let json = r#"{ + "packages": { + "foo/bar": [ + { + "name": "foo/bar", + "version": "2.0.0", + "version_normalized": "2.0.0.0", + "type": "library", + "license": ["MIT"], + "require": {"php": ">=8.1"}, + "description": "A great package" + }, + { + "version": "1.2.0", + "version_normalized": "1.2.0.0", + "license": ["GPL"], + "homepage": "https://example.org" + }, + { + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "homepage": "__unset" + } + ] + }, + "minified": "composer/2.0" + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert_eq!(versions.len(), 3); + + // Version 2.0.0 — full data (first entry). + assert_eq!(versions[0].version, "2.0.0"); + assert_eq!(versions[0].package_type.as_deref(), Some("library")); + assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); + assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1"); + assert_eq!(versions[0].description.as_deref(), Some("A great package")); + assert!(versions[0].homepage.is_none()); + + // Version 1.2.0 — inherits name, type, require, description from 2.0.0; + // license changed to GPL; homepage added. + assert_eq!(versions[1].version, "1.2.0"); + assert_eq!(versions[1].package_type.as_deref(), Some("library")); + assert_eq!(versions[1].license.as_ref().unwrap(), &["GPL"]); + assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1"); + assert_eq!(versions[1].description.as_deref(), Some("A great package")); + assert_eq!(versions[1].homepage.as_deref(), Some("https://example.org")); + + // Version 1.0.0 — inherits everything from 1.2.0 except homepage + // which is __unset (deleted). + assert_eq!(versions[2].version, "1.0.0"); + assert_eq!(versions[2].package_type.as_deref(), Some("library")); + assert_eq!(versions[2].license.as_ref().unwrap(), &["GPL"]); + assert_eq!(versions[2].require.get("php").unwrap(), ">=8.1"); + assert_eq!(versions[2].description.as_deref(), Some("A great package")); + assert!(versions[2].homepage.is_none()); + } + + #[test] + fn parse_p2_response_not_minified_no_inheritance() { + // Without "minified" key, each version stands alone — no inheritance. + let json = r#"{ + "packages": { + "foo/bar": [ + { + "version": "2.0.0", + "version_normalized": "2.0.0.0", + "license": ["MIT"], + "description": "A great package" + }, + { + "version": "1.0.0", + "version_normalized": "1.0.0.0" + } + ] + } + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert_eq!(versions.len(), 2); + + assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); + assert_eq!(versions[0].description.as_deref(), Some("A great package")); + + // Without minified flag, version 1.0.0 does NOT inherit from 2.0.0. + assert!(versions[1].license.is_none()); + assert!(versions[1].description.is_none()); + } + + #[test] + fn parse_p2_response_minified_single_version() { + // Edge case: minified response with only one version. + let json = r#"{ + "packages": { + "foo/bar": [ + { + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "license": ["MIT"] + } + ] + }, + "minified": "composer/2.0" + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); + } + + #[test] + fn parse_p2_response_minified_empty_versions() { + let json = r#"{ + "packages": { + "foo/bar": [] + }, + "minified": "composer/2.0" + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert!(versions.is_empty()); + } + + #[test] + fn parse_p2_response_minified_map_fields_inherited() { + // Verify BTreeMap fields (require, replace, etc.) are inherited. + let json = r#"{ + "packages": { + "foo/bar": [ + { + "version": "2.0.0", + "version_normalized": "2.0.0.0", + "require": {"php": ">=8.1", "ext-json": "*"}, + "replace": {"foo/old": "self.version"} + }, + { + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "replace": "__unset" + } + ] + }, + "minified": "composer/2.0" + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert_eq!(versions.len(), 2); + + // Version 1.0.0 inherits require from 2.0.0, replace is unset. + assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1"); + assert_eq!(versions[1].require.get("ext-json").unwrap(), "*"); + assert!(versions[1].replace.is_empty()); + } + // ──────────── SecurityAdvisory parsing tests ───────────────────────────── #[test] |
