use std::path::{Path, PathBuf};
use crate::error::AxiomError;
use crate::host_tools::{HostCommandResult, HostCommandSpec, run_host_command};
use crate::models::{
DependencyAuditStatus, DependencyAuditSummary, DependencyInventorySummary,
ReleaseSecurityAuditMode, SecurityAuditCheck,
};
use crate::text::{OutputTrimMode, first_non_empty_output, truncate_text};
#[derive(Debug, Clone, PartialEq, Eq)]
enum CargoAuditProbe {
Available { tool_version: Option<String> },
Missing,
HostToolsDisabled { reason: String },
}
pub fn resolve_security_audit_mode(
raw: Option<&str>,
) -> Result<ReleaseSecurityAuditMode, AxiomError> {
match raw
.unwrap_or("offline")
.trim()
.to_ascii_lowercase()
.as_str()
{
"offline" => Ok(ReleaseSecurityAuditMode::Offline),
"strict" => Ok(ReleaseSecurityAuditMode::Strict),
other => Err(AxiomError::Validation(format!(
"invalid security audit mode: {other} (expected offline|strict)"
))),
}
}
fn cargo_audit_args(mode: ReleaseSecurityAuditMode, advisory_db_path: &Path) -> Vec<String> {
let mut args = vec![
"audit".to_string(),
"--json".to_string(),
"--db".to_string(),
advisory_db_path.display().to_string(),
];
match mode {
ReleaseSecurityAuditMode::Offline => {
args.push("--no-fetch".to_string());
args.push("--stale".to_string());
}
ReleaseSecurityAuditMode::Strict => {}
}
args
}
pub fn dependency_inventory_summary(workspace_dir: &Path) -> DependencyInventorySummary {
let lockfile_present = workspace_dir.join("Cargo.lock").exists();
let package_count = match run_host_command(
HostCommandSpec::new(
"security_audit:inventory",
"cargo",
&["metadata", "--format-version", "1"],
)
.with_current_dir(workspace_dir),
) {
HostCommandResult::Completed {
success: true,
stdout,
..
} => serde_json::from_str::<serde_json::Value>(&stdout)
.ok()
.and_then(|value| {
value
.get("packages")
.and_then(|v| v.as_array())
.map(Vec::len)
})
.unwrap_or(0),
_ => 0,
};
DependencyInventorySummary {
lockfile_present,
package_count,
}
}
pub fn dependency_audit_summary(
workspace_dir: &Path,
mode: ReleaseSecurityAuditMode,
) -> DependencyAuditSummary {
let advisory_db_path = resolve_advisory_db_path(workspace_dir);
let tool_version = match probe_cargo_audit_tool() {
CargoAuditProbe::HostToolsDisabled { reason } => {
return DependencyAuditSummary {
tool: "cargo-audit".to_string(),
mode,
available: false,
executed: false,
status: DependencyAuditStatus::HostToolsDisabled,
advisories_found: 0,
tool_version: None,
output_excerpt: Some(format_audit_output_excerpt(&advisory_db_path, Some(reason))),
};
}
CargoAuditProbe::Missing => {
return DependencyAuditSummary {
tool: "cargo-audit".to_string(),
mode,
available: false,
executed: false,
status: DependencyAuditStatus::ToolMissing,
advisories_found: 0,
tool_version: None,
output_excerpt: None,
};
}
CargoAuditProbe::Available { tool_version } => tool_version,
};
if let Err(reason) = prepare_advisory_db_directory(&advisory_db_path, mode) {
return DependencyAuditSummary {
tool: "cargo-audit".to_string(),
mode,
available: true,
executed: false,
status: DependencyAuditStatus::Error,
advisories_found: 0,
tool_version,
output_excerpt: Some(format_audit_output_excerpt(&advisory_db_path, Some(reason))),
};
}
let audit_args = cargo_audit_args(mode, &advisory_db_path);
let audit_arg_refs = audit_args.iter().map(String::as_str).collect::<Vec<_>>();
match run_host_command(
HostCommandSpec::new("security_audit:dependency_audit", "cargo", &audit_arg_refs)
.with_current_dir(workspace_dir),
) {
HostCommandResult::Completed {
success,
stdout,
stderr,
} => {
let advisories = parse_cargo_audit_advisory_count(&stdout)
.or_else(|| parse_cargo_audit_advisory_count(&stderr))
.unwrap_or(0);
let status = if advisories > 0 {
DependencyAuditStatus::VulnerabilitiesFound
} else if success {
DependencyAuditStatus::Passed
} else {
DependencyAuditStatus::Error
};
let output_excerpt = Some(format_audit_output_excerpt(
&advisory_db_path,
first_non_empty_output(&stdout, &stderr, OutputTrimMode::Trim),
));
DependencyAuditSummary {
tool: "cargo-audit".to_string(),
mode,
available: true,
executed: true,
status,
advisories_found: advisories,
tool_version,
output_excerpt,
}
}
HostCommandResult::SpawnError { error } => DependencyAuditSummary {
tool: "cargo-audit".to_string(),
mode,
available: true,
executed: true,
status: DependencyAuditStatus::Error,
advisories_found: 0,
tool_version,
output_excerpt: Some(format_audit_output_excerpt(&advisory_db_path, Some(error))),
},
HostCommandResult::Blocked { reason } => DependencyAuditSummary {
tool: "cargo-audit".to_string(),
mode,
available: false,
executed: false,
status: DependencyAuditStatus::HostToolsDisabled,
advisories_found: 0,
tool_version: None,
output_excerpt: Some(format_audit_output_excerpt(&advisory_db_path, Some(reason))),
},
}
}
pub fn build_security_audit_checks(
inventory: &DependencyInventorySummary,
dependency_audit: &DependencyAuditSummary,
) -> Vec<SecurityAuditCheck> {
vec![
SecurityAuditCheck {
name: "lockfile_present".to_string(),
passed: inventory.lockfile_present,
details: if inventory.lockfile_present {
"Cargo.lock detected".to_string()
} else {
"Cargo.lock missing".to_string()
},
},
SecurityAuditCheck {
name: "dependency_inventory".to_string(),
passed: inventory.package_count > 0,
details: format!("packages={}", inventory.package_count),
},
SecurityAuditCheck {
name: "cargo_audit_tool".to_string(),
passed: dependency_audit.available,
details: if dependency_audit.available {
format!(
"cargo-audit available ({})",
dependency_audit
.tool_version
.as_deref()
.unwrap_or("unknown-version")
)
} else {
"cargo-audit not installed".to_string()
},
},
SecurityAuditCheck {
name: "dependency_vulnerabilities".to_string(),
passed: dependency_audit.available
&& dependency_audit.executed
&& dependency_audit.advisories_found == 0
&& dependency_audit.status == DependencyAuditStatus::Passed,
details: format!(
"mode={} status={} advisories_found={}",
dependency_audit.mode, dependency_audit.status, dependency_audit.advisories_found
),
},
]
}
fn probe_cargo_audit_tool() -> CargoAuditProbe {
match run_host_command(HostCommandSpec::new(
"security_audit:probe_cargo_audit_tool",
"cargo",
&["audit", "-V"],
)) {
HostCommandResult::Completed {
success,
stdout,
stderr,
} => {
let stdout = stdout.trim().to_string();
if success {
let tool_version = if stdout.is_empty() {
None
} else {
Some(stdout)
};
return CargoAuditProbe::Available { tool_version };
}
if stderr.contains("no such command") {
return CargoAuditProbe::Missing;
}
let tool_version = if stdout.is_empty() {
None
} else {
Some(stdout)
};
CargoAuditProbe::Available { tool_version }
}
HostCommandResult::SpawnError { .. } => CargoAuditProbe::Missing,
HostCommandResult::Blocked { reason } => CargoAuditProbe::HostToolsDisabled { reason },
}
}
fn parse_cargo_audit_advisory_count(raw: &str) -> Option<usize> {
let value = serde_json::from_str::<serde_json::Value>(raw).ok()?;
let pointers = [
"/vulnerabilities/counts/total",
"/vulnerabilities/counts/found",
"/vulnerabilities/found",
];
for pointer in pointers {
if let Some(count) = value
.pointer(pointer)
.and_then(serde_json::Value::as_u64)
.map(saturating_u64_to_usize)
{
return Some(count);
}
}
if let Some(items) = value
.pointer("/vulnerabilities/list")
.and_then(|v| v.as_array())
{
return Some(items.len());
}
if let Some(items) = value.pointer("/vulnerabilities").and_then(|v| v.as_array()) {
return Some(items.len());
}
Some(0)
}
fn resolve_advisory_db_path(workspace_dir: &Path) -> PathBuf {
if let Some(path) = std::env::var_os("AXIOMSYNC_ADVISORY_DB")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
{
return path;
}
workspace_dir.join(".axiomsync").join("advisory-db")
}
fn prepare_advisory_db_directory(
advisory_db_path: &Path,
mode: ReleaseSecurityAuditMode,
) -> Result<(), String> {
let Some(parent) = advisory_db_path.parent() else {
return Err("invalid advisory db path without parent".to_string());
};
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create advisory db parent: {err}"))?;
if advisory_db_path.exists() {
let metadata = std::fs::metadata(advisory_db_path)
.map_err(|err| format!("failed to read advisory db metadata: {err}"))?;
if metadata.is_file() {
match mode {
ReleaseSecurityAuditMode::Strict => std::fs::remove_file(advisory_db_path)
.map_err(|err| format!("failed to reset advisory db file path: {err}"))?,
ReleaseSecurityAuditMode::Offline => {
return Err(
"offline mode does not fetch advisory data; run strict once to bootstrap advisory-db"
.to_string(),
)
}
}
}
}
if advisory_db_path.exists() && advisory_db_path.is_dir() {
let has_entries = std::fs::read_dir(advisory_db_path)
.ok()
.and_then(|mut entries| entries.next())
.is_some();
let has_git_dir = advisory_db_path.join(".git").is_dir();
if has_entries && !has_git_dir {
match mode {
ReleaseSecurityAuditMode::Strict => {
std::fs::remove_dir_all(advisory_db_path).map_err(
|err| format!("failed to reset invalid advisory db directory: {err}"),
)?
}
ReleaseSecurityAuditMode::Offline => {
return Err(
"offline mode requires a bootstrapped advisory-db metadata directory; run strict once to initialize advisory-db"
.to_string(),
)
}
}
}
}
if matches!(mode, ReleaseSecurityAuditMode::Offline) && !advisory_db_path.join(".git").is_dir()
{
return Err(
"offline mode requires a bootstrapped advisory-db metadata directory; run strict once to initialize advisory-db"
.to_string(),
);
}
Ok(())
}
fn format_audit_output_excerpt(advisory_db_path: &Path, output: Option<String>) -> String {
let context = format!("advisory_db={}", advisory_db_path.display());
match output {
Some(text) if !text.trim().is_empty() => {
truncate_text(&format!("{context}; {}", text.trim()), 1200)
}
_ => context,
}
}
fn saturating_u64_to_usize(value: u64) -> usize {
usize::try_from(value).unwrap_or(usize::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_cargo_audit_advisory_count_supports_counts_total_shape() {
let payload = r#"{"vulnerabilities":{"counts":{"total":3}}}"#;
assert_eq!(parse_cargo_audit_advisory_count(payload), Some(3));
}
#[test]
fn parse_cargo_audit_advisory_count_supports_list_shape() {
let payload = r#"{"vulnerabilities":{"list":[{"id":"A"},{"id":"B"}]}}"#;
assert_eq!(parse_cargo_audit_advisory_count(payload), Some(2));
}
#[test]
fn parse_cargo_audit_advisory_count_defaults_to_zero_for_known_json_without_matches() {
let payload = r#"{"vulnerabilities":{"counts":{"unknown":1}}}"#;
assert_eq!(parse_cargo_audit_advisory_count(payload), Some(0));
}
#[test]
fn build_security_audit_checks_flags_fail_when_audit_missing() {
let inventory = DependencyInventorySummary {
lockfile_present: true,
package_count: 12,
};
let audit = DependencyAuditSummary {
tool: "cargo-audit".to_string(),
mode: ReleaseSecurityAuditMode::Offline,
available: false,
executed: false,
status: DependencyAuditStatus::ToolMissing,
advisories_found: 0,
tool_version: None,
output_excerpt: None,
};
let checks = build_security_audit_checks(&inventory, &audit);
assert!(checks.iter().any(|check| !check.passed));
assert_eq!(checks.len(), 4);
}
#[test]
fn dependency_audit_summary_roundtrips_mode_and_status_contract_values() {
let summary = DependencyAuditSummary {
tool: "cargo-audit".to_string(),
mode: ReleaseSecurityAuditMode::Strict,
available: true,
executed: true,
status: DependencyAuditStatus::VulnerabilitiesFound,
advisories_found: 2,
tool_version: Some("cargo-audit 1.0.0".to_string()),
output_excerpt: Some("advisory_db=/tmp/db; found advisories".to_string()),
};
let json = serde_json::to_value(&summary).expect("serialize dependency audit summary");
assert_eq!(json["mode"], "strict");
assert_eq!(json["status"], "vulnerabilities_found");
let roundtrip: DependencyAuditSummary =
serde_json::from_value(json).expect("deserialize dependency audit summary");
assert_eq!(roundtrip.mode, ReleaseSecurityAuditMode::Strict);
assert_eq!(
roundtrip.status,
DependencyAuditStatus::VulnerabilitiesFound
);
}
#[test]
fn resolve_security_audit_mode_supports_offline_and_strict() {
assert_eq!(
resolve_security_audit_mode(Some("offline")).expect("offline"),
ReleaseSecurityAuditMode::Offline
);
assert_eq!(
resolve_security_audit_mode(Some("strict")).expect("strict"),
ReleaseSecurityAuditMode::Strict
);
}
#[test]
fn resolve_security_audit_mode_rejects_unknown_value() {
let err = resolve_security_audit_mode(Some("fast")).expect_err("must reject");
assert!(err.to_string().contains("invalid security audit mode"));
}
#[test]
fn cargo_audit_args_include_db_and_mode_flags() {
let db = Path::new("/tmp/advisory-db");
let strict = cargo_audit_args(ReleaseSecurityAuditMode::Strict, db);
assert_eq!(
strict,
vec![
"audit".to_string(),
"--json".to_string(),
"--db".to_string(),
"/tmp/advisory-db".to_string()
]
);
let offline = cargo_audit_args(ReleaseSecurityAuditMode::Offline, db);
assert_eq!(
offline,
vec![
"audit".to_string(),
"--json".to_string(),
"--db".to_string(),
"/tmp/advisory-db".to_string(),
"--no-fetch".to_string(),
"--stale".to_string()
]
);
}
#[test]
fn resolve_advisory_db_path_defaults_to_workspace_scoped_advisory_db() {
let workspace = Path::new("/tmp/axiomsync-workspace");
let path = resolve_advisory_db_path(workspace);
assert_eq!(path, workspace.join(".axiomsync").join("advisory-db"));
}
#[test]
fn format_audit_output_excerpt_includes_db_context() {
let db = Path::new("/tmp/advisory-db");
let excerpt = format_audit_output_excerpt(db, Some("failure".to_string()));
assert!(excerpt.contains("advisory_db=/tmp/advisory-db"));
assert!(excerpt.contains("failure"));
}
#[test]
fn prepare_advisory_db_directory_offline_requires_bootstrapped_advisory_db() {
let temp = tempfile::tempdir().expect("tempdir");
let advisory_db = temp.path().join("advisory-db");
let err = prepare_advisory_db_directory(&advisory_db, ReleaseSecurityAuditMode::Offline)
.expect_err("offline must fail without bootstrapped advisory db");
assert!(err.contains("offline mode requires a bootstrapped advisory-db"));
}
#[test]
fn prepare_advisory_db_directory_strict_resets_non_git_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let advisory_db = temp.path().join("advisory-db");
std::fs::create_dir_all(&advisory_db).expect("create advisory dir");
std::fs::write(advisory_db.join("junk.txt"), "junk").expect("write junk");
prepare_advisory_db_directory(&advisory_db, ReleaseSecurityAuditMode::Strict)
.expect("strict should reset invalid advisory db dir");
assert!(!advisory_db.exists());
}
#[test]
fn dependency_audit_summary_strict_attempts_recovery_from_non_git_advisory_db_directory() {
if !matches!(probe_cargo_audit_tool(), CargoAuditProbe::Available { .. }) {
return;
}
let temp = tempfile::tempdir().expect("tempdir");
let advisory_db = temp.path().join(".axiomsync").join("advisory-db");
std::fs::create_dir_all(&advisory_db).expect("create advisory dir");
std::fs::write(advisory_db.join("junk.txt"), "junk").expect("write junk");
let summary = dependency_audit_summary(temp.path(), ReleaseSecurityAuditMode::Strict);
assert!(summary.executed);
assert!(
summary
.output_excerpt
.as_deref()
.unwrap_or("")
.contains("advisory_db=")
);
assert!(
!summary
.output_excerpt
.as_deref()
.unwrap_or("")
.contains("non-empty but not initialized as a git repository")
);
}
}