aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src/packagist.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
committernsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
commit8cc1ba8a02c0318b65658f1634de378c780392b9 (patch)
treefdd5cb61e488018891a486b25991b87c84220bb8 /crates/mozart-registry/src/packagist.rs
parent72b2e877c01e67ba7edd37e34ac2eadb7a1c62c4 (diff)
downloadphp-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.gz
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.zst
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.zip
refactor(workspace): consolidate crates into mozart-core
Merged mozart-archiver, mozart-autoload, mozart-registry, mozart-sat-resolver, and mozart-vcs into mozart-core to align the source layout with Composer's structure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src/packagist.rs')
-rw-r--r--crates/mozart-registry/src/packagist.rs1011
1 files changed, 0 insertions, 1011 deletions
diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs
deleted file mode 100644
index 5c99b07..0000000
--- a/crates/mozart-registry/src/packagist.rs
+++ /dev/null
@@ -1,1011 +0,0 @@
-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")]
- pub dist_type: String,
- pub url: String,
- pub reference: Option<String>,
- pub shasum: Option<String>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct PackagistSource {
- #[serde(rename = "type")]
- pub source_type: String,
- pub url: String,
- pub reference: Option<String>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct PackagistVersion {
- pub version: String,
- pub version_normalized: String,
- #[serde(default, deserialize_with = "deserialize_unset_as_default")]
- pub require: BTreeMap<String, String>,
- #[serde(default, deserialize_with = "deserialize_unset_as_default")]
- pub replace: BTreeMap<String, String>,
- #[serde(default, deserialize_with = "deserialize_unset_as_default")]
- pub provide: BTreeMap<String, String>,
- #[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,
- deserialize_with = "deserialize_unset_as_default"
- )]
- pub require_dev: BTreeMap<String, String>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub suggest: Option<BTreeMap<String, String>>,
-
- #[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",
- 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",
- default,
- deserialize_with = "deserialize_unset_as_none"
- )]
- pub notification_url: Option<String>,
-
- /// `default-branch: true` marks the repository's default branch (e.g. the
- /// branch returned by `git symbolic-ref HEAD`). For packages without a
- /// numeric version prefix this triggers the synthetic `9999999-dev` alias
- /// generation in `ArrayLoader::getBranchAlias` — see the alias loop in
- /// `crate::resolver::packagist_to_pool_inputs`.
- #[serde(rename = "default-branch", default)]
- pub default_branch: bool,
-
- /// Abandonment marker. Composer accepts `abandoned: true` (no replacement
- /// suggested) or `abandoned: "<replacement-package>"`. Anything else
- /// (absent, `false`, empty string) means the package is active. Mirrors
- /// `Composer\Package\CompletePackage::isAbandoned`.
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub abandoned: Option<serde_json::Value>,
-}
-
-impl PackagistVersion {
- /// Extract the `extra.branch-alias` map from this version's metadata.
- ///
- /// Composer packages can declare branch aliases in `extra.branch-alias`:
- /// ```json
- /// {
- /// "extra": {
- /// "branch-alias": {
- /// "dev-master": "2.x-dev"
- /// }
- /// }
- /// }
- /// ```
- ///
- /// Returns a map from branch name (e.g. `"dev-master"`) to alias target
- /// (e.g. `"2.x-dev"`). Returns an empty map when no aliases are declared.
- pub fn branch_aliases(&self) -> BTreeMap<String, String> {
- let Some(extra) = &self.extra else {
- return BTreeMap::new();
- };
-
- let Some(branch_alias) = extra.get("branch-alias") else {
- return BTreeMap::new();
- };
-
- let Some(map) = branch_alias.as_object() else {
- return BTreeMap::new();
- };
-
- map.iter()
- .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
- .collect()
- }
-}
-
-/// Parse a Packagist p2 API JSON response.
-///
-/// 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>> {
- let raw: serde_json::Value = serde_json::from_str(json)?;
-
- // 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()
- .map(|v| serde_json::from_value(v).map_err(Into::into))
- .collect()
-}
-
-/// Fetch package version metadata from the Packagist p2 API.
-///
-/// The JSON response is cached on disk under the key
-/// `"provider-{vendor}~{package}.json"`. Subsequent calls for the same
-/// package are served from cache without a network request (unless the
-/// cache is disabled).
-#[tracing::instrument(skip(repo_cache))]
-pub async fn fetch_package_versions(
- package_name: &str,
- repo_cache: &Cache,
-) -> anyhow::Result<Vec<PackagistVersion>> {
- // Build cache key: replace `/` with `~` per cache key convention
- let cache_key = format!("provider-{}.json", package_name.replace('/', "~"));
-
- // Check cache first
- if let Some(cached) = repo_cache.read(&cache_key) {
- tracing::debug!("cache hit");
- return parse_p2_response(&cached, package_name);
- }
-
- // Cache miss — fetch from Packagist
- let url = format!("https://repo.packagist.org/p2/{package_name}.json");
- tracing::debug!(%url, "fetching package metadata");
- let client = mozart_core::http::client_builder().build()?;
- let response = client.get(&url).send().await?;
- tracing::debug!(status = %response.status(), "received response");
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Failed to fetch package \"{package_name}\" from Packagist (HTTP {})",
- response.status()
- );
- }
-
- let body = response.text().await?;
-
- // Write to cache
- let _ = repo_cache.write(&cache_key, &body);
-
- parse_p2_response(&body, package_name)
-}
-
-/// A single search result from the Packagist search API.
-#[derive(Debug, Deserialize, Serialize, Clone)]
-pub struct SearchResult {
- pub name: String,
- pub description: String,
- pub url: String,
- pub repository: Option<String>,
- pub downloads: u64,
- pub favers: u64,
- /// Abandonment status: absent/false means active, a string indicates the
- /// replacement package name, `true` means abandoned with no replacement.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub abandoned: Option<serde_json::Value>,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct SearchResponse {
- pub results: Vec<SearchResult>,
- pub total: u64,
- pub next: Option<String>,
-}
-
-/// Maximum number of pages to fetch from the Packagist search API.
-const SEARCH_MAX_PAGES: usize = 20;
-
-/// Percent-encode a string for use in a URL query parameter value.
-fn url_encode(s: &str) -> String {
- let mut encoded = String::with_capacity(s.len());
- for byte in s.bytes() {
- match byte {
- b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
- encoded.push(byte as char);
- }
- b' ' => encoded.push_str("%20"),
- other => {
- encoded.push_str(&format!("%{other:02X}"));
- }
- }
- }
- encoded
-}
-
-/// Search Packagist for packages matching `query`.
-///
-/// Fetches up to `SEARCH_MAX_PAGES` pages of results and returns the full list.
-/// An optional `package_type` filter can narrow results (e.g. `"library"`).
-#[tracing::instrument(fields(type_filter = package_type))]
-pub async fn search_packages(
- query: &str,
- package_type: Option<&str>,
-) -> anyhow::Result<(Vec<SearchResult>, u64)> {
- let client = mozart_core::http::client_builder().build()?;
-
- let mut all_results: Vec<SearchResult> = Vec::new();
- let mut page = 1usize;
- let mut next_url: Option<String> = None;
- let mut total: u64 = 0;
-
- loop {
- let response: SearchResponse = if let Some(ref url) = next_url {
- tracing::debug!(%url, page, "fetching next page");
- let resp = client.get(url).send().await?;
- tracing::debug!(status = %resp.status(), "received response");
- if !resp.status().is_success() {
- anyhow::bail!("Packagist search request failed (HTTP {})", resp.status());
- }
- resp.json().await?
- } else {
- let encoded_query = url_encode(query);
- let mut url = format!("https://packagist.org/search.json?q={encoded_query}");
- if let Some(t) = package_type {
- url.push_str("&type=");
- url.push_str(&url_encode(t));
- }
-
- tracing::debug!(%url, "fetching search results");
- let resp = client.get(&url).send().await?;
- tracing::debug!(status = %resp.status(), "received response");
- if !resp.status().is_success() {
- anyhow::bail!("Packagist search request failed (HTTP {})", resp.status());
- }
- resp.json().await?
- };
-
- if page == 1 {
- total = response.total;
- }
-
- all_results.extend(response.results);
- next_url = response.next;
- page += 1;
-
- if next_url.is_none() || page > SEARCH_MAX_PAGES {
- break;
- }
- }
-
- Ok((all_results, total))
-}
-
-/// Response shape of `https://packagist.org/packages/list.json[?type=...]`.
-#[derive(Debug, Deserialize)]
-struct ListResponse {
- #[serde(rename = "packageNames")]
- package_names: Vec<String>,
-}
-
-/// Fetch the full list of Packagist package names, optionally filtered by type.
-///
-/// Backs Composer's `ComposerRepository::getPackageNames()` for the
-/// `SEARCH_NAME` and `SEARCH_VENDOR` search modes. Cached on disk under
-/// `list-packages~{type}.json` (or `list-packages~all.json` when no type
-/// filter is given).
-#[tracing::instrument(skip(repo_cache))]
-pub async fn fetch_package_names(
- package_type: Option<&str>,
- repo_cache: &Cache,
-) -> anyhow::Result<Vec<String>> {
- let cache_key = match package_type {
- Some(t) => format!("list-packages~{t}.json"),
- None => "list-packages~all.json".to_string(),
- };
-
- if let Some(cached) = repo_cache.read(&cache_key) {
- tracing::debug!("cache hit");
- let parsed: ListResponse = serde_json::from_str(&cached)?;
- return Ok(parsed.package_names);
- }
-
- let mut url = "https://packagist.org/packages/list.json".to_string();
- if let Some(t) = package_type {
- url.push_str("?type=");
- url.push_str(&url_encode(t));
- }
- tracing::debug!(%url, "fetching package list");
- let client = mozart_core::http::client_builder().build()?;
- let response = client.get(&url).send().await?;
- tracing::debug!(status = %response.status(), "received response");
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Failed to fetch package list from Packagist (HTTP {})",
- response.status()
- );
- }
-
- let body = response.text().await?;
- let _ = repo_cache.write(&cache_key, &body);
-
- let parsed: ListResponse = serde_json::from_str(&body)?;
- Ok(parsed.package_names)
-}
-
-/// Fetch the deduplicated list of Packagist vendor names.
-///
-/// Mirrors Composer's `ComposerRepository::getVendorNames()` which derives
-/// vendors from `getPackageNames()` (regardless of type) by stripping the
-/// `/...` suffix and de-duplicating in insertion order.
-#[tracing::instrument(skip(repo_cache))]
-pub async fn fetch_vendor_names(repo_cache: &Cache) -> anyhow::Result<Vec<String>> {
- let names = fetch_package_names(None, repo_cache).await?;
- let mut seen: indexmap::IndexSet<String> = indexmap::IndexSet::new();
- for name in names {
- let vendor = match name.split_once('/') {
- Some((v, _)) => v.to_string(),
- None => name,
- };
- seen.insert(vendor);
- }
- Ok(seen.into_iter().collect())
-}
-
-/// A single security advisory from the Packagist API.
-#[derive(Debug, Clone, Deserialize, Serialize)]
-pub struct SecurityAdvisory {
- #[serde(rename = "advisoryId")]
- pub advisory_id: String,
-
- #[serde(rename = "packageName")]
- pub package_name: String,
-
- #[serde(rename = "remoteId")]
- pub remote_id: String,
-
- pub title: String,
-
- pub link: Option<String>,
-
- pub cve: Option<String>,
-
- /// Composer version constraint string, e.g. ">=1.0,<1.5.1|>=2.0,<2.3"
- #[serde(rename = "affectedVersions")]
- pub affected_versions: String,
-
- pub source: String,
-
- #[serde(rename = "reportedAt")]
- pub reported_at: String,
-
- #[serde(rename = "composerRepository")]
- pub composer_repository: Option<String>,
-
- pub severity: Option<String>,
-
- #[serde(default)]
- pub sources: Vec<AdvisorySource>,
-}
-
-/// A source entry within a security advisory.
-#[derive(Debug, Clone, Deserialize, Serialize)]
-pub struct AdvisorySource {
- pub name: String,
- #[serde(rename = "remoteId")]
- pub remote_id: String,
-}
-
-/// Response from POST `https://packagist.org/api/security-advisories/`.
-#[derive(Debug, Deserialize)]
-pub struct SecurityAdvisoriesResponse {
- pub advisories: BTreeMap<String, Vec<SecurityAdvisory>>,
-}
-
-/// Fetch security advisories for the given package names from the Packagist API.
-///
-/// Sends a POST request to `https://packagist.org/api/security-advisories/`
-/// with form-encoded package names. Returns advisories grouped by package name.
-///
-/// If the package list is very large (500+), requests are batched in chunks of
-/// 500 names per request and the results are merged.
-#[tracing::instrument(skip(package_names), fields(package_count = package_names.len()))]
-pub async fn fetch_security_advisories(
- package_names: &[&str],
-) -> anyhow::Result<BTreeMap<String, Vec<SecurityAdvisory>>> {
- let client = mozart_core::http::client_builder().build()?;
-
- let mut all_advisories: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new();
-
- for chunk in package_names.chunks(500) {
- // Build an application/x-www-form-urlencoded body manually.
- // Each package is encoded as `packages[]=<name>` and joined with `&`.
- let body: String = chunk
- .iter()
- .map(|name| format!("packages[]={}", url_encode(name)))
- .collect::<Vec<_>>()
- .join("&");
-
- tracing::debug!(chunk_size = chunk.len(), "fetching security advisories");
- let response = client
- .post("https://packagist.org/api/security-advisories/")
- .header("Content-Type", "application/x-www-form-urlencoded")
- .body(body)
- .send()
- .await?;
- tracing::debug!(status = %response.status(), "received response");
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Packagist security advisories request failed (HTTP {})",
- response.status()
- );
- }
-
- let parsed: SecurityAdvisoriesResponse = response.json().await?;
-
- for (pkg_name, advisories) in parsed.advisories {
- if !advisories.is_empty() {
- all_advisories
- .entry(pkg_name)
- .or_default()
- .extend(advisories);
- }
- }
- }
-
- Ok(all_advisories)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn parse_p2_response_basic() {
- let json = r#"{
- "packages": {
- "monolog/monolog": [
- {
- "version": "3.8.0",
- "version_normalized": "3.8.0.0",
- "require": {"php": ">=8.1"},
- "dist": {
- "type": "zip",
- "url": "https://example.com/monolog-3.8.0.zip",
- "reference": "abc123",
- "shasum": ""
- },
- "source": {
- "type": "git",
- "url": "https://github.com/Seldaek/monolog.git",
- "reference": "abc123"
- }
- },
- {
- "version": "3.7.0",
- "version_normalized": "3.7.0.0",
- "require": {"php": ">=8.1"}
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "monolog/monolog").unwrap();
- assert_eq!(versions.len(), 2);
- assert_eq!(versions[0].version, "3.8.0");
- assert_eq!(versions[0].version_normalized, "3.8.0.0");
- assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1");
- assert!(versions[0].dist.is_some());
- assert!(versions[0].source.is_some());
- assert_eq!(versions[1].version, "3.7.0");
- assert!(versions[1].dist.is_none());
- }
-
- #[test]
- fn parse_p2_response_not_found() {
- let json = r#"{"packages": {"other/pkg": []}}"#;
- let result = parse_p2_response(json, "monolog/monolog");
- assert!(result.is_err());
- }
-
- #[test]
- fn parse_p2_response_with_dev_version() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {}
- },
- {
- "version": "1.0.0",
- "version_normalized": "1.0.0.0",
- "require": {}
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- assert_eq!(versions.len(), 2);
- assert_eq!(versions[0].version, "dev-master");
- assert_eq!(versions[1].version, "1.0.0");
- }
-
- #[test]
- fn test_branch_aliases_present() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {},
- "extra": {
- "branch-alias": {
- "dev-master": "2.x-dev"
- }
- }
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert_eq!(aliases.len(), 1);
- assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev");
- }
-
- #[test]
- fn test_branch_aliases_multiple() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {},
- "extra": {
- "branch-alias": {
- "dev-master": "2.x-dev",
- "dev-1.x": "1.5.x-dev"
- }
- }
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert_eq!(aliases.len(), 2);
- assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev");
- assert_eq!(aliases.get("dev-1.x").unwrap(), "1.5.x-dev");
- }
-
- #[test]
- fn test_branch_aliases_no_extra() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {}
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert!(aliases.is_empty());
- }
-
- #[test]
- fn test_branch_aliases_extra_without_branch_alias_key() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {},
- "extra": {
- "installer-name": "my-plugin"
- }
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert!(aliases.is_empty());
- }
-
- #[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());
- }
-
- #[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());
- }
-
- #[test]
- fn test_parse_security_advisories_response() {
- let json = r#"{
- "advisories": {
- "monolog/monolog": [
- {
- "advisoryId": "PKSA-b2m0-qqf7-qck4",
- "packageName": "monolog/monolog",
- "remoteId": "monolog/monolog/2017-11-13-1.yaml",
- "title": "Header injection in NativeMailerHandler",
- "link": "https://github.com/Seldaek/monolog/pull/683",
- "cve": null,
- "affectedVersions": ">=1.8.0,<1.12.0",
- "source": "FriendsOfPHP/security-advisories",
- "reportedAt": "2017-11-13T00:00:00+00:00",
- "composerRepository": "https://packagist.org",
- "severity": "low",
- "sources": [
- {
- "name": "FriendsOfPHP/security-advisories",
- "remoteId": "monolog/monolog/2017-11-13-1.yaml"
- }
- ]
- }
- ]
- }
- }"#;
-
- let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
- assert_eq!(response.advisories.len(), 1);
- let advisories = response.advisories.get("monolog/monolog").unwrap();
- assert_eq!(advisories.len(), 1);
- let adv = &advisories[0];
- assert_eq!(adv.advisory_id, "PKSA-b2m0-qqf7-qck4");
- assert_eq!(adv.package_name, "monolog/monolog");
- assert_eq!(adv.title, "Header injection in NativeMailerHandler");
- assert_eq!(adv.affected_versions, ">=1.8.0,<1.12.0");
- assert_eq!(adv.severity.as_deref(), Some("low"));
- assert!(adv.cve.is_none());
- assert_eq!(adv.sources.len(), 1);
- assert_eq!(adv.sources[0].name, "FriendsOfPHP/security-advisories");
- }
-
- #[test]
- fn test_parse_security_advisories_empty() {
- let json = r#"{"advisories": {"other/package": []}}"#;
- let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
- assert_eq!(response.advisories.len(), 1);
- let advisories = response.advisories.get("other/package").unwrap();
- assert!(advisories.is_empty());
- }
-
- #[test]
- fn test_parse_security_advisories_null_fields() {
- let json = r#"{
- "advisories": {
- "vendor/pkg": [
- {
- "advisoryId": "PKSA-0000-0000-0000",
- "packageName": "vendor/pkg",
- "remoteId": "vendor/pkg/2024-01-01.yaml",
- "title": "Some vulnerability",
- "link": null,
- "cve": null,
- "affectedVersions": ">=1.0,<2.0",
- "source": "FriendsOfPHP/security-advisories",
- "reportedAt": "2024-01-01T00:00:00+00:00",
- "composerRepository": null,
- "severity": null,
- "sources": []
- }
- ]
- }
- }"#;
-
- let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
- let advisories = response.advisories.get("vendor/pkg").unwrap();
- assert_eq!(advisories.len(), 1);
- let adv = &advisories[0];
- assert!(adv.link.is_none());
- assert!(adv.cve.is_none());
- assert!(adv.severity.is_none());
- assert!(adv.composer_repository.is_none());
- assert!(adv.sources.is_empty());
- }
-}