aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src/repository/packagist.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart-core/src/repository/packagist.rs')
-rw-r--r--crates/mozart-core/src/repository/packagist.rs1011
1 files changed, 1011 insertions, 0 deletions
diff --git a/crates/mozart-core/src/repository/packagist.rs b/crates/mozart-core/src/repository/packagist.rs
new file mode 100644
index 0000000..199ff51
--- /dev/null
+++ b/crates/mozart-core/src/repository/packagist.rs
@@ -0,0 +1,1011 @@
+use super::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 = crate::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 = crate::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 = crate::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 = crate::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());
+ }
+}