diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-08 23:22:34 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-08 23:22:34 +0900 |
| commit | d770693bac655da4a21144b4cae7592536fecb8b (patch) | |
| tree | 5d29005db018416c03a14c9d367f412b8148650c /crates | |
| parent | eeb845f2f8629e3ccfb8ee1a1ec0602c0f186427 (diff) | |
| download | php-mozart-d770693bac655da4a21144b4cae7592536fecb8b.tar.gz php-mozart-d770693bac655da4a21144b4cae7592536fecb8b.tar.zst php-mozart-d770693bac655da4a21144b4cae7592536fecb8b.zip | |
fix(audit): align with Composer's AuditCommand pipeline
- Add mozart-core::advisory::{AuditFormat, AbandonedHandling, AuditConfig}
mirroring Composer\Advisory\AuditConfig; reads audit.ignore,
audit.ignore-severity, audit.ignore-abandoned, audit.abandoned,
audit.block-insecure, audit.block-abandoned, audit.ignore-unreachable
from composer.json config with full apply-scope support
- Add mozart-registry::advisory::Auditor mirroring Composer\Advisory\Auditor;
process_advisories() filters by package name, advisory ID, CVE, source
remote ID, and severity; filter_abandoned_packages() respects ignore-abandoned
- Add RepositorySet::get_matching_security_advisories() wrapping
fetch_security_advisories with version-matching and unreachable-repo tracking
- JSON output now includes ignored-advisories and unreachable-repositories keys
- --abandoned falls back to audit.abandoned config (was hardcoded to "fail")
- --ignore-severity merges with audit.ignore-severity config
- --ignore-unreachable ORs with audit.ignore-unreachable config
- Move normalize_or_separator into repository/mod.rs alongside version matching
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/mozart-core/src/advisory.rs | 315 | ||||
| -rw-r--r-- | crates/mozart-core/src/config_source.rs | 33 | ||||
| -rw-r--r-- | crates/mozart-core/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/advisory.rs | 739 | ||||
| -rw-r--r-- | crates/mozart-registry/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/mod.rs | 125 | ||||
| -rw-r--r-- | crates/mozart/src/commands.rs | 2 | ||||
| -rw-r--r-- | crates/mozart/src/commands/audit.rs | 892 | ||||
| -rw-r--r-- | crates/mozart/src/commands/base_config.rs | 6 | ||||
| -rw-r--r-- | crates/mozart/src/commands/repository.rs | 39 |
10 files changed, 1334 insertions, 819 deletions
diff --git a/crates/mozart-core/src/advisory.rs b/crates/mozart-core/src/advisory.rs new file mode 100644 index 0000000..4752c8b --- /dev/null +++ b/crates/mozart-core/src/advisory.rs @@ -0,0 +1,315 @@ +use indexmap::IndexMap; + +use crate::config::Config; + +pub const FORMAT_TABLE: &str = "table"; +pub const FORMAT_PLAIN: &str = "plain"; +pub const FORMAT_JSON: &str = "json"; +pub const FORMAT_SUMMARY: &str = "summary"; +pub const FORMATS: [&str; 4] = [FORMAT_TABLE, FORMAT_PLAIN, FORMAT_JSON, FORMAT_SUMMARY]; + +pub const ABANDONED_IGNORE: &str = "ignore"; +pub const ABANDONED_REPORT: &str = "report"; +pub const ABANDONED_FAIL: &str = "fail"; +pub const ABANDONEDS: [&str; 3] = [ABANDONED_IGNORE, ABANDONED_REPORT, ABANDONED_FAIL]; + +/// Mirrors `Auditor::FORMAT_*` constants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AuditFormat { + #[default] + Table, + Plain, + Json, + Summary, +} + +impl AuditFormat { + pub fn as_str(self) -> &'static str { + match self { + Self::Table => FORMAT_TABLE, + Self::Plain => FORMAT_PLAIN, + Self::Json => FORMAT_JSON, + Self::Summary => FORMAT_SUMMARY, + } + } + + pub fn from_str(s: &str) -> Option<Self> { + match s { + FORMAT_TABLE => Some(Self::Table), + FORMAT_PLAIN => Some(Self::Plain), + FORMAT_JSON => Some(Self::Json), + FORMAT_SUMMARY => Some(Self::Summary), + _ => None, + } + } +} + +/// Mirrors `Auditor::ABANDONED_*` constants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AbandonedHandling { + Ignore, + Report, + #[default] + Fail, +} + +impl AbandonedHandling { + pub fn as_str(self) -> &'static str { + match self { + Self::Ignore => ABANDONED_IGNORE, + Self::Report => ABANDONED_REPORT, + Self::Fail => ABANDONED_FAIL, + } + } + + pub fn from_str(s: &str) -> Option<Self> { + match s { + ABANDONED_IGNORE => Some(Self::Ignore), + ABANDONED_REPORT => Some(Self::Report), + ABANDONED_FAIL => Some(Self::Fail), + _ => None, + } + } +} + +/// Mirrors `Composer\Advisory\AuditConfig`. +#[derive(Debug, Clone)] +pub struct AuditConfig { + pub audit: bool, + pub audit_format: AuditFormat, + pub audit_abandoned: AbandonedHandling, + pub block_insecure: bool, + pub block_abandoned: bool, + pub ignore_unreachable: bool, + pub ignore_list_for_audit: IndexMap<String, Option<String>>, + pub ignore_list_for_blocking: IndexMap<String, Option<String>>, + pub ignore_severity_for_audit: IndexMap<String, Option<String>>, + pub ignore_severity_for_blocking: IndexMap<String, Option<String>>, + pub ignore_abandoned_for_audit: IndexMap<String, Option<String>>, + pub ignore_abandoned_for_blocking: IndexMap<String, Option<String>>, +} + +struct ParsedIgnore { + audit: IndexMap<String, Option<String>>, + block: IndexMap<String, Option<String>>, +} + +/// Mirrors `AuditConfig::parseIgnoreWithApply()`. +/// +/// Supports these JSON shapes: +/// - `["CVE-1"]` — simple list, apply=all, reason=null +/// - `{"CVE-1": "reason"}` — with reason, apply=all +/// - `{"CVE-1": null}` — null reason, apply=all +/// - `{"CVE-1": {"apply": "audit|block|all", "reason": "..."}}` — detailed +fn parse_ignore_with_apply(config: &serde_json::Value) -> ParsedIgnore { + let mut for_audit: IndexMap<String, Option<String>> = IndexMap::new(); + let mut for_block: IndexMap<String, Option<String>> = IndexMap::new(); + + match config { + serde_json::Value::Array(arr) => { + for item in arr { + if let Some(s) = item.as_str() { + for_audit.insert(s.to_string(), None); + for_block.insert(s.to_string(), None); + } + } + } + serde_json::Value::Object(obj) => { + for (key, value) in obj { + let (apply, reason) = match value { + serde_json::Value::String(r) => ("all", Some(r.clone())), + serde_json::Value::Null => ("all", None), + serde_json::Value::Object(detail) => { + let apply = detail + .get("apply") + .and_then(|v| v.as_str()) + .unwrap_or("all"); + if !matches!(apply, "audit" | "block" | "all") { + continue; + } + let reason = detail + .get("reason") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + (apply, reason) + } + _ => continue, + }; + + if apply == "audit" || apply == "all" { + for_audit.insert(key.clone(), reason.clone()); + } + if apply == "block" || apply == "all" { + for_block.insert(key.clone(), reason); + } + } + } + _ => {} + } + + ParsedIgnore { + audit: for_audit, + block: for_block, + } +} + +impl AuditConfig { + /// Mirrors `AuditConfig::fromConfig()`. + pub fn from_config(config: &Config, audit: bool, audit_format: AuditFormat) -> Self { + let empty_arr = serde_json::Value::Array(vec![]); + let audit_val = config + .get("audit") + .unwrap_or_else(|| serde_json::Value::Object(Default::default())); + + let ignore_list_val = audit_val + .get("ignore") + .cloned() + .unwrap_or_else(|| empty_arr.clone()); + let ignore_list_parsed = parse_ignore_with_apply(&ignore_list_val); + + let ignore_abandoned_val = audit_val + .get("ignore-abandoned") + .cloned() + .unwrap_or_else(|| empty_arr.clone()); + let ignore_abandoned_parsed = parse_ignore_with_apply(&ignore_abandoned_val); + + let ignore_severity_val = audit_val + .get("ignore-severity") + .cloned() + .unwrap_or_else(|| empty_arr.clone()); + let ignore_severity_parsed = parse_ignore_with_apply(&ignore_severity_val); + + let audit_abandoned = audit_val + .get("abandoned") + .and_then(|v| v.as_str()) + .and_then(AbandonedHandling::from_str) + .unwrap_or_default(); + + let block_insecure = audit_val + .get("block-insecure") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let block_abandoned = audit_val + .get("block-abandoned") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let ignore_unreachable = audit_val + .get("ignore-unreachable") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Self { + audit, + audit_format, + audit_abandoned, + block_insecure, + block_abandoned, + ignore_unreachable, + ignore_list_for_audit: ignore_list_parsed.audit, + ignore_list_for_blocking: ignore_list_parsed.block, + ignore_severity_for_audit: ignore_severity_parsed.audit, + ignore_severity_for_blocking: ignore_severity_parsed.block, + ignore_abandoned_for_audit: ignore_abandoned_parsed.audit, + ignore_abandoned_for_blocking: ignore_abandoned_parsed.block, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ignore_simple_array() { + let val = serde_json::json!(["CVE-2024-1234", "PKSA-0001"]); + let parsed = parse_ignore_with_apply(&val); + assert_eq!(parsed.audit.len(), 2); + assert_eq!(parsed.block.len(), 2); + assert_eq!(parsed.audit.get("CVE-2024-1234"), Some(&None)); + } + + #[test] + fn test_parse_ignore_object_with_reasons() { + let val = serde_json::json!({ + "CVE-2024-1234": "manually patched", + "PKSA-0001": null + }); + let parsed = parse_ignore_with_apply(&val); + assert_eq!( + parsed.audit.get("CVE-2024-1234"), + Some(&Some("manually patched".to_string())) + ); + assert_eq!(parsed.audit.get("PKSA-0001"), Some(&None)); + } + + #[test] + fn test_parse_ignore_detailed_apply_audit_only() { + let val = serde_json::json!({ + "CVE-2024-1234": { "apply": "audit", "reason": "test" } + }); + let parsed = parse_ignore_with_apply(&val); + assert_eq!(parsed.audit.len(), 1); + assert_eq!(parsed.block.len(), 0); + } + + #[test] + fn test_parse_ignore_detailed_apply_block_only() { + let val = serde_json::json!({ + "CVE-2024-1234": { "apply": "block", "reason": "test" } + }); + let parsed = parse_ignore_with_apply(&val); + assert_eq!(parsed.audit.len(), 0); + assert_eq!(parsed.block.len(), 1); + } + + #[test] + fn test_parse_ignore_detailed_apply_all() { + let val = serde_json::json!({ + "CVE-2024-1234": { "apply": "all", "reason": "test" } + }); + let parsed = parse_ignore_with_apply(&val); + assert_eq!(parsed.audit.len(), 1); + assert_eq!(parsed.block.len(), 1); + } + + #[test] + fn test_audit_config_defaults() { + let config = Config::default(); + let audit_config = AuditConfig::from_config(&config, true, AuditFormat::Table); + assert!(audit_config.audit); + assert_eq!(audit_config.audit_format, AuditFormat::Table); + assert_eq!(audit_config.audit_abandoned, AbandonedHandling::Fail); + assert!(audit_config.block_insecure); + assert!(!audit_config.block_abandoned); + assert!(!audit_config.ignore_unreachable); + assert!(audit_config.ignore_list_for_audit.is_empty()); + assert!(audit_config.ignore_severity_for_audit.is_empty()); + } + + #[test] + fn test_audit_config_from_config_with_audit_section() { + use std::collections::BTreeMap; + let mut config = Config::default(); + config + .merge(&BTreeMap::from([( + "audit".to_string(), + serde_json::json!({ + "ignore": ["CVE-2024-1234"], + "ignore-severity": {"low": "low severity not critical"}, + "abandoned": "report", + "block-insecure": false, + "ignore-unreachable": true + }), + )])) + .unwrap(); + + let audit_config = AuditConfig::from_config(&config, true, AuditFormat::Summary); + assert_eq!(audit_config.audit_abandoned, AbandonedHandling::Report); + assert!(!audit_config.block_insecure); + assert!(audit_config.ignore_unreachable); + assert_eq!(audit_config.ignore_list_for_audit.len(), 1); + assert_eq!(audit_config.ignore_severity_for_audit.len(), 1); + } +} diff --git a/crates/mozart-core/src/config_source.rs b/crates/mozart-core/src/config_source.rs index e5c3536..984007a 100644 --- a/crates/mozart-core/src/config_source.rs +++ b/crates/mozart-core/src/config_source.rs @@ -54,10 +54,7 @@ impl JsonConfigSource { if let Some(inner) = val.as_object() { let mut entry = serde_json::Map::new(); if !inner.contains_key("name") { - entry.insert( - "name".to_string(), - serde_json::Value::String(key.clone()), - ); + entry.insert("name".to_string(), serde_json::Value::String(key.clone())); } for (k, v) in inner { entry.insert(k.clone(), v.clone()); @@ -91,9 +88,7 @@ impl JsonConfigSource { serde_json::Value::Object(o) => o.is_empty(), _ => false, }; - if is_empty - && let Some(obj) = root.as_object_mut() - { + if is_empty && let Some(obj) = root.as_object_mut() { obj.remove("repositories"); } } @@ -269,9 +264,9 @@ impl JsonConfigSource { // List format: find entry by `name` field let idx = root["repositories"].as_array().and_then(|repos| { - repos.iter().position(|repo| { - repo.get("name").and_then(|n| n.as_str()) == Some(name) - }) + repos + .iter() + .position(|repo| repo.get("name").and_then(|n| n.as_str()) == Some(name)) }); match idx { @@ -329,7 +324,11 @@ mod tests { fn add_repository_prepend() { let dir = TempDir::new().unwrap(); let (src, path) = source(&dir, "composer.json"); - std::fs::write(&path, r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"}]}"#).unwrap(); + std::fs::write( + &path, + r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"}]}"#, + ) + .unwrap(); src.add_repository( "b", &serde_json::json!({"type": "vcs", "url": "https://b.com"}), @@ -346,7 +345,11 @@ mod tests { fn add_repository_append() { let dir = TempDir::new().unwrap(); let (src, path) = source(&dir, "composer.json"); - std::fs::write(&path, r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"}]}"#).unwrap(); + std::fs::write( + &path, + r#"{"repositories":[{"name":"a","type":"vcs","url":"https://a.com"}]}"#, + ) + .unwrap(); src.add_repository( "b", &serde_json::json!({"type": "vcs", "url": "https://b.com"}), @@ -375,11 +378,7 @@ mod tests { fn add_repository_disable_already_disabled_is_noop() { let dir = TempDir::new().unwrap(); let (src, path) = source(&dir, "composer.json"); - std::fs::write( - &path, - r#"{"repositories":[{"packagist.org":false}]}"#, - ) - .unwrap(); + std::fs::write(&path, r#"{"repositories":[{"packagist.org":false}]}"#).unwrap(); src.add_repository("packagist.org", &serde_json::Value::Bool(false), true) .unwrap(); let json: serde_json::Value = diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs index 7403d46..f37bf43 100644 --- a/crates/mozart-core/src/lib.rs +++ b/crates/mozart-core/src/lib.rs @@ -1,5 +1,6 @@ extern crate self as mozart_core; +pub mod advisory; pub mod composer; pub mod config; pub mod config_source; diff --git a/crates/mozart-registry/src/advisory.rs b/crates/mozart-registry/src/advisory.rs new file mode 100644 index 0000000..97242b3 --- /dev/null +++ b/crates/mozart-registry/src/advisory.rs @@ -0,0 +1,739 @@ +use std::collections::BTreeMap; + +use indexmap::IndexMap; +use mozart_core::advisory::{AbandonedHandling, AuditFormat}; +use mozart_core::console::Console; +use mozart_core::{console_format, console_writeln, console_writeln_error}; + +use crate::packagist::SecurityAdvisory; +use crate::repository::RepositorySet; + +/// A package being audited, with version and abandonment information. +#[derive(Debug, Clone)] +pub struct PackageInfo { + pub name: String, + pub version: String, + pub version_normalized: Option<String>, + /// Raw abandoned field from JSON: `true` = abandoned no replacement, `String` = replacement name. + pub abandoned_raw: Option<serde_json::Value>, +} + +impl PackageInfo { + /// Mirrors `CompletePackage::isAbandoned()`. + pub fn is_abandoned(&self) -> bool { + matches!( + &self.abandoned_raw, + Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::String(_)) + ) + } + + /// Mirrors `CompletePackage::getReplacementPackage()`. + pub fn replacement_package(&self) -> Option<&str> { + match &self.abandoned_raw { + Some(serde_json::Value::String(s)) => Some(s.as_str()), + _ => None, + } + } +} + +/// An advisory paired with the installed version of the package it affects. +#[derive(Debug, Clone)] +pub struct MatchedAdvisory { + pub advisory: SecurityAdvisory, + pub installed_version: String, +} + +/// A matched advisory that was filtered out by the ignore list. +#[derive(Debug, Clone)] +pub struct IgnoredAdvisory { + pub advisory: SecurityAdvisory, + pub installed_version: String, + pub ignore_reason: Option<String>, +} + +/// Result of `Auditor::process_advisories`. +#[derive(Debug, Default)] +pub struct ProcessedAdvisories { + pub advisories: BTreeMap<String, Vec<MatchedAdvisory>>, + pub ignored_advisories: BTreeMap<String, Vec<IgnoredAdvisory>>, +} + +/// An abandoned package found during audit. +#[derive(Debug, Clone)] +pub struct AbandonedPackage { + pub name: String, + pub version: String, + pub replacement: Option<String>, +} + +/// Mirrors `Composer\Advisory\Auditor`. +pub struct Auditor; + +impl Auditor { + pub fn new() -> Self { + Self + } + + /// Main audit entry point. Mirrors `Composer\Advisory\Auditor::audit()`. + /// + /// Returns a bitmask: 0=ok, 1=vulnerable, 2=abandoned, 3=both. + pub async fn audit( + &self, + console: &Console, + repo_set: &RepositorySet, + packages: &[PackageInfo], + format: AuditFormat, + warning_only: bool, + ignore_list: &IndexMap<String, Option<String>>, + abandoned: AbandonedHandling, + ignored_severities: &IndexMap<String, Option<String>>, + ignore_unreachable: bool, + ignore_abandoned: &IndexMap<String, Option<String>>, + ) -> anyhow::Result<u8> { + let (all_advisories, unreachable_repos) = repo_set + .get_matching_security_advisories( + packages, + format == AuditFormat::Summary, + ignore_unreachable, + ) + .await?; + + let ProcessedAdvisories { + advisories, + ignored_advisories, + } = self.process_advisories(all_advisories, ignore_list, ignored_severities); + + let abandoned_packages = if abandoned == AbandonedHandling::Ignore { + vec![] + } else { + self.filter_abandoned_packages(packages, ignore_abandoned) + }; + + let abandoned_count = if abandoned == AbandonedHandling::Fail { + abandoned_packages.len() + } else { + 0 + }; + + let affected_packages_count = advisories.len(); + let bitmask = self.calculate_bitmask(affected_packages_count > 0, abandoned_count > 0); + + if format == AuditFormat::Json { + self.render_json( + &advisories, + &ignored_advisories, + &unreachable_repos, + &abandoned_packages, + console, + ); + return Ok(bitmask); + } + + let (ignored_pkg_count, ignored_total) = self.count_ignored(&ignored_advisories); + let (active_pkg_count, active_total) = self.count_matched(&advisories); + + if active_pkg_count > 0 || ignored_pkg_count > 0 { + if ignored_pkg_count > 0 { + let plurality = if ignored_total == 1 { "y" } else { "ies" }; + let pkg_plurality = if ignored_pkg_count == 1 { "" } else { "s" }; + let punctuation = if format == AuditFormat::Summary { + "." + } else { + ":" + }; + let msg = format!( + "Found {ignored_total} ignored security vulnerability advisor{plurality} affecting {ignored_pkg_count} package{pkg_plurality}{punctuation}" + ); + console_writeln_error!(console, &console_format!("<info>{msg}</info>")); + self.output_advisories_ignored(console, &ignored_advisories, format); + } + + if active_pkg_count > 0 { + let plurality = if active_total == 1 { "y" } else { "ies" }; + let pkg_plurality = if active_pkg_count == 1 { "" } else { "s" }; + let punctuation = if format == AuditFormat::Summary { + "." + } else { + ":" + }; + let msg = format!( + "Found {active_total} security vulnerability advisor{plurality} affecting {active_pkg_count} package{pkg_plurality}{punctuation}" + ); + if warning_only { + console_writeln_error!(console, &console_format!("<warning>{msg}</warning>")); + } else { + console_writeln_error!(console, &console_format!("<error>{msg}</error>")); + } + self.output_advisories(console, &advisories, format); + } + + if format == AuditFormat::Summary { + console_writeln_error!( + console, + "Run \"mozart audit\" for a full list of advisories." + ); + } + } else { + console_writeln_error!( + console, + &console_format!("<info>No security vulnerability advisories found.</info>") + ); + } + + if !unreachable_repos.is_empty() { + console_writeln_error!( + console, + &console_format!("<warning>The following repositories were unreachable:</warning>") + ); + for repo in &unreachable_repos { + console_writeln_error!(console, &format!(" - {repo}")); + } + } + + if !abandoned_packages.is_empty() && format != AuditFormat::Summary { + self.output_abandoned_packages(console, &abandoned_packages, format); + } + + Ok(bitmask) + } + + /// Mirrors `Composer\Advisory\Auditor::processAdvisories()`. + /// + /// Splits advisories into active and ignored based on the ignore list and ignored severities. + /// Checks by: package name, advisory ID, severity, CVE, and source remote IDs. + pub fn process_advisories( + &self, + all_advisories: BTreeMap<String, Vec<MatchedAdvisory>>, + ignore_list: &IndexMap<String, Option<String>>, + ignored_severities: &IndexMap<String, Option<String>>, + ) -> ProcessedAdvisories { + if ignore_list.is_empty() && ignored_severities.is_empty() { + return ProcessedAdvisories { + advisories: all_advisories, + ignored_advisories: BTreeMap::new(), + }; + } + + let mut advisories: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); + let mut ignored: BTreeMap<String, Vec<IgnoredAdvisory>> = BTreeMap::new(); + + for (package, pkg_advisories) in all_advisories { + for matched in pkg_advisories { + let adv = &matched.advisory; + let mut is_active = true; + let mut ignore_reason: Option<String> = None; + + // Check by package name + if let Some(reason) = ignore_list.get(&package) { + is_active = false; + ignore_reason = reason.clone(); + } + + // Check by advisory ID + if is_active + && let Some(reason) = ignore_list.get(&adv.advisory_id) { + is_active = false; + ignore_reason = reason.clone(); + } + + // Check by severity + if is_active + && let Some(ref sev) = adv.severity + && let Some(reason) = ignored_severities.get(sev.as_str()) { + is_active = false; + ignore_reason = reason + .clone() + .or_else(|| Some(format!("{sev} severity is ignored"))); + } + + // Check by CVE + if is_active + && let Some(ref cve) = adv.cve + && let Some(reason) = ignore_list.get(cve.as_str()) { + is_active = false; + ignore_reason = reason.clone(); + } + + // Check by source remote IDs + if is_active { + for source in &adv.sources { + if let Some(reason) = ignore_list.get(&source.remote_id) { + is_active = false; + ignore_reason = reason.clone(); + break; + } + } + } + + if is_active { + advisories.entry(package.clone()).or_default().push(matched); + } else { + ignored + .entry(package.clone()) + .or_default() + .push(IgnoredAdvisory { + advisory: matched.advisory, + installed_version: matched.installed_version, + ignore_reason, + }); + } + } + } + + ProcessedAdvisories { + advisories, + ignored_advisories: ignored, + } + } + + /// Mirrors `Composer\Advisory\Auditor::filterAbandonedPackages()`. + pub fn filter_abandoned_packages( + &self, + packages: &[PackageInfo], + ignore_abandoned: &IndexMap<String, Option<String>>, + ) -> Vec<AbandonedPackage> { + packages + .iter() + .filter(|pkg| { + if !pkg.is_abandoned() { + return false; + } + if !ignore_abandoned.is_empty() { + let name_lower = pkg.name.to_lowercase(); + // Case-insensitive exact name match (wildcard support deferred) + if ignore_abandoned + .keys() + .any(|k| k.to_lowercase() == name_lower) + { + return false; + } + } + true + }) + .map(|pkg| AbandonedPackage { + name: pkg.name.clone(), + version: pkg.version.clone(), + replacement: pkg.replacement_package().map(|s| s.to_string()), + }) + .collect() + } + + /// Mirrors `Composer\Advisory\Auditor::needsCompleteAdvisoryLoad()`. + /// + /// Mozart always fetches full advisories (no partial optimization), so this is always false. + pub fn needs_complete_advisory_load( + &self, + advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, + _ignore_list: &IndexMap<String, Option<String>>, + ) -> bool { + let _ = advisories; + false + } + + fn calculate_bitmask(&self, has_vulnerable: bool, has_abandoned: bool) -> u8 { + let mut bitmask = 0u8; + if has_vulnerable { + bitmask |= 1; + } + if has_abandoned { + bitmask |= 2; + } + bitmask + } + + fn count_ignored(&self, advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>) -> (usize, usize) { + let pkg_count = advisories.len(); + let total = advisories.values().map(|v| v.len()).sum(); + (pkg_count, total) + } + + fn count_matched(&self, advisories: &BTreeMap<String, Vec<MatchedAdvisory>>) -> (usize, usize) { + let pkg_count = advisories.len(); + let total = advisories.values().map(|v| v.len()).sum(); + (pkg_count, total) + } + + fn output_advisories( + &self, + console: &Console, + advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, + format: AuditFormat, + ) { + match format { + AuditFormat::Table => self.output_advisories_table(console, advisories), + AuditFormat::Plain => self.output_advisories_plain(console, advisories), + AuditFormat::Summary => {} + AuditFormat::Json => unreachable!(), + } + } + + fn output_advisories_ignored( + &self, + console: &Console, + advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, + format: AuditFormat, + ) { + match format { + AuditFormat::Table => self.output_ignored_advisories_table(console, advisories), + AuditFormat::Plain => self.output_ignored_advisories_plain(console, advisories), + AuditFormat::Summary => {} + AuditFormat::Json => unreachable!(), + } + } + + fn output_advisories_table( + &self, + console: &Console, + advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, + ) { + for pkg_advisories in advisories.values() { + for matched in pkg_advisories { + self.render_advisory_table( + console, + &matched.advisory, + &matched.installed_version, + None, + ); + } + } + } + + fn output_ignored_advisories_table( + &self, + console: &Console, + advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, + ) { + for pkg_advisories in advisories.values() { + for ignored in pkg_advisories { + self.render_advisory_table( + console, + &ignored.advisory, + &ignored.installed_version, + ignored.ignore_reason.as_deref(), + ); + } + } + } + + fn render_advisory_table( + &self, + console: &Console, + adv: &SecurityAdvisory, + installed_version: &str, + ignore_reason: Option<&str>, + ) { + let label_width = 17usize; + let mut rows: Vec<(&str, String)> = vec![ + ("Package", adv.package_name.clone()), + ("Version", installed_version.to_string()), + ("Severity", adv.severity.clone().unwrap_or_default()), + ("Advisory ID", adv.advisory_id.clone()), + ( + "CVE", + adv.cve.clone().unwrap_or_else(|| "NO CVE".to_string()), + ), + ("Title", adv.title.clone()), + ("URL", adv.link.clone().unwrap_or_default()), + ("Affected versions", adv.affected_versions.clone()), + ("Reported at", adv.reported_at.clone()), + ]; + if let Some(reason) = ignore_reason { + rows.push(("Ignore reason", reason.to_string())); + } + + let value_width = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0).max(20); + let separator = format!( + "+-{:-<lw$}-+-{:-<vw$}-+", + "", + "", + lw = label_width, + vw = value_width + ); + + console_writeln_error!(console, &separator); + for (label, value) in &rows { + console_writeln_error!( + console, + &format!( + "| {:<lw$} | {:<vw$} |", + label, + value, + lw = label_width, + vw = value_width + ), + ); + } + console_writeln_error!(console, &separator); + console_writeln_error!(console, ""); + } + + fn output_advisories_plain( + &self, + console: &Console, + advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, + ) { + let mut first = true; + for pkg_advisories in advisories.values() { + for matched in pkg_advisories { + if !first { + console_writeln_error!(console, "--------"); + } + self.render_advisory_plain( + console, + &matched.advisory, + &matched.installed_version, + None, + ); + first = false; + } + } + } + + fn output_ignored_advisories_plain( + &self, + console: &Console, + advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, + ) { + let mut first = true; + for pkg_advisories in advisories.values() { + for ignored in pkg_advisories { + if !first { + console_writeln_error!(console, "--------"); + } + self.render_advisory_plain( + console, + &ignored.advisory, + &ignored.installed_version, + ignored.ignore_reason.as_deref(), + ); + first = false; + } + } + } + + fn render_advisory_plain( + &self, + console: &Console, + adv: &SecurityAdvisory, + installed_version: &str, + ignore_reason: Option<&str>, + ) { + console_writeln_error!(console, &format!("Package: {}", adv.package_name)); + console_writeln_error!(console, &format!("Version: {installed_version}")); + console_writeln_error!( + console, + &format!("Severity: {}", adv.severity.as_deref().unwrap_or("")) + ); + console_writeln_error!(console, &format!("Advisory ID: {}", adv.advisory_id)); + console_writeln_error!( + console, + &format!("CVE: {}", adv.cve.as_deref().unwrap_or("NO CVE")) + ); + console_writeln_error!(console, &format!("Title: {}", adv.title)); + console_writeln_error!( + console, + &format!("URL: {}", adv.link.as_deref().unwrap_or("")) + ); + console_writeln_error!( + console, + &format!("Affected versions: {}", adv.affected_versions) + ); + console_writeln_error!(console, &format!("Reported at: {}", adv.reported_at)); + if let Some(reason) = ignore_reason { + console_writeln_error!(console, &format!("Ignore reason: {reason}")); + } + } + + fn output_abandoned_packages( + &self, + console: &Console, + packages: &[AbandonedPackage], + format: AuditFormat, + ) { + let count = packages.len(); + let plurality = if count == 1 { "" } else { "s" }; + console_writeln_error!( + console, + &console_format!("<error>Found {count} abandoned package{plurality}:</error>") + ); + + if format == AuditFormat::Plain { + for pkg in packages { + match &pkg.replacement { + Some(repl) => console_writeln_error!( + console, + &format!( + "{} ({}) is abandoned. Use {} instead.", + pkg.name, pkg.version, repl + ), + ), + None => console_writeln_error!( + console, + &format!( + "{} ({}) is abandoned. No replacement was suggested.", + pkg.name, pkg.version + ), + ), + } + } + return; + } + + // Table format + let name_width = 20usize; + let ver_width = packages + .iter() + .map(|a| a.version.len()) + .max() + .unwrap_or(0) + .max("Version".len()); + let repl_width = packages + .iter() + .map(|a| { + a.replacement + .as_deref() + .unwrap_or("No replacement suggested") + .len() + }) + .max() + .unwrap_or(0) + .max("Suggested Replacement".len()); + + console_writeln_error!( + console, + &format!( + "| {:<nw$} | {:<vw$} | {:<rw$} |", + "Abandoned Package", + "Version", + "Suggested Replacement", + nw = name_width, + vw = ver_width, + rw = repl_width + ), + ); + console_writeln_error!( + console, + &format!( + "+-{:-<nw$}-+-{:-<vw$}-+-{:-<rw$}-+", + "", + "", + "", + nw = name_width, + vw = ver_width, + rw = repl_width + ), + ); + for pkg in packages { + let replacement = pkg + .replacement + .as_deref() + .unwrap_or("No replacement suggested"); + console_writeln_error!( + console, + &format!( + "| {:<nw$} | {:<vw$} | {:<rw$} |", + pkg.name, + pkg.version, + replacement, + nw = name_width, + vw = ver_width, + rw = repl_width + ), + ); + } + console_writeln_error!(console, ""); + } + + fn render_json( + &self, + advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, + ignored_advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, + unreachable_repos: &[String], + abandoned_packages: &[AbandonedPackage], + console: &Console, + ) { + let mut advisories_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); + for (pkg_name, matched_list) in advisories { + let arr: Vec<serde_json::Value> = matched_list + .iter() + .map(|m| serde_json::to_value(&m.advisory).unwrap_or(serde_json::Value::Null)) + .collect(); + advisories_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); + } + + let mut output = serde_json::json!({ "advisories": advisories_map }); + + // ignored-advisories (only if non-empty) + if !ignored_advisories.is_empty() { + let mut ignored_map: serde_json::Map<String, serde_json::Value> = + serde_json::Map::new(); + for (pkg_name, ignored_list) in ignored_advisories { + let arr: Vec<serde_json::Value> = ignored_list + .iter() + .map(|i| { + let mut val = + serde_json::to_value(&i.advisory).unwrap_or(serde_json::Value::Null); + if let serde_json::Value::Object(ref mut obj) = val { + obj.insert( + "ignoreReason".to_string(), + i.ignore_reason + .as_ref() + .map(|r| serde_json::Value::String(r.clone())) + .unwrap_or(serde_json::Value::Null), + ); + } + val + }) + .collect(); + ignored_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); + } + if let serde_json::Value::Object(ref mut obj) = output { + obj.insert( + "ignored-advisories".to_string(), + serde_json::Value::Object(ignored_map), + ); + } + } + + // unreachable-repositories (only if non-empty) + if !unreachable_repos.is_empty() { + let repos_arr: Vec<serde_json::Value> = unreachable_repos + .iter() + .map(|r| serde_json::Value::String(r.clone())) + .collect(); + if let serde_json::Value::Object(ref mut obj) = output { + obj.insert( + "unreachable-repositories".to_string(), + serde_json::Value::Array(repos_arr), + ); + } + } + + // abandoned map: package_name => replacement (null if none) + let mut abandoned_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); + for pkg in abandoned_packages { + abandoned_map.insert( + pkg.name.clone(), + pkg.replacement + .as_ref() + .map(|r| serde_json::Value::String(r.clone())) + .unwrap_or(serde_json::Value::Null), + ); + } + if let serde_json::Value::Object(ref mut obj) = output { + obj.insert( + "abandoned".to_string(), + serde_json::Value::Object(abandoned_map), + ); + } + + let json_str = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string()); + console_writeln!(console, &json_str); + } +} + +impl Default for Auditor { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs index 9d72c36..8f9af91 100644 --- a/crates/mozart-registry/src/lib.rs +++ b/crates/mozart-registry/src/lib.rs @@ -1,3 +1,4 @@ +pub mod advisory; pub mod browse_repos; pub mod cache; pub mod composer_repo; diff --git a/crates/mozart-registry/src/repository/mod.rs b/crates/mozart-registry/src/repository/mod.rs index 6642638..46f62f0 100644 --- a/crates/mozart-registry/src/repository/mod.rs +++ b/crates/mozart-registry/src/repository/mod.rs @@ -10,6 +10,9 @@ //! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package` //! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories. +use std::collections::BTreeMap; + +use crate::advisory::{MatchedAdvisory, PackageInfo}; use crate::packagist::{PackagistVersion, SearchResult}; pub mod inline_package_repo; @@ -191,4 +194,126 @@ impl RepositorySet { } Ok(all) } + + /// Fetch security advisories matching the installed packages, with version filtering. + /// + /// Mirrors `Composer\Repository\RepositorySet::getMatchingSecurityAdvisories()`. + /// Returns the matched advisories (already filtered by installed version) and a list + /// of unreachable repository URLs. When `ignore_unreachable` is false and a repository + /// is unreachable, the error is propagated instead. + pub async fn get_matching_security_advisories( + &self, + packages: &[PackageInfo], + _allow_partial: bool, + ignore_unreachable: bool, + ) -> anyhow::Result<(BTreeMap<String, Vec<MatchedAdvisory>>, Vec<String>)> { + let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); + + let (raw_advisories, unreachable_repos) = + match crate::packagist::fetch_security_advisories(&names).await { + Ok(a) => (a, vec![]), + Err(e) if ignore_unreachable => { + tracing::warn!("Packagist advisory fetch failed (ignored): {e}"); + let unreachable = vec!["https://packagist.org".to_string()]; + (BTreeMap::new(), unreachable) + } + Err(e) => return Err(e), + }; + + let matched = version_filter_advisories(&raw_advisories, packages); + + Ok((matched, unreachable_repos)) + } +} + +/// Normalize single-pipe OR separators (`|`) in a version constraint string to +/// double-pipe (`||`) so the constraint parser can handle both forms. +/// +/// The Packagist security advisories API may return constraints with single `|` +/// as the OR separator (e.g. `>=1.0,<1.5|>=2.0,<2.3`), but Mozart's +/// `VersionConstraint::parse` expects `||`. +/// +/// TODO: fix `mozart_semver::VersionConstraint::parse` to accept single `|` and remove this. +fn normalize_or_separator(constraint: &str) -> String { + let bytes = constraint.as_bytes(); + let mut result = String::with_capacity(constraint.len() + 4); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'|' { + if i + 1 < bytes.len() && bytes[i + 1] == b'|' { + result.push_str("||"); + i += 2; + } else { + result.push_str("||"); + i += 1; + } + } else { + result.push(bytes[i] as char); + i += 1; + } + } + result +} + +/// Filter raw advisories by installed package versions. +/// +/// Mirrors the version-matching step inside Composer's repository advisory fetch. +fn version_filter_advisories( + all_advisories: &BTreeMap<String, Vec<crate::packagist::SecurityAdvisory>>, + packages: &[PackageInfo], +) -> BTreeMap<String, Vec<MatchedAdvisory>> { + let mut result: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); + + for pkg in packages { + let Some(advisories) = all_advisories.get(&pkg.name) else { + continue; + }; + + let version_str = pkg + .version_normalized + .as_deref() + .unwrap_or(pkg.version.as_str()); + + let installed_ver = match mozart_semver::Version::parse(version_str) { + Ok(v) => v, + Err(_) => { + tracing::warn!( + "Could not parse version {:?} for package {:?}, skipping advisory matching", + version_str, + pkg.name + ); + continue; + } + }; + + let mut matched: Vec<MatchedAdvisory> = Vec::new(); + + for advisory in advisories { + let normalized = normalize_or_separator(&advisory.affected_versions); + let constraint = match mozart_semver::VersionConstraint::parse(&normalized) { + Ok(c) => c, + Err(_) => { + tracing::warn!( + "Could not parse affected versions {:?} for advisory {:?}, skipping", + advisory.affected_versions, + advisory.advisory_id + ); + continue; + } + }; + + if constraint.matches(&installed_ver) { + matched.push(MatchedAdvisory { + advisory: advisory.clone(), + installed_version: pkg.version.clone(), + }); + } + } + + if !matched.is_empty() { + result.insert(pkg.name.clone(), matched); + } + } + + result } diff --git a/crates/mozart/src/commands.rs b/crates/mozart/src/commands.rs index bf98bee..1717437 100644 --- a/crates/mozart/src/commands.rs +++ b/crates/mozart/src/commands.rs @@ -1,7 +1,7 @@ pub mod about; pub mod archive; -pub(crate) mod base_config; pub mod audit; +pub(crate) mod base_config; pub mod browse; pub mod bump; pub mod check_platform_reqs; diff --git a/crates/mozart/src/commands/audit.rs b/crates/mozart/src/commands/audit.rs index da61e62..7f2c5c2 100644 --- a/crates/mozart/src/commands/audit.rs +++ b/crates/mozart/src/commands/audit.rs @@ -1,11 +1,13 @@ -use clap::Args; -use mozart_core::console_format; -use mozart_core::console_writeln; -use mozart_core::console_writeln_error; -use mozart_registry::packagist::SecurityAdvisory; -use std::collections::BTreeMap; use std::path::Path; +use clap::Args; +use indexmap::IndexMap; +use mozart_core::advisory::{AbandonedHandling, AuditConfig, AuditFormat}; +use mozart_core::composer::Composer; +use mozart_registry::advisory::{Auditor, PackageInfo}; +use mozart_registry::cache::{Cache, build_cache_config}; +use mozart_registry::repository::RepositorySet; + #[derive(Args)] pub struct AuditArgs { /// Disables auditing of require-dev packages @@ -13,8 +15,8 @@ pub struct AuditArgs { pub no_dev: bool, /// Output format (table, plain, json, summary) - #[arg(short, long, default_value = "table")] - pub format: String, + #[arg(short, long)] + pub format: Option<String>, /// Audit packages from the lock file instead of installed #[arg(long)] @@ -33,148 +35,95 @@ pub struct AuditArgs { pub ignore_unreachable: bool, } -#[derive(Debug)] -struct PackageEntry { - name: String, - version: String, - version_normalized: Option<String>, - abandoned: Option<serde_json::Value>, -} - -/// An advisory that matched an installed package version. -struct MatchedAdvisory { - advisory: SecurityAdvisory, - installed_version: String, -} - -/// An abandoned package found during audit. -struct AbandonedPackage { - name: String, - version: String, - replacement: Option<String>, -} - -/// Aggregated audit results. -struct AuditResult { - /// Map from package name to list of matching advisories. - advisories: BTreeMap<String, Vec<MatchedAdvisory>>, - /// Abandoned packages found (only if --abandoned != ignore). - abandoned: Vec<AbandonedPackage>, - /// Total count of advisory-affected packages. - affected_package_count: usize, - /// Total count of individual advisories. - total_advisory_count: usize, -} - pub async fn execute( args: &AuditArgs, cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - // Validate format - let format = args.format.as_str(); - if format != "table" && format != "plain" && format != "json" && format != "summary" { - anyhow::bail!( - "Invalid format \"{}\". Supported formats: table, plain, json, summary", - format - ); - } - - // Validate --abandoned - let abandoned_mode = match args.abandoned.as_deref().unwrap_or("fail") { - "ignore" => "ignore", - "report" => "report", - "fail" => "fail", - other => anyhow::bail!( - "Invalid abandoned value \"{}\". Supported values: ignore, report, fail", - other - ), - }; - let working_dir = cli.working_dir()?; - // Load packages - let packages = load_packages(&working_dir, args.locked, args.no_dev)?; + // Load Composer state (reads composer.json + config) + let composer = Composer::require(&working_dir)?; - if packages.is_empty() { - console.info("No packages - skipping audit."); - return Ok(()); - } + // Parse audit config from composer.json's config.audit section + let audit_config = AuditConfig::from_config(composer.config(), true, AuditFormat::Table); - // Fetch advisories - let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); - let all_advisories = match mozart_registry::packagist::fetch_security_advisories(&names).await { - Ok(a) => a, - Err(e) => { - if args.ignore_unreachable { - BTreeMap::new() - } else { - return Err(e); - } - } + // Resolve format: CLI arg > config default (table) + let format = match args.format.as_deref() { + Some(f) => match AuditFormat::from_str(f) { + Some(fmt) => fmt, + None => anyhow::bail!( + "Invalid format \"{f}\". Supported formats: table, plain, json, summary" + ), + }, + None => audit_config.audit_format, }; - // Filter advisories by installed versions and severity - let matched = filter_advisories(&all_advisories, &packages, &args.ignore_severity, console); - - // Detect abandoned packages - let abandoned = if abandoned_mode == "ignore" { - Vec::new() - } else { - detect_abandoned(&packages) + // Resolve --abandoned: CLI > config + let abandoned = match args.abandoned.as_deref() { + Some(s) => match AbandonedHandling::from_str(s) { + Some(h) => h, + None => anyhow::bail!( + "Invalid abandoned value \"{s}\". Supported values: ignore, report, fail" + ), + }, + None => audit_config.audit_abandoned, }; - // Build result - let affected_package_count = matched.len(); - let total_advisory_count = matched.values().map(|v| v.len()).sum(); + // Merge CLI --ignore-severity with config's ignore_severity_for_audit + let mut ignore_severities: IndexMap<String, Option<String>> = + audit_config.ignore_severity_for_audit.clone(); + for sev in &args.ignore_severity { + ignore_severities.entry(sev.clone()).or_insert(None); + } - let result = AuditResult { - advisories: matched, - abandoned, - affected_package_count, - total_advisory_count, - }; + // OR CLI --ignore-unreachable with config + let ignore_unreachable = args.ignore_unreachable || audit_config.ignore_unreachable; - // Render output - match format { - "table" => render_table(&result, console), - "plain" => render_plain(&result, console), - "json" => render_json(&result, console)?, - "summary" => render_summary(&result, console), - _ => unreachable!(), + // Load packages + let packages = get_packages(&composer, args)?; + + if packages.is_empty() { + console.info("No packages - skipping audit."); + return Ok(()); } - // Compute bitmask exit code - let has_advisories = result.total_advisory_count > 0; - let has_abandoned = !result.abandoned.is_empty() && abandoned_mode == "fail"; + // Build repository set + let repo_cache = Cache::repo(&build_cache_config(cli.no_cache)); + let repo_set = RepositorySet::with_packagist(repo_cache); - let exit_code: i32 = match (has_advisories, has_abandoned) { - (false, false) => 0, - (true, false) => 1, - (false, true) => 2, - (true, true) => 3, - }; + // Run audit + let exit_code = Auditor::new() + .audit( + console, + &repo_set, + &packages, + format, + false, + &audit_config.ignore_list_for_audit, + abandoned, + &ignore_severities, + ignore_unreachable, + &audit_config.ignore_abandoned_for_audit, + ) + .await?; if exit_code != 0 { - return Err(mozart_core::exit_code::bail_silent(exit_code)); + return Err(mozart_core::exit_code::bail_silent(exit_code as i32)); } Ok(()) } -fn load_packages( - working_dir: &Path, - locked: bool, - no_dev: bool, -) -> anyhow::Result<Vec<PackageEntry>> { - if locked { - load_locked_packages(working_dir, no_dev) +fn get_packages(composer: &Composer, args: &AuditArgs) -> anyhow::Result<Vec<PackageInfo>> { + if args.locked { + load_locked_packages(composer.project_dir(), args.no_dev) } else { - load_installed_packages(working_dir, no_dev) + load_installed_packages(composer.project_dir(), args.no_dev) } } -fn load_installed_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<PackageEntry>> { +fn load_installed_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<PackageInfo>> { let vendor_dir = working_dir.join("vendor"); let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; @@ -194,12 +143,12 @@ fn load_installed_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<V true }) .map(|p| { - let abandoned = p.extra_fields.get("abandoned").cloned(); - PackageEntry { + let abandoned_raw = p.extra_fields.get("abandoned").cloned(); + PackageInfo { name: p.name.clone(), version: p.version.clone(), version_normalized: p.version_normalized.clone(), - abandoned, + abandoned_raw, } }) .collect(); @@ -207,7 +156,7 @@ fn load_installed_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<V Ok(packages) } -fn load_locked_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<PackageEntry>> { +fn load_locked_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<PackageInfo>> { let lock_path = working_dir.join("composer.lock"); if !lock_path.exists() { anyhow::bail!( @@ -220,19 +169,20 @@ fn load_locked_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec< let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = lock.packages.iter().collect(); - if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { - all_packages.extend(pkgs_dev.iter()); - } + if !no_dev + && let Some(ref pkgs_dev) = lock.packages_dev { + all_packages.extend(pkgs_dev.iter()); + } let packages = all_packages .iter() .map(|p| { - let abandoned = p.extra_fields.get("abandoned").cloned(); - PackageEntry { + let abandoned_raw = p.extra_fields.get("abandoned").cloned(); + PackageInfo { name: p.name.clone(), version: p.version.clone(), version_normalized: p.version_normalized.clone(), - abandoned, + abandoned_raw, } }) .collect(); @@ -240,605 +190,35 @@ fn load_locked_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec< Ok(packages) } -fn filter_advisories( - all_advisories: &BTreeMap<String, Vec<SecurityAdvisory>>, - packages: &[PackageEntry], - ignore_severity: &[String], - console: &mozart_core::console::Console, -) -> BTreeMap<String, Vec<MatchedAdvisory>> { - let ignore_set: indexmap::IndexSet<String> = - ignore_severity.iter().map(|s| s.to_lowercase()).collect(); - - let mut result: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); - - for pkg in packages { - let Some(advisories) = all_advisories.get(&pkg.name) else { - continue; - }; - - // Parse the installed version - let version_str = pkg - .version_normalized - .as_deref() - .unwrap_or(pkg.version.as_str()); - - let installed_ver = match mozart_semver::Version::parse(version_str) { - Ok(v) => v, - Err(_) => { - console_writeln_error!( - console, - &format!( - "Warning: could not parse version \"{}\" for package \"{}\", skipping advisory matching", - version_str, pkg.name - ), - ); - continue; - } - }; - - let mut matched: Vec<MatchedAdvisory> = Vec::new(); - - for advisory in advisories { - // Apply severity filter - if let Some(ref sev) = advisory.severity - && ignore_set.contains(&sev.to_lowercase()) - { - continue; - } - - // Parse and match the affected versions constraint. - // Normalize single-pipe OR separators (`|`) to double-pipe (`||`) - // since the Packagist API may use either form. - let normalized_constraint = normalize_or_separator(&advisory.affected_versions); - let constraint = match mozart_semver::VersionConstraint::parse(&normalized_constraint) { - Ok(c) => c, - Err(_) => { - console_writeln_error!( - console, - &format!( - "Warning: could not parse affected versions \"{}\" for advisory \"{}\", skipping", - advisory.affected_versions, advisory.advisory_id - ), - ); - continue; - } - }; - - if constraint.matches(&installed_ver) { - matched.push(MatchedAdvisory { - advisory: advisory.clone(), - installed_version: pkg.version.clone(), - }); - } - } - - if !matched.is_empty() { - result.insert(pkg.name.clone(), matched); - } - } - - result -} - -/// Normalize single-pipe OR separators (`|`) in a version constraint string to -/// double-pipe (`||`) so the constraint parser can handle both forms. -/// -/// The Packagist security advisories API may return constraints with single `|` -/// as the OR separator (e.g. `>=1.0,<1.5|>=2.0,<2.3`), but Mozart's -/// `VersionConstraint::parse` expects `||`. -fn normalize_or_separator(constraint: &str) -> String { - // Replace isolated `|` (not already `||`) with `||`. - // Walk byte-by-byte to avoid replacing `||` again. - let bytes = constraint.as_bytes(); - let mut result = String::with_capacity(constraint.len() + 4); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'|' { - if i + 1 < bytes.len() && bytes[i + 1] == b'|' { - // Already `||` — emit as-is and skip both - result.push_str("||"); - i += 2; - } else { - // Single `|` — upgrade to `||` - result.push_str("||"); - i += 1; - } - } else { - result.push(bytes[i] as char); - i += 1; - } - } - result -} - -fn detect_abandoned(packages: &[PackageEntry]) -> Vec<AbandonedPackage> { - let mut result = Vec::new(); - - for pkg in packages { - let Some(ref abandoned_val) = pkg.abandoned else { - continue; - }; - - let replacement = match abandoned_val { - serde_json::Value::Bool(true) => None, - serde_json::Value::String(s) => Some(s.clone()), - _ => continue, - }; - - result.push(AbandonedPackage { - name: pkg.name.clone(), - version: pkg.version.clone(), - replacement, - }); - } - - result -} - -fn render_table(result: &AuditResult, console: &mozart_core::console::Console) { - if result.total_advisory_count == 0 && result.abandoned.is_empty() { - console.info(&console_format!( - "<info>No security vulnerability advisories found.</info>" - )); - return; - } - - if result.total_advisory_count > 0 { - let advisory_word = if result.total_advisory_count == 1 { - "advisory" - } else { - "advisories" - }; - let header = format!( - "Found {} security vulnerability {} affecting {} package(s):", - result.total_advisory_count, advisory_word, result.affected_package_count - ); - console_writeln_error!(console, &console_format!("<highlight>{header}</highlight>"),); - console_writeln_error!(console, ""); - - for advisories in result.advisories.values() { - for matched in advisories { - let adv = &matched.advisory; - - // Compute column widths for the two-column table - let label_width = 17usize; - let rows: Vec<(&str, String)> = vec![ - ("Package", adv.package_name.clone()), - ("Version", matched.installed_version.clone()), - ("Severity", adv.severity.clone().unwrap_or_default()), - ("Advisory ID", adv.advisory_id.clone()), - ( - "CVE", - adv.cve.clone().unwrap_or_else(|| "NO CVE".to_string()), - ), - ("Title", adv.title.clone()), - ("URL", adv.link.clone().unwrap_or_default()), - ("Affected versions", adv.affected_versions.clone()), - ("Reported at", adv.reported_at.clone()), - ]; - - let value_width = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0).max(20); - let separator = format!( - "+-{:-<lw$}-+-{:-<vw$}-+", - "", - "", - lw = label_width, - vw = value_width - ); - - console_writeln_error!(console, &separator); - for (label, value) in &rows { - console_writeln_error!( - console, - &format!( - "| {:<lw$} | {:<vw$} |", - label, - value, - lw = label_width, - vw = value_width - ), - ); - } - console_writeln_error!(console, &separator); - console_writeln_error!(console, ""); - } - } - } - - if !result.abandoned.is_empty() { - let header = format!("Found {} abandoned package(s):", result.abandoned.len()); - console_writeln_error!(console, &console_format!("<highlight>{header}</highlight>"),); - console_writeln_error!(console, ""); - - let name_width = 20usize; - let ver_width = result - .abandoned - .iter() - .map(|a| a.version.len()) - .max() - .unwrap_or(0) - .max("Version".len()); - let repl_width = result - .abandoned - .iter() - .map(|a| { - a.replacement - .as_deref() - .unwrap_or("No replacement suggested") - .len() - }) - .max() - .unwrap_or(0) - .max("Suggested Replacement".len()); - - console_writeln_error!( - console, - &format!( - "| {:<nw$} | {:<vw$} | {:<rw$} |", - "Abandoned Package", - "Version", - "Suggested Replacement", - nw = name_width, - vw = ver_width, - rw = repl_width - ), - ); - console_writeln_error!( - console, - &format!( - "+-{:-<nw$}-+-{:-<vw$}-+-{:-<rw$}-+", - "", - "", - "", - nw = name_width, - vw = ver_width, - rw = repl_width - ), - ); - for pkg in &result.abandoned { - let replacement = pkg - .replacement - .as_deref() - .unwrap_or("No replacement suggested"); - console_writeln_error!( - console, - &format!( - "| {:<nw$} | {:<vw$} | {:<rw$} |", - pkg.name, - pkg.version, - replacement, - nw = name_width, - vw = ver_width, - rw = repl_width - ), - ); - } - console_writeln_error!(console, ""); - } -} - -fn render_plain(result: &AuditResult, console: &mozart_core::console::Console) { - if result.total_advisory_count == 0 && result.abandoned.is_empty() { - console.info("No security vulnerability advisories found."); - return; - } - - if result.total_advisory_count > 0 { - let advisory_word = if result.total_advisory_count == 1 { - "advisory" - } else { - "advisories" - }; - console_writeln_error!( - console, - &format!( - "Found {} security vulnerability {} affecting {} package(s):", - result.total_advisory_count, advisory_word, result.affected_package_count - ), - ); - console_writeln_error!(console, ""); - - for advisories in result.advisories.values() { - for matched in advisories { - let adv = &matched.advisory; - console_writeln_error!(console, &format!("Package: {}", adv.package_name),); - console_writeln_error!(console, &format!("Version: {}", matched.installed_version),); - console_writeln_error!( - console, - &format!("Severity: {}", adv.severity.as_deref().unwrap_or("")), - ); - console_writeln_error!(console, &format!("Advisory ID: {}", adv.advisory_id),); - console_writeln_error!( - console, - &format!("CVE: {}", adv.cve.as_deref().unwrap_or("NO CVE")), - ); - console_writeln_error!(console, &format!("Title: {}", adv.title),); - console_writeln_error!( - console, - &format!("URL: {}", adv.link.as_deref().unwrap_or("")), - ); - console_writeln_error!( - console, - &format!("Affected versions: {}", adv.affected_versions), - ); - console_writeln_error!(console, &format!("Reported at: {}", adv.reported_at),); - console_writeln_error!(console, "--------"); - } - } - } - - for pkg in &result.abandoned { - match &pkg.replacement { - Some(repl) => console_writeln_error!( - console, - &format!( - "{} ({}) is abandoned. Use {} instead.", - pkg.name, pkg.version, repl - ), - ), - None => console_writeln_error!( - console, - &format!( - "{} ({}) is abandoned. No replacement was suggested.", - pkg.name, pkg.version - ), - ), - } - } -} - -fn render_json( - result: &AuditResult, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { - // Build advisories map: package_name -> [advisory objects] - let mut advisories_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); - for (pkg_name, advisories) in &result.advisories { - let arr: Vec<serde_json::Value> = advisories - .iter() - .map(|m| serde_json::to_value(&m.advisory).unwrap_or(serde_json::Value::Null)) - .collect(); - advisories_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); - } - - // Build abandoned map: package_name -> { version, replacement } - let mut abandoned_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); - for pkg in &result.abandoned { - let repl = match &pkg.replacement { - Some(s) => serde_json::Value::String(s.clone()), - None => serde_json::Value::Null, - }; - let entry = serde_json::json!({ - "version": pkg.version, - "replacement": repl, - }); - abandoned_map.insert(pkg.name.clone(), entry); - } - - let output = serde_json::json!({ - "advisories": advisories_map, - "abandoned": abandoned_map, - }); - - console_writeln!(console, &serde_json::to_string_pretty(&output)?,); - Ok(()) -} - -fn render_summary(result: &AuditResult, console: &mozart_core::console::Console) { - if result.total_advisory_count == 0 { - console.info("No security vulnerability advisories found."); - } else { - let advisory_word = if result.total_advisory_count == 1 { - "advisory" - } else { - "advisories" - }; - console_writeln_error!( - console, - &format!( - "Found {} security vulnerability {} affecting {} package(s).", - result.total_advisory_count, advisory_word, result.affected_package_count - ), - ); - console.info("Run \"mozart audit\" for a full list of advisories."); - } - - for pkg in &result.abandoned { - match &pkg.replacement { - Some(repl) => console_writeln_error!( - console, - &format!( - "{} ({}) is abandoned. Use {} instead.", - pkg.name, pkg.version, repl - ), - ), - None => console_writeln_error!( - console, - &format!( - "{} ({}) is abandoned. No replacement was suggested.", - pkg.name, pkg.version - ), - ), - } - } -} - #[cfg(test)] mod tests { - use super::*; - use mozart_registry::packagist::{AdvisorySource, SecurityAdvisory}; use std::collections::BTreeMap; - fn make_advisory( - id: &str, - pkg: &str, - affected: &str, - severity: Option<&str>, - ) -> SecurityAdvisory { - SecurityAdvisory { - advisory_id: id.to_string(), - package_name: pkg.to_string(), - remote_id: format!("{id}.yaml"), - title: format!("Advisory {id}"), - link: None, - cve: None, - affected_versions: affected.to_string(), - source: "FriendsOfPHP/security-advisories".to_string(), - reported_at: "2024-01-01T00:00:00+00:00".to_string(), - composer_repository: None, - severity: severity.map(|s| s.to_string()), - sources: vec![], - } - } + use super::*; + use mozart_registry::lockfile::{LockFile, LockedPackage}; - fn make_pkg(name: &str, version: &str, version_normalized: Option<&str>) -> PackageEntry { - PackageEntry { + fn make_pkg(name: &str, version: &str, version_normalized: Option<&str>) -> PackageInfo { + PackageInfo { name: name.to_string(), version: version.to_string(), version_normalized: version_normalized.map(|s| s.to_string()), - abandoned: None, + abandoned_raw: None, } } - fn make_pkg_abandoned(name: &str, version: &str, replacement: Option<&str>) -> PackageEntry { - let abandoned = match replacement { + fn make_pkg_abandoned(name: &str, version: &str, replacement: Option<&str>) -> PackageInfo { + let abandoned_raw = match replacement { Some(r) => Some(serde_json::Value::String(r.to_string())), None => Some(serde_json::Value::Bool(true)), }; - PackageEntry { + PackageInfo { name: name.to_string(), version: version.to_string(), version_normalized: None, - abandoned, + abandoned_raw, } } - fn make_console() -> mozart_core::console::Console { - mozart_core::console::Console::new(0, false, false, false, false) - } - - #[test] - fn test_filter_advisories_matching() { - let console = make_console(); - let advisory = make_advisory("PKSA-0001", "vendor/pkg", ">=1.0,<2.0", None); - let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); - all.insert("vendor/pkg".to_string(), vec![advisory]); - - let packages = vec![make_pkg("vendor/pkg", "1.5.0", Some("1.5.0.0"))]; - let result = filter_advisories(&all, &packages, &[], &console); - - assert_eq!(result.len(), 1); - assert_eq!(result["vendor/pkg"].len(), 1); - } - - #[test] - fn test_filter_advisories_not_matching() { - let console = make_console(); - let advisory = make_advisory("PKSA-0002", "vendor/pkg", ">=1.0,<2.0", None); - let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); - all.insert("vendor/pkg".to_string(), vec![advisory]); - - let packages = vec![make_pkg("vendor/pkg", "2.0.0", Some("2.0.0.0"))]; - let result = filter_advisories(&all, &packages, &[], &console); - - assert!(result.is_empty()); - } - - #[test] - fn test_filter_advisories_ignore_severity() { - let console = make_console(); - let advisory = make_advisory("PKSA-0003", "vendor/pkg", ">=1.0,<2.0", Some("low")); - let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); - all.insert("vendor/pkg".to_string(), vec![advisory]); - - let packages = vec![make_pkg("vendor/pkg", "1.5.0", Some("1.5.0.0"))]; - let result = filter_advisories(&all, &packages, &["low".to_string()], &console); - - assert!(result.is_empty()); - } - - #[test] - fn test_filter_advisories_multiple_packages() { - let console = make_console(); - let adv1 = make_advisory("PKSA-0004", "vendor/pkg1", ">=1.0,<2.0", None); - let adv2 = make_advisory("PKSA-0005", "vendor/pkg2", ">=3.0,<4.0", None); - let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); - all.insert("vendor/pkg1".to_string(), vec![adv1]); - all.insert("vendor/pkg2".to_string(), vec![adv2]); - - let packages = vec![ - make_pkg("vendor/pkg1", "1.5.0", Some("1.5.0.0")), - make_pkg("vendor/pkg2", "3.5.0", Some("3.5.0.0")), - ]; - let result = filter_advisories(&all, &packages, &[], &console); - - assert_eq!(result.len(), 2); - assert_eq!(result["vendor/pkg1"].len(), 1); - assert_eq!(result["vendor/pkg2"].len(), 1); - } - - #[test] - fn test_filter_advisories_complex_constraint() { - let console = make_console(); - // OR constraint: >=1.0,<1.5|>=2.0,<2.3 - let advisory = make_advisory("PKSA-0006", "vendor/pkg", ">=1.0,<1.5|>=2.0,<2.3", None); - let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); - all.insert("vendor/pkg".to_string(), vec![advisory]); - - // 2.1.0 is in [2.0, 2.3) so should match - let packages = vec![make_pkg("vendor/pkg", "2.1.0", Some("2.1.0.0"))]; - let result = filter_advisories(&all, &packages, &[], &console); - - assert_eq!(result.len(), 1); - } - - #[test] - fn test_filter_advisories_no_advisories() { - let console = make_console(); - let all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); - let packages = vec![make_pkg("vendor/pkg", "1.5.0", Some("1.5.0.0"))]; - let result = filter_advisories(&all, &packages, &[], &console); - assert!(result.is_empty()); - } - - #[test] - fn test_detect_abandoned_true() { - let packages = vec![make_pkg_abandoned("old/pkg", "1.0.0", None)]; - let result = detect_abandoned(&packages); - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "old/pkg"); - assert!(result[0].replacement.is_none()); - } - - #[test] - fn test_detect_abandoned_with_replacement() { - let packages = vec![make_pkg_abandoned("old/pkg", "1.0.0", Some("new/pkg"))]; - let result = detect_abandoned(&packages); - assert_eq!(result.len(), 1); - assert_eq!(result[0].replacement.as_deref(), Some("new/pkg")); - } - - #[test] - fn test_detect_abandoned_not_abandoned() { - let packages = vec![make_pkg("active/pkg", "1.0.0", None)]; - let result = detect_abandoned(&packages); - assert!(result.is_empty()); - } - - #[test] - fn test_detect_abandoned_mixed() { - let packages = vec![ - make_pkg("active/pkg", "1.0.0", None), - make_pkg_abandoned("old/pkg", "2.0.0", Some("new/pkg")), - make_pkg("another/active", "3.0.0", None), - make_pkg_abandoned("dead/pkg", "1.0.0", None), - ]; - let result = detect_abandoned(&packages); - assert_eq!(result.len(), 2); - assert!(result.iter().any(|p| p.name == "old/pkg")); - assert!(result.iter().any(|p| p.name == "dead/pkg")); - } - #[test] fn test_load_installed_packages() { use tempfile::tempdir; @@ -919,7 +299,6 @@ mod tests { #[test] fn test_load_locked_packages() { - use mozart_registry::lockfile::{LockFile, LockedPackage}; use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -975,7 +354,6 @@ mod tests { #[test] fn test_load_locked_packages_no_dev() { - use mozart_registry::lockfile::{LockFile, LockedPackage}; use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -1047,12 +425,10 @@ mod tests { lock.write_to_file(&working_dir.join("composer.lock")) .unwrap(); - // With --no-dev: only prod let packages = load_locked_packages(working_dir, true).unwrap(); assert_eq!(packages.len(), 1); assert_eq!(packages[0].name, "psr/log"); - // Without --no-dev: both let packages_all = load_locked_packages(working_dir, false).unwrap(); assert_eq!(packages_all.len(), 2); } @@ -1069,89 +445,47 @@ mod tests { } #[test] - fn test_render_json_structure() { - let advisory = make_advisory("PKSA-0001", "vendor/pkg", ">=1.0,<2.0", Some("high")); - let mut advisories: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); - advisories.insert( - "vendor/pkg".to_string(), - vec![MatchedAdvisory { - advisory, - installed_version: "1.5.0".to_string(), - }], - ); + fn test_package_info_abandoned() { + let pkg = make_pkg_abandoned("old/pkg", "1.0.0", None); + assert!(pkg.is_abandoned()); + assert!(pkg.replacement_package().is_none()); - let abandoned = vec![AbandonedPackage { - name: "old/pkg".to_string(), - version: "1.0.0".to_string(), - replacement: Some("new/pkg".to_string()), - }]; + let pkg_with_repl = make_pkg_abandoned("old/pkg", "1.0.0", Some("new/pkg")); + assert!(pkg_with_repl.is_abandoned()); + assert_eq!(pkg_with_repl.replacement_package(), Some("new/pkg")); - let result = AuditResult { - affected_package_count: 1, - total_advisory_count: 1, - advisories, - abandoned, - }; - - // Should not panic - let console = make_console(); - render_json(&result, &console).unwrap(); - } - - #[test] - fn test_render_json_empty() { - let console = make_console(); - let result = AuditResult { - advisories: BTreeMap::new(), - abandoned: vec![], - affected_package_count: 0, - total_advisory_count: 0, - }; - render_json(&result, &console).unwrap(); + let active_pkg = make_pkg("active/pkg", "1.0.0", None); + assert!(!active_pkg.is_abandoned()); } #[test] fn test_invalid_format() { - // We test the validation logic directly let format = "xml"; - let valid = - format == "table" || format == "plain" || format == "json" || format == "summary"; - assert!(!valid); + assert!(AuditFormat::from_str(format).is_none()); } #[test] - fn test_invalid_abandoned_value() { - let value = "maybe"; - let valid = value == "ignore" || value == "report" || value == "fail"; - assert!(!valid); + fn test_valid_formats() { + for fmt in &["table", "plain", "json", "summary"] { + assert!( + AuditFormat::from_str(fmt).is_some(), + "format {fmt} should be valid" + ); + } } #[test] - fn test_valid_formats() { - for format in &["table", "plain", "json", "summary"] { - let valid = *format == "table" - || *format == "plain" - || *format == "json" - || *format == "summary"; - assert!(valid, "format {} should be valid", format); - } + fn test_invalid_abandoned_value() { + assert!(AbandonedHandling::from_str("maybe").is_none()); } #[test] fn test_valid_abandoned_values() { for value in &["ignore", "report", "fail"] { - let valid = *value == "ignore" || *value == "report" || *value == "fail"; - assert!(valid, "abandoned value {} should be valid", value); + assert!( + AbandonedHandling::from_str(value).is_some(), + "abandoned value {value} should be valid" + ); } } - - #[test] - fn test_advisory_source_fields() { - let src = AdvisorySource { - name: "FriendsOfPHP/security-advisories".to_string(), - remote_id: "monolog/monolog/2017-11-13-1.yaml".to_string(), - }; - assert_eq!(src.name, "FriendsOfPHP/security-advisories"); - assert_eq!(src.remote_id, "monolog/monolog/2017-11-13-1.yaml"); - } } diff --git a/crates/mozart/src/commands/base_config.rs b/crates/mozart/src/commands/base_config.rs index be663d5..bfed161 100644 --- a/crates/mozart/src/commands/base_config.rs +++ b/crates/mozart/src/commands/base_config.rs @@ -11,11 +11,7 @@ pub(crate) struct BaseConfigContext { } impl BaseConfigContext { - pub fn initialize( - global: bool, - file: Option<&str>, - cli: &super::Cli, - ) -> anyhow::Result<Self> { + pub fn initialize(global: bool, file: Option<&str>, cli: &super::Cli) -> anyhow::Result<Self> { if global && file.is_some() { anyhow::bail!("--file and --global can not be combined"); } diff --git a/crates/mozart/src/commands/repository.rs b/crates/mozart/src/commands/repository.rs index 318450a..27c822c 100644 --- a/crates/mozart/src/commands/repository.rs +++ b/crates/mozart/src/commands/repository.rs @@ -89,10 +89,7 @@ fn list_repositories( let mut display_repos = repos; if !packagist_present { let mut m = serde_json::Map::new(); - m.insert( - "packagist.org".to_string(), - serde_json::Value::Bool(false), - ); + m.insert("packagist.org".to_string(), serde_json::Value::Bool(false)); display_repos.push(serde_json::Value::Object(m)); } @@ -119,10 +116,7 @@ fn list_repositories( .get("type") .and_then(|t| t.as_str()) .unwrap_or("unknown"); - let url = entry - .get("url") - .map(render_value) - .unwrap_or_default(); + let url = entry.get("url").map(render_value).unwrap_or_default(); console_writeln!(console, &format!("[{name}] {repo_type} {url}")); } @@ -139,12 +133,15 @@ fn host_ends_with_packagist_org(url: &str) -> bool { fn execute_add(ctx: &BaseConfigContext, args: &RepositoryArgs) -> anyhow::Result<()> { let name = args.name.as_deref().ok_or_else(|| { - anyhow!("You must pass a repository name. Example: mozart repo add foo vcs https://example.org") + anyhow!( + "You must pass a repository name. Example: mozart repo add foo vcs https://example.org" + ) })?; - let arg1 = args.arg1.as_deref().ok_or_else(|| { - anyhow!("You must pass the type and a url, or a JSON string.") - })?; + let arg1 = args + .arg1 + .as_deref() + .ok_or_else(|| anyhow!("You must pass the type and a url, or a JSON string."))?; // Mirror Composer's `Preg::isMatch('{^\s*\{}', $arg1)` check. let repo_config = if arg1.trim_start().starts_with('{') { @@ -186,8 +183,11 @@ fn execute_remove(ctx: &BaseConfigContext, args: &RepositoryArgs) -> anyhow::Res // Removing packagist means disabling it (Composer behaviour). // Default append=false so the disable entry goes to the front when // the user didn't pass --append. - ctx.config_source - .add_repository("packagist.org", &serde_json::Value::Bool(false), args.append)?; + ctx.config_source.add_repository( + "packagist.org", + &serde_json::Value::Bool(false), + args.append, + )?; } Ok(()) @@ -251,12 +251,17 @@ fn execute_disable(ctx: &BaseConfigContext, args: &RepositoryArgs) -> anyhow::Re .ok_or_else(|| anyhow!("Usage: mozart repo disable packagist.org"))?; if name == "packagist.org" || name == "packagist" { - ctx.config_source - .add_repository("packagist.org", &serde_json::Value::Bool(false), args.append)?; + ctx.config_source.add_repository( + "packagist.org", + &serde_json::Value::Bool(false), + args.append, + )?; return Ok(()); } - anyhow::bail!("Only packagist.org can be enabled/disabled using this command. Use add/remove for other repositories."); + anyhow::bail!( + "Only packagist.org can be enabled/disabled using this command. Use add/remove for other repositories." + ); } fn execute_enable(ctx: &BaseConfigContext, args: &RepositoryArgs) -> anyhow::Result<()> { |
