#![cfg_attr(coverage_nightly, coverage(off))]
use super::types::CachedMetric;
use std::io::Read;
use std::path::Path;
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
pub(crate) fn run_with_timeout(
cmd: &mut Command,
limit: Duration,
) -> std::io::Result<Option<Output>> {
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let mut stdout = child.stdout.take();
let mut stderr = child.stderr.take();
let drain = |pipe: Option<&mut dyn Read>| -> Vec<u8> {
let mut buf = Vec::new();
if let Some(p) = pipe {
let _ = p.read_to_end(&mut buf);
}
buf
};
let out_handle = std::thread::spawn(move || drain(stdout.as_mut().map(|p| p as &mut dyn Read)));
let err_handle = std::thread::spawn(move || drain(stderr.as_mut().map(|p| p as &mut dyn Read)));
let deadline = Instant::now() + limit;
let status = loop {
match child.try_wait()? {
Some(status) => break status,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Ok(None);
}
None => std::thread::sleep(Duration::from_millis(50)),
}
};
Ok(Some(Output {
status,
stdout: out_handle.join().unwrap_or_default(),
stderr: err_handle.join().unwrap_or_default(),
}))
}
pub(crate) enum DenyRefresh {
Recorded(Box<CachedMetric>),
ToolMissing,
Failed(String),
}
pub(crate) const DENY_STATUS_PATH: &str = ".pmat-metrics/deny-status.json";
pub(crate) const CARGO_DENY_INSTALL_HINT: &str = "cargo install cargo-deny --locked";
fn is_missing_subcommand(stderr: &str) -> bool {
stderr.contains("no such command") || stderr.contains("no such subcommand")
}
fn count_vulnerabilities(output: &str) -> u64 {
output.matches("error[vulnerability]").count() as u64
}
fn failure_summary(stdout: &str, stderr: &str) -> String {
let lines = stdout.lines().chain(stderr.lines()).map(str::trim);
let mut first_error = None;
for line in lines {
if line.contains("FAILED") && line.contains(" ok") {
return line.to_string();
}
if first_error.is_none() && line.starts_with("error") {
first_error = Some(line.to_string());
}
}
first_error.unwrap_or_else(|| "cargo deny check failed".to_string())
}
const DENY_TIMEOUT: Duration = Duration::from_secs(120);
pub(crate) fn refresh_deny_cache(project_path: &Path) -> DenyRefresh {
let output = match run_with_timeout(
Command::new("cargo")
.args(["deny", "check"])
.current_dir(project_path),
DENY_TIMEOUT,
) {
Ok(Some(output)) => output,
Ok(None) => {
return DenyRefresh::Failed(format!(
"'cargo deny check' did not finish within {}s (advisory-DB fetch may be stalled; \
check connectivity or run it manually)",
DENY_TIMEOUT.as_secs()
))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return DenyRefresh::ToolMissing,
Err(e) => return DenyRefresh::Failed(format!("could not run 'cargo deny check': {e}")),
};
let stderr = String::from_utf8_lossy(&output.stderr);
if is_missing_subcommand(&stderr) {
return DenyRefresh::ToolMissing;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let passed = output.status.success();
let count = count_vulnerabilities(&stderr) + count_vulnerabilities(&stdout);
let value = serde_json::json!({
"passed": passed,
"vulnerability_count": count,
"summary": if passed { String::new() } else { failure_summary(&stdout, &stderr) },
"timestamp": chrono::Utc::now().to_rfc3339(),
"source": "pmat work complete (cargo deny check)",
});
if let Err(e) = write_status(project_path, &value) {
return DenyRefresh::Failed(format!("could not write {DENY_STATUS_PATH}: {e}"));
}
DenyRefresh::Recorded(Box::new(CachedMetric {
value,
age_minutes: 0,
is_stale_warn: false,
is_stale_block: false,
}))
}
fn write_status(project_path: &Path, value: &serde_json::Value) -> std::io::Result<()> {
write_json_status(project_path, DENY_STATUS_PATH, value)
}
pub(crate) fn write_json_status(
project_path: &Path,
relative: &str,
value: &serde_json::Value,
) -> std::io::Result<()> {
let path = project_path.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(value)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_subcommand_detected_from_cargo_message() {
assert!(is_missing_subcommand("error: no such command: `deny`"));
assert!(is_missing_subcommand("error: no such subcommand: `deny`"));
}
#[test]
fn missing_subcommand_not_confused_with_a_real_failure() {
assert!(!is_missing_subcommand(
"error[vulnerability]: RUSTSEC-2024-0001"
));
assert!(!is_missing_subcommand("advisories FAILED"));
assert!(!is_missing_subcommand(""));
}
#[test]
fn vulnerabilities_counted_per_diagnostic() {
let out = "error[vulnerability]: RUSTSEC-2024-0001\n\
error[vulnerability]: RUSTSEC-2024-0002\n\
warning[unmaintained]: RUSTSEC-2024-0003\n";
assert_eq!(count_vulnerabilities(out), 2);
}
#[test]
fn failure_summary_prefers_the_per_check_line() {
let stdout = "advisories ok, bans ok, licenses FAILED, sources ok\n";
let stderr = "error[unlicensed]: dogfood = 0.1.0 is unlicensed\n";
assert_eq!(
failure_summary(stdout, stderr),
"advisories ok, bans ok, licenses FAILED, sources ok"
);
}
#[test]
fn failure_summary_falls_back_to_the_first_error() {
let stderr =
"error[vulnerability]: RUSTSEC-2024-0001\nerror[vulnerability]: RUSTSEC-2024-0002\n";
assert_eq!(
failure_summary("", stderr),
"error[vulnerability]: RUSTSEC-2024-0001"
);
}
#[test]
fn failure_summary_always_says_something() {
assert_eq!(failure_summary("", ""), "cargo deny check failed");
}
#[test]
fn clean_output_counts_zero() {
assert_eq!(count_vulnerabilities("advisories ok\nbans ok\n"), 0);
assert_eq!(count_vulnerabilities(""), 0);
}
#[test]
fn write_status_creates_missing_metrics_dir() {
let dir = tempfile::tempdir().unwrap();
let value = serde_json::json!({ "passed": true, "vulnerability_count": 0 });
write_status(dir.path(), &value).unwrap();
let written = std::fs::read_to_string(dir.path().join(DENY_STATUS_PATH)).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed["passed"], serde_json::json!(true));
assert_eq!(parsed["vulnerability_count"], serde_json::json!(0));
}
#[test]
fn write_status_overwrites_a_stale_verdict() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".pmat-metrics")).unwrap();
std::fs::write(
dir.path().join(DENY_STATUS_PATH),
r#"{"passed": true, "timestamp": "2026-07-01T21:02:57Z"}"#,
)
.unwrap();
write_status(
dir.path(),
&serde_json::json!({ "passed": false, "vulnerability_count": 3 }),
)
.unwrap();
let written = std::fs::read_to_string(dir.path().join(DENY_STATUS_PATH)).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed["passed"], serde_json::json!(false));
assert_eq!(parsed["vulnerability_count"], serde_json::json!(3));
}
}