aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-08 23:22:34 +0900
committernsfisis <nsfisis@gmail.com>2026-05-08 23:22:34 +0900
commitd770693bac655da4a21144b4cae7592536fecb8b (patch)
tree5d29005db018416c03a14c9d367f412b8148650c /crates
parenteeb845f2f8629e3ccfb8ee1a1ec0602c0f186427 (diff)
downloadphp-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.rs315
-rw-r--r--crates/mozart-core/src/config_source.rs33
-rw-r--r--crates/mozart-core/src/lib.rs1
-rw-r--r--crates/mozart-registry/src/advisory.rs739
-rw-r--r--crates/mozart-registry/src/lib.rs1
-rw-r--r--crates/mozart-registry/src/repository/mod.rs125
-rw-r--r--crates/mozart/src/commands.rs2
-rw-r--r--crates/mozart/src/commands/audit.rs892
-rw-r--r--crates/mozart/src/commands/base_config.rs6
-rw-r--r--crates/mozart/src/commands/repository.rs39
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<()> {