use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc;
use std::time::Duration;
use std::time::SystemTime;
pub const POLICY_FILE: &str = "deny.toml";
pub const OPTIONAL_CHECKS: &[&str] = &["bans", "licenses", "sources"];
pub const STALE_AFTER_DAYS: u64 = 7;
const CONFIG_ONLY_CODES: &[&str] = &[
"advisory-ignored",
"advisory-not-detected",
"unknown-advisory",
"yanked-not-detected",
"license-not-encountered",
"license-exception-not-encountered",
"unmatched-source",
"unmatched-organization",
"unmatched-skip",
"unmatched-skip-root",
"unmatched-path-bypass",
"unmatched-glob",
"unused-wrapper",
"unmatched-wrapper",
"no-license-field",
"deprecated",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
impl Severity {
pub const fn label(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Critical => "critical",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub id: String,
pub code: String,
pub package: String,
pub title: String,
pub severity: Severity,
pub waived: bool,
pub blocking: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Evaluation {
NoPolicy,
AuditorMissing { checks: Vec<String> },
DatabaseMissing { checks: Vec<String> },
Audited {
findings: Vec<Finding>,
checks: Vec<String>,
db_age_days: Option<u64>,
auditor: String,
},
Unavailable { reason: String, checks: Vec<String> },
}
pub fn policy_checks(policy: &str) -> Vec<String> {
let declared = declared_sections(policy);
let mut checks = vec!["advisories".to_owned()];
checks.extend(
OPTIONAL_CHECKS
.iter()
.filter(|section| declared.iter().any(|key| key == *section))
.map(|section| (*section).to_owned()),
);
checks
}
pub fn derivations_disagree(policy: &str) -> Vec<String> {
let parsed = declared_sections(policy);
OPTIONAL_CHECKS
.iter()
.filter(|section| {
let by_parse = parsed.iter().any(|key| key == *section);
let by_scan = policy
.lines()
.any(|line| line_declares(code_of(line), section));
by_parse != by_scan
})
.map(|section| (*section).to_owned())
.collect()
}
fn declared_sections(policy: &str) -> Vec<String> {
if let Ok(table) = policy.parse::<toml::Table>() {
return table.keys().cloned().collect();
}
OPTIONAL_CHECKS
.iter()
.filter(|section| {
policy
.lines()
.any(|line| line_declares(code_of(line), section))
})
.map(|section| (*section).to_owned())
.collect()
}
fn line_declares(code: &str, section: &str) -> bool {
let body = code
.strip_prefix("[[")
.or_else(|| code.strip_prefix('['))
.map_or(code, ascii_trim_start);
let body = body.strip_prefix(['"', '\'']).unwrap_or(body);
let Some(rest) = body.strip_prefix(section) else {
return false;
};
let rest = rest.strip_prefix(['"', '\'']).unwrap_or(rest);
matches!(ascii_trim_start(rest).chars().next(), Some(']' | '.' | '='))
}
fn code_of(line: &str) -> &str {
let line = ascii_trim(line);
if line.starts_with('#') {
return "";
}
ascii_trim(line.split('#').next().unwrap_or(""))
}
fn ascii_trim(text: &str) -> &str {
text.trim_matches(|c: char| c.is_ascii_whitespace())
}
fn ascii_trim_start(text: &str) -> &str {
text.trim_start_matches(|c: char| c.is_ascii_whitespace())
}
pub fn policy_db_path(policy: &str) -> Option<String> {
let value = policy
.parse::<toml::Table>()
.ok()?
.get("advisories")?
.get("db-path")?
.as_str()?
.to_owned();
(!value.is_empty()).then(|| expand_path(&value))
}
fn expand_path(path: &str) -> String {
let home =
directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_string_lossy().into_owned());
let cargo_home = crate::upgrade::cargo_home().map(|dir| dir.to_string_lossy().into_owned());
expand_path_with(path, home.as_deref(), cargo_home.as_deref())
}
pub fn expand_path_with(path: &str, home: Option<&str>, cargo_home: Option<&str>) -> String {
for (prefix, value) in [
("${CARGO_HOME}", cargo_home),
("$CARGO_HOME", cargo_home),
("~", home),
] {
if let Some(rest) = path.strip_prefix(prefix)
&& let Some(value) = value
{
return format!("{value}{rest}");
}
}
path.to_owned()
}
#[allow(
clippy::suboptimal_flops,
reason = "matches the CVSS reference implementations"
)]
pub fn cvss3_base_score(vector: &str) -> Option<f64> {
let (body, v31) = match vector.strip_prefix("CVSS:3.1/") {
Some(body) => (body, true),
None => (vector.strip_prefix("CVSS:3.0/")?, false),
};
let (mut av, mut ac, mut pr, mut ui) = (None, None, None, None);
let (mut scope, mut conf, mut integ, mut avail) = (None, None, None, None);
for part in body.split('/') {
let (key, value) = part.split_once(':')?;
let slot = match key {
"AV" => &mut av,
"AC" => &mut ac,
"PR" => &mut pr,
"UI" => &mut ui,
"S" => &mut scope,
"C" => &mut conf,
"I" => &mut integ,
"A" => &mut avail,
_ => continue,
};
*slot = Some(value);
}
let changed = match scope? {
"U" => false,
"C" => true,
_ => return None,
};
let av = match av? {
"N" => 0.85,
"A" => 0.62,
"L" => 0.55,
"P" => 0.2,
_ => return None,
};
let ac = match ac? {
"L" => 0.77,
"H" => 0.44,
_ => return None,
};
let pr = match (pr?, changed) {
("N", _) => 0.85,
("L", false) => 0.62,
("L", true) => 0.68,
("H", false) => 0.27,
("H", true) => 0.50,
_ => return None,
};
let ui = match ui? {
"N" => 0.85,
"R" => 0.62,
_ => return None,
};
let impact_metric = |value: &str| match value {
"H" => Some(0.56),
"L" => Some(0.22),
"N" => Some(0.0),
_ => None,
};
let conf = impact_metric(conf?)?;
let integ = impact_metric(integ?)?;
let avail = impact_metric(avail?)?;
let iss: f64 = 1.0 - (1.0 - conf) * (1.0 - integ) * (1.0 - avail);
let impact = if changed {
if v31 {
7.52 * (iss - 0.029) - 3.25 * (iss * 0.9731 - 0.02).powi(13)
} else {
7.52 * (iss - 0.029) - 3.25 * (iss - 0.02).powi(15)
}
} else {
6.42 * iss
};
if impact <= 0.0 {
return Some(0.0);
}
let exploitability: f64 = 8.22 * av * ac * pr * ui;
let raw = if changed {
1.08 * (impact + exploitability)
} else {
impact + exploitability
};
Some(roundup(raw.min(10.0)))
}
fn roundup(value: f64) -> f64 {
let scaled = (value * 100_000.0).round();
if scaled % 10_000.0 == 0.0 {
scaled / 100_000.0
} else {
((scaled / 10_000.0).floor() + 1.0) / 10.0
}
}
pub fn severity_from_cvss(vector: Option<&str>) -> Option<Severity> {
let score = cvss3_base_score(vector?)?;
Some(match score {
s if s >= 9.0 => Severity::Critical,
s if s >= 7.0 => Severity::High,
s if s >= 4.0 => Severity::Medium,
s if s > 0.0 => Severity::Low,
_ => return None,
})
}
pub fn parse_audit(ndjson: &str) -> Option<Vec<Finding>> {
let mut finished = false;
let mut waived: HashSet<String> = HashSet::new();
let mut diagnostics: Vec<serde_json::Value> = Vec::new();
for line in ndjson.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
match entry.get("type").and_then(serde_json::Value::as_str) {
Some("summary") => finished = true,
Some("diagnostic") => {
let Some(fields) = entry.get("fields") else {
continue;
};
if code(fields) == "advisory-ignored" {
if let Some(id) = waived_id(fields) {
waived.insert(id);
}
} else {
diagnostics.push(fields.clone());
}
}
_ => {}
}
}
if !finished {
return None;
}
Some(
diagnostics
.iter()
.filter_map(|fields| finding_from(fields, &waived))
.collect(),
)
}
fn code(fields: &serde_json::Value) -> &str {
fields
.get("code")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
}
fn waived_id(fields: &serde_json::Value) -> Option<String> {
let labels = fields.get("labels")?.as_array()?;
let span_of = |label: &serde_json::Value| {
label
.get("span")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
};
labels
.iter()
.find(|label| {
label.get("message").and_then(serde_json::Value::as_str)
== Some("advisory ignored here")
})
.and_then(span_of)
.or_else(|| {
labels
.iter()
.filter_map(span_of)
.find(|span| is_advisory_id(span))
})
}
fn is_advisory_id(span: &str) -> bool {
!span.is_empty()
&& span.len() <= 64
&& span
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn finding_from(fields: &serde_json::Value, waived: &HashSet<String>) -> Option<Finding> {
let code = code(fields);
let level = fields
.get("severity")
.and_then(serde_json::Value::as_str)
.unwrap_or("note");
let advisory = fields.get("advisory").filter(|value| !value.is_null());
if !matches!(level, "error" | "warning") && advisory.is_none() {
return None;
}
let blocking = level == "error";
if CONFIG_ONLY_CODES.contains(&code) && !blocking {
return None;
}
let code = if code.is_empty() { "finding" } else { code };
let id = advisory
.and_then(|advisory| advisory.get("id"))
.and_then(serde_json::Value::as_str)
.unwrap_or(code)
.to_owned();
let message = fields
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let title = one_line(
&advisory
.and_then(|advisory| advisory.get("title"))
.and_then(serde_json::Value::as_str)
.map_or_else(|| labelled_title(fields, message), str::to_owned),
);
let waived = waived.contains(&id);
Some(Finding {
code: code.to_owned(),
package: package_of(fields, advisory),
title,
severity: grade(advisory, blocking, waived),
waived,
blocking,
id,
})
}
pub const TITLE_MAX_CHARS: usize = 160;
pub fn one_line(text: &str) -> String {
let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() <= TITLE_MAX_CHARS {
return collapsed;
}
let kept: String = collapsed.chars().take(TITLE_MAX_CHARS - 1).collect();
format!("{kept}\u{2026}")
}
fn labelled_title(fields: &serde_json::Value, message: &str) -> String {
let span = fields
.get("labels")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.find(|label| {
!label
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.is_empty()
})
.and_then(|label| label.get("span"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if span.is_empty() || message.contains(span) {
return message.to_owned();
}
format!("{message}: {span}")
}
fn package_of(fields: &serde_json::Value, advisory: Option<&serde_json::Value>) -> String {
let krate = fields
.get("graphs")
.and_then(serde_json::Value::as_array)
.and_then(|graphs| graphs.first())
.and_then(|graph| graph.get("Krate"));
if let Some(krate) = krate {
let name = krate
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let version = krate
.get("version")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !name.is_empty() {
return if version.is_empty() {
one_line(name)
} else {
one_line(&format!("{name} {version}"))
};
}
}
advisory
.and_then(|advisory| advisory.get("package"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned()
}
fn grade(advisory: Option<&serde_json::Value>, blocking: bool, waived: bool) -> Severity {
let base = advisory.map_or(Severity::Low, |advisory| {
match advisory
.get("informational")
.and_then(serde_json::Value::as_str)
{
None => severity_from_cvss(advisory.get("cvss").and_then(serde_json::Value::as_str))
.unwrap_or(Severity::High),
Some("unsound") => Severity::Medium,
Some(_) => Severity::Low,
}
});
if waived {
base
} else if blocking {
base.max(Severity::High)
} else {
base.min(Severity::Medium)
}
}
pub fn age_days(then: SystemTime, now: SystemTime) -> u64 {
now.duration_since(then)
.map_or(0, |elapsed| elapsed.as_secs() / 86_400)
}
pub fn newest_db_mtime(dbs_dir: &Path) -> Option<SystemTime> {
std::fs::read_dir(dbs_dir)
.ok()?
.filter_map(Result::ok)
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| {
let checkout = entry.path();
let git = checkout.join(".git");
[git.join("FETCH_HEAD"), git, checkout]
.into_iter()
.filter_map(|path| path.metadata().ok()?.modified().ok())
.max()
})
.max()
}
fn default_db_dir() -> PathBuf {
crate::upgrade::cargo_home()
.unwrap_or_else(|| PathBuf::from(".cargo"))
.join("advisory-dbs")
}
const BANNER_ID_LIMIT: usize = 5;
pub fn dev_lines(eval: &Evaluation) -> Vec<String> {
let Evaluation::Audited { findings, .. } = eval else {
return Vec::new();
};
let blocking: Vec<&Finding> = findings
.iter()
.filter(|finding| !finding.waived && finding.blocking)
.collect();
let count = blocking.len();
if count == 0 {
return Vec::new();
}
let plural = if count == 1 { "" } else { "s" };
let worst = blocking
.iter()
.map(|finding| finding.severity)
.max()
.unwrap_or(Severity::High);
if worst < Severity::Critical {
return vec![format!(
" \u{26A0}\u{FE0F} {count} blocking dependency finding{plural} (worst: {}) \u{2014} run `autumn doctor` for detail.",
worst.label()
)];
}
let critical: Vec<&str> = blocking
.iter()
.filter(|finding| finding.severity == Severity::Critical)
.map(|finding| finding.id.as_str())
.collect();
vec![
" \u{26A0}\u{FE0F} CRITICAL DEPENDENCY ADVISORY".to_owned(),
format!(
" {count} blocking finding{plural}, {} critical: {}",
critical.len(),
name_some(&critical, BANNER_ID_LIMIT)
),
" Run `autumn doctor` for detail. `deny.toml` holds the policy and the waivers."
.to_owned(),
]
}
pub fn name_some(ids: &[&str], limit: usize) -> String {
let mut named = ids
.iter()
.take(limit)
.copied()
.collect::<Vec<_>>()
.join(", ");
if ids.len() > limit {
let _ = write!(named, ", and {} more", ids.len() - limit);
}
named
}
pub const DOCTOR_BUDGET: Duration = Duration::from_secs(30);
pub struct Running {
pub receiver: mpsc::Receiver<Evaluation>,
pub stop: StopHandle,
}
pub fn spawn_evaluation(root: &Path) -> Running {
spawn(root, None)
}
pub fn spawn_evaluation_with_deadline(root: &Path, deadline: Duration) -> Running {
spawn(root, Some(deadline))
}
fn spawn(root: &Path, deadline: Option<Duration>) -> Running {
let (sender, receiver) = mpsc::channel();
let root = root.to_path_buf();
let stop = StopHandle::default();
ACTIVE.register(&stop);
let finished = deadline.map(|deadline| watchdog(stop.clone(), deadline));
let worker_stop = stop.clone();
std::thread::spawn(move || {
let _ = sender.send(evaluate_with(&root, &worker_stop));
drop(finished);
});
Running { receiver, stop }
}
fn watchdog(stop: StopHandle, deadline: Duration) -> mpsc::Sender<()> {
let (finished, wait) = mpsc::channel::<()>();
std::thread::spawn(move || {
if wait.recv_timeout(deadline) == Err(mpsc::RecvTimeoutError::Timeout) {
stop.stop();
}
});
finished
}
pub fn await_evaluation(
receiver: &mpsc::Receiver<Evaluation>,
budget: Duration,
) -> Option<Evaluation> {
receiver.recv_timeout(budget).ok()
}
pub fn evaluate_within(root: &Path, budget: Duration) -> Evaluation {
await_or_stop(&spawn_evaluation(root), budget)
}
fn await_or_stop(running: &Running, budget: Duration) -> Evaluation {
await_evaluation(&running.receiver, budget).unwrap_or_else(|| {
running.stop.stop();
Evaluation::Unavailable {
reason: format!("the audit did not finish within {}s", budget.as_secs()),
checks: Vec::new(),
}
})
}
static ACTIVE: Registry = Registry(std::sync::Mutex::new(Vec::new()));
pub fn stop_all() {
ACTIVE.stop_all();
}
struct Registry(std::sync::Mutex<Vec<StopHandle>>);
impl Registry {
fn register(&self, stop: &StopHandle) {
let mut handles = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
handles.push(stop.clone());
}
fn stop_all(&self) {
let handles = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for stop in handles.iter() {
stop.stop();
}
}
}
#[derive(Default)]
struct Slot {
child: Option<std::process::Child>,
stopped: bool,
}
#[derive(Clone, Default)]
pub struct StopHandle(std::sync::Arc<std::sync::Mutex<Slot>>);
impl StopHandle {
fn slot(&self) -> std::sync::MutexGuard<'_, Slot> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn stop(&self) {
let mut slot = self.slot();
slot.stopped = true;
if let Some(child) = slot.child.as_mut() {
kill_tree(child);
}
}
#[cfg(test)]
pub fn is_stopped(&self) -> bool {
self.slot().stopped
}
}
fn kill_tree(child: &mut std::process::Child) {
crate::process::force_kill_group(child.id());
let _ = child.kill();
}
const DRAIN_GRACE: Duration = Duration::from_secs(2);
const POLL: Duration = Duration::from_millis(10);
fn run_stoppable(
command: &mut Command,
stop: &StopHandle,
) -> std::io::Result<std::process::Output> {
use std::io::Read;
use std::process::Stdio;
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(command, 0);
let mut child = command.spawn()?;
let drain = |pipe: Option<Box<dyn Read + Send>>| {
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let mut bytes = Vec::new();
if let Some(mut pipe) = pipe {
let _ = pipe.read_to_end(&mut bytes);
}
let _ = sender.send(bytes);
});
receiver
};
let stdout = drain(child.stdout.take().map(|p| Box::new(p) as _));
let stderr = drain(child.stderr.take().map(|p| Box::new(p) as _));
{
let mut slot = stop.slot();
if slot.stopped {
kill_tree(&mut child);
}
slot.child = Some(child);
}
let status = loop {
let mut slot = stop.slot();
let waited = slot.child.as_mut().map(std::process::Child::try_wait);
match waited {
Some(Ok(Some(status))) => {
slot.child = None;
if slot.stopped {
return Err(std::io::ErrorKind::Interrupted.into());
}
break status;
}
Some(Ok(None)) => {}
Some(Err(error)) => return Err(error),
None => return Err(std::io::ErrorKind::Interrupted.into()),
}
drop(slot);
std::thread::sleep(POLL);
};
Ok(std::process::Output {
status,
stdout: stdout.recv_timeout(DRAIN_GRACE).unwrap_or_default(),
stderr: stderr.recv_timeout(DRAIN_GRACE).unwrap_or_default(),
})
}
pub fn run_bounded(
command: &mut Command,
budget: Option<Duration>,
) -> std::io::Result<std::process::Output> {
let stop = StopHandle::default();
ACTIVE.register(&stop);
if let Some(budget) = budget {
let timer = stop.clone();
std::thread::spawn(move || {
std::thread::sleep(budget);
timer.stop();
});
}
run_stoppable(command, &stop)
}
pub fn find_policy_root(start: &Path) -> Option<PathBuf> {
let start = start.canonicalize().ok()?;
let mut nearest = None;
let mut outermost = None;
let mut bounded = false;
for dir in start.ancestors() {
if dir.join(POLICY_FILE).is_file() {
nearest.get_or_insert_with(|| dir.to_path_buf());
outermost = Some(dir.to_path_buf());
}
if dir.join(".git").exists() || is_workspace_root(dir) {
bounded = true;
break;
}
}
if bounded { outermost } else { nearest }
}
fn is_workspace_root(dir: &Path) -> bool {
std::fs::read_to_string(dir.join("Cargo.toml"))
.ok()
.and_then(|manifest| manifest.parse::<toml::Table>().ok())
.is_some_and(|manifest| manifest.contains_key("workspace"))
}
#[cfg(test)]
pub fn evaluate(root: &Path) -> Evaluation {
evaluate_with(root, &StopHandle::default())
}
fn evaluate_with(root: &Path, stop: &StopHandle) -> Evaluation {
let Some(root) = find_policy_root(root) else {
return Evaluation::NoPolicy;
};
let Ok(policy) = std::fs::read_to_string(root.join(POLICY_FILE)) else {
return Evaluation::NoPolicy;
};
let checks = policy_checks(&policy);
if let Err(error) = policy.parse::<toml::Table>() {
return Evaluation::Unavailable {
reason: one_line(&format!("{POLICY_FILE} is not valid TOML: {error}")),
checks,
};
}
let split = derivations_disagree(&policy);
if !split.is_empty() {
return Evaluation::Unavailable {
reason: format!(
"{POLICY_FILE} gives doctor and the CI workflow different check lists for {}. Write each as a plain [{}] header. Do not put header-like text in strings.",
split.join(", "),
split.join("], [")
),
checks,
};
}
let auditor = match auditor_version() {
Auditor::Version(version) => version,
Auditor::NotInstalled => return Evaluation::AuditorMissing { checks },
Auditor::Broken(reason) => {
return Evaluation::Unavailable {
reason: format!("{AUDITOR} is installed but not usable: {reason}"),
checks,
};
}
};
let custom_db = policy_db_path(&policy);
let mtime = newest_db_mtime(&resolve_db_dir(custom_db, &root));
if mtime.is_none() && !policy_declares_db_path(&policy) {
return Evaluation::DatabaseMissing { checks };
}
let mut command = Command::new(AUDITOR);
command
.args([
"--offline",
"--format",
"json",
"--log-level",
"info",
"check",
])
.args(&checks)
.current_dir(&root);
no_toolchain_installs(&mut command);
let output = match run_stoppable(&mut command, stop) {
Ok(output) => output,
Err(error) => {
return Evaluation::Unavailable {
reason: one_line(&format!("could not run cargo-deny: {error}")),
checks,
};
}
};
let stream = String::from_utf8_lossy(&output.stderr);
let Some(findings) = parse_audit(&stream) else {
return Evaluation::Unavailable {
reason: first_error(&stream),
checks,
};
};
if !output.status.success() && !findings.iter().any(|finding| finding.blocking) {
return Evaluation::Unavailable {
reason: "the auditor rejected this tree for a reason that could not be read; run `cargo deny check` to see it".to_owned(),
checks,
};
}
Evaluation::Audited {
findings,
checks,
db_age_days: mtime.map(|mtime| age_days(mtime, SystemTime::now())),
auditor,
}
}
fn resolve_db_dir(custom: Option<String>, root: &Path) -> PathBuf {
custom.map_or_else(default_db_dir, |path| root.join(path))
}
fn policy_declares_db_path(policy: &str) -> bool {
policy_db_path(policy).is_some()
}
const AUDITOR: &str = "cargo-deny";
pub fn no_toolchain_installs(command: &mut Command) {
command.env("RUSTUP_AUTO_INSTALL", "0");
}
enum Auditor {
Version(String),
NotInstalled,
Broken(String),
}
fn auditor_version() -> Auditor {
let mut command = Command::new(AUDITOR);
no_toolchain_installs(&mut command);
let output = match command.arg("--version").output() {
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Auditor::NotInstalled;
}
Err(error) => return Auditor::Broken(one_line(&error.to_string())),
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.lines().find(|line| !line.trim().is_empty());
return Auditor::Broken(match detail {
Some(line) => one_line(line),
None => format!("`{AUDITOR} --version` exited with {}", output.status),
});
}
let reported = String::from_utf8_lossy(&output.stdout);
Auditor::Version(one_line(reported.lines().next().unwrap_or("cargo-deny")))
}
fn first_error(stream: &str) -> String {
stream
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line.trim()).ok())
.find(|entry| {
entry.get("fields").and_then(|fields| fields.get("level"))
== Some(&serde_json::Value::String("ERROR".to_owned()))
})
.and_then(|entry| Some(one_line(entry.get("fields")?.get("message")?.as_str()?)))
.unwrap_or_else(|| "cargo-deny produced no verdict".to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn run_bounded_returns_the_output_of_a_fast_command() {
let mut command = Command::new("sh");
command.args(["-c", "echo bounded"]);
let output = run_bounded(&mut command, Some(Duration::from_secs(30))).expect("runs");
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "bounded");
}
#[cfg(unix)]
#[test]
fn run_bounded_stops_a_command_past_its_budget() {
let mut command = Command::new("sleep");
command.arg("30");
let started = std::time::Instant::now();
let err = run_bounded(&mut command, Some(Duration::from_millis(100)))
.expect_err("the budget stops it");
assert_eq!(err.kind(), std::io::ErrorKind::Interrupted);
assert!(started.elapsed() < Duration::from_secs(20));
}
const CRITICAL_VULN: &str = r#"
{"fields":{"level":"INFO","message":"checking advisories..."},"type":"log"}
{"fields":{"advisory":{"cvss":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H","id":"RUSTSEC-2099-0001","informational":null,"package":"badcrate","title":"remote code execution"},"code":"vulnerability","graphs":[{"Krate":{"name":"badcrate","version":"1.2.3"}}],"message":"remote code execution","severity":"error"},"type":"diagnostic"}
{"fields":{"advisories":{"errors":1,"helps":0,"notes":0,"warnings":0}},"type":"summary"}
"#;
const WAIVED_UNMAINTAINED: &str = r#"
{"fields":{"code":"advisory-ignored","labels":[{"column":13,"line":64,"message":"advisory ignored here","span":"RUSTSEC-2024-0384"},{"column":43,"line":64,"message":"ignore reason","span":"instant unmaintained"}],"message":"advisory ignored","severity":"note"},"type":"diagnostic"}
{"fields":{"advisory":{"cvss":null,"id":"RUSTSEC-2024-0384","informational":"unmaintained","package":"instant","title":"`instant` is unmaintained"},"code":"unmaintained","graphs":[{"Krate":{"name":"instant","version":"0.1.13"}}],"message":"`instant` is unmaintained","severity":"note"},"type":"diagnostic"}
{"fields":{"advisories":{"errors":0,"helps":0,"notes":2,"warnings":0}},"type":"summary"}
"#;
const YANKED_WARNING: &str = r#"
{"fields":{"code":"yanked","graphs":[{"Krate":{"name":"oldcrate","version":"0.3.1"}}],"message":"detected yanked crate","severity":"warning"},"type":"diagnostic"}
{"fields":{"advisories":{"errors":0,"helps":0,"notes":0,"warnings":1}},"type":"summary"}
"#;
const POLICY_VIOLATIONS: &str = r#"
{"fields":{"code":"banned","graphs":[{"Krate":{"name":"serde","version":"1.0.229"}}],"labels":[{"column":20,"line":4,"message":"banned here","span":"serde"}],"message":"crate 'serde = 1.0.229' is explicitly banned","severity":"error"},"type":"diagnostic"}
{"fields":{"code":"rejected","graphs":[{"Krate":{"name":"brotli","version":"8.0.4"}}],"labels":[{"column":12,"line":28,"message":"rejected: license is not explicitly allowed","span":"BSD-3-Clause"}],"message":"failed to satisfy license requirements","severity":"error"},"type":"diagnostic"}
{"fields":{"code":"source-not-allowed","graphs":[{"Krate":{"name":"adler2","version":"2.0.1"}}],"message":"detected 'registry' source not explicitly allowed","severity":"error"},"type":"diagnostic"}
{"fields":{"code":"license-not-encountered","message":"license 'Zlib' was not encountered","severity":"warning"},"type":"diagnostic"}
{"fields":{"licenses":{"errors":2,"helps":0,"notes":0,"warnings":1}},"type":"summary"}
"#;
const INFORMATIONAL_NOTES: &str = r#"
{"fields":{"code":"skipped-private-workspace-crate","graphs":[{"Krate":{"name":"blog","version":"0.1.0"}}],"message":"skipping private workspace crate 'blog = 0.1.0'","severity":"note"},"type":"diagnostic"}
{"fields":{"code":"skipped-private-workspace-crate","graphs":[{"Krate":{"name":"hello","version":"0.1.0"}}],"message":"skipping private workspace crate 'hello = 0.1.0'","severity":"note"},"type":"diagnostic"}
{"fields":{"licenses":{"errors":0,"helps":0,"notes":2,"warnings":0}},"type":"summary"}
"#;
const MULTILINE_LABEL: &str = r#"
{"fields":{"code":"duplicate","graphs":[{"Krate":{"name":"base64","version":"0.21.7"}}],"labels":[{"column":1,"line":3,"message":"lock entries","span":"base64 0.21.7 registry+https://github.com/rust-lang/crates.io-index\nbase64 0.22.1 registry+https://github.com/rust-lang/crates.io-index\nbase64 0.23.1 registry+https://github.com/rust-lang/crates.io-index"}],"message":"found 3 duplicate entries for crate 'base64'","severity":"warning"},"type":"diagnostic"}
{"fields":{"bans":{"errors":0,"helps":0,"notes":0,"warnings":1}},"type":"summary"}
"#;
const WAIVED_VULNERABILITY: &str = r#"
{"fields":{"code":"advisory-ignored","labels":[{"column":13,"line":6,"message":"advisory ignored here","span":"RUSTSEC-2099-0001"}],"message":"advisory ignored","severity":"note"},"type":"diagnostic"}
{"fields":{"advisory":{"cvss":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H","id":"RUSTSEC-2099-0001","informational":null,"package":"badcrate","title":"remote code execution"},"code":"vulnerability","graphs":[{"Krate":{"name":"badcrate","version":"1.2.3"}}],"message":"remote code execution","severity":"note"},"type":"diagnostic"}
{"fields":{"advisories":{"errors":0,"helps":0,"notes":2,"warnings":0}},"type":"summary"}
"#;
const V4_VULNERABILITY: &str = r#"
{"fields":{"advisory":{"cvss":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N","id":"RUSTSEC-2099-0002","informational":null,"package":"newcrate","title":"remote code execution"},"code":"vulnerability","graphs":[{"Krate":{"name":"newcrate","version":"2.0.0"}}],"message":"remote code execution","severity":"error"},"type":"diagnostic"}
{"fields":{"advisories":{"errors":1,"helps":0,"notes":0,"warnings":0}},"type":"summary"}
"#;
const PROMOTED_CONFIG_DIAGNOSTIC: &str = r#"
{"fields":{"code":"advisory-not-detected","labels":[{"column":13,"line":4,"message":"no crate matched advisory criteria","span":"RUSTSEC-2020-0071"}],"message":"advisory was not encountered","severity":"error"},"type":"diagnostic"}
{"fields":{"advisories":{"errors":1,"helps":0,"notes":0,"warnings":0}},"type":"summary"}
"#;
fn audited(findings: Vec<Finding>) -> Evaluation {
Evaluation::Audited {
findings,
checks: vec!["advisories".to_owned()],
db_age_days: Some(1),
auditor: "cargo-deny 0.20.2".to_owned(),
}
}
fn finding(id: &str, severity: Severity, waived: bool, blocking: bool) -> Finding {
Finding {
id: id.to_owned(),
code: "vulnerability".to_owned(),
package: "badcrate 1.2.3".to_owned(),
title: "boom".to_owned(),
severity,
waived,
blocking,
}
}
#[test]
fn policy_with_only_advisories_checks_advisories() {
let policy = "[advisories]\nyanked = \"warn\"\n";
assert_eq!(policy_checks(policy), vec!["advisories".to_owned()]);
}
#[test]
fn commented_sections_do_not_widen_the_check_list() {
let policy = "[advisories]\n# [licenses]\n# allow = [\"MIT\"]\n#[bans]\n";
assert_eq!(policy_checks(policy), vec!["advisories".to_owned()]);
}
#[test]
fn declared_sections_widen_the_check_list_in_command_order() {
let policy = "[sources]\n[advisories]\n[licenses]\nallow = []\n[bans]\n";
assert_eq!(
policy_checks(policy),
vec![
"advisories".to_owned(),
"bans".to_owned(),
"licenses".to_owned(),
"sources".to_owned()
]
);
}
#[test]
fn advisories_are_checked_even_when_the_section_is_absent() {
assert_eq!(policy_checks("[bans]\n"), vec!["advisories", "bans"]);
}
#[test]
fn a_custom_db_path_is_read_from_the_policy() {
let policy = "[advisories]\ndb-path = \"/srv/advisory-db\"\n";
assert_eq!(policy_db_path(policy).as_deref(), Some("/srv/advisory-db"));
}
#[test]
fn marvin_attack_vector_scores_medium() {
let v = "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N";
let score = cvss3_base_score(v).expect("v3.1 vector must parse");
assert!((score - 5.9).abs() < 0.05, "expected 5.9, got {score}");
assert_eq!(severity_from_cvss(Some(v)), Some(Severity::Medium));
}
#[test]
fn worst_case_unchanged_scope_vector_scores_critical() {
let v = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H";
let score = cvss3_base_score(v).expect("v3.1 vector must parse");
assert!((score - 9.8).abs() < 0.05, "expected 9.8, got {score}");
assert_eq!(severity_from_cvss(Some(v)), Some(Severity::Critical));
}
#[test]
fn changed_scope_raises_the_score() {
let v = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H";
let score = cvss3_base_score(v).expect("v3.1 vector must parse");
assert!((score - 10.0).abs() < 0.05, "expected 10.0, got {score}");
}
#[test]
fn a_vector_with_no_impact_scores_none() {
let v = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N";
assert_eq!(cvss3_base_score(v), Some(0.0));
assert_eq!(severity_from_cvss(Some(v)), None);
}
#[test]
fn v30_vectors_parse_too() {
let v = "CVSS:3.0/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H";
let score = cvss3_base_score(v).expect("v3.0 vector must parse");
assert!((score - 7.8).abs() < 0.05, "expected 7.8, got {score}");
}
#[test]
fn non_v3_and_malformed_vectors_are_not_scored() {
assert_eq!(cvss3_base_score("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N"), None);
assert_eq!(cvss3_base_score("CVSS:3.1/AV:N/AC:L"), None);
assert_eq!(cvss3_base_score("high"), None);
assert_eq!(severity_from_cvss(None), None);
}
#[test]
fn a_stream_without_a_summary_is_not_a_verdict() {
assert_eq!(parse_audit(""), None);
assert_eq!(
parse_audit(r#"{"fields":{"level":"ERROR","message":"boom"},"type":"log"}"#),
None
);
}
#[test]
fn a_clean_run_yields_a_verdict_with_no_findings() {
let clean = r#"{"fields":{"advisories":{"errors":0,"helps":0,"notes":0,"warnings":0}},"type":"summary"}"#;
assert_eq!(parse_audit(clean), Some(Vec::new()));
}
#[test]
fn a_vulnerability_carries_its_id_package_title_and_severity() {
let findings = parse_audit(CRITICAL_VULN).expect("summary present");
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert_eq!(f.id, "RUSTSEC-2099-0001");
assert_eq!(f.code, "vulnerability");
assert_eq!(f.package, "badcrate 1.2.3");
assert_eq!(f.title, "remote code execution");
assert_eq!(f.severity, Severity::Critical);
assert!(f.blocking);
assert!(!f.waived);
}
#[test]
fn a_waived_advisory_is_reported_as_waived_not_as_a_failure() {
let findings = parse_audit(WAIVED_UNMAINTAINED).expect("summary present");
assert_eq!(findings.len(), 1, "got {findings:?}");
let f = &findings[0];
assert_eq!(f.id, "RUSTSEC-2024-0384");
assert!(f.waived);
assert!(!f.blocking);
}
#[test]
fn a_warned_finding_never_exceeds_medium() {
let findings = parse_audit(YANKED_WARNING).expect("summary present");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "yanked");
assert_eq!(findings[0].package, "oldcrate 0.3.1");
assert!(!findings[0].blocking);
assert!(findings[0].severity <= Severity::Medium);
}
#[test]
fn a_denied_finding_is_at_least_high() {
let findings = parse_audit(POLICY_VIOLATIONS).expect("summary present");
let codes: Vec<&str> = findings.iter().map(|f| f.code.as_str()).collect();
assert_eq!(codes, vec!["banned", "rejected", "source-not-allowed"]);
for f in &findings {
assert!(f.blocking, "{} must block", f.code);
assert!(f.severity >= Severity::High, "{} severity too low", f.code);
}
}
#[test]
fn a_policy_violation_names_the_crate_and_the_violated_rule() {
let findings = parse_audit(POLICY_VIOLATIONS).expect("summary present");
let rejected = findings
.iter()
.find(|f| f.code == "rejected")
.expect("rejected");
assert_eq!(rejected.package, "brotli 8.0.4");
assert_eq!(rejected.id, "rejected");
assert!(
rejected.title.contains("BSD-3-Clause"),
"the rejected license must be named: {}",
rejected.title
);
}
#[test]
fn config_only_diagnostics_are_not_findings() {
let findings = parse_audit(POLICY_VIOLATIONS).expect("summary present");
assert!(
findings.iter().all(|f| f.code != "license-not-encountered"),
"config-only diagnostics must not be reported"
);
}
#[test]
fn informational_notes_are_not_findings() {
assert_eq!(parse_audit(INFORMATIONAL_NOTES), Some(Vec::new()));
}
#[test]
fn a_waived_advisory_survives_the_note_filter() {
let findings = parse_audit(WAIVED_UNMAINTAINED).expect("summary present");
assert_eq!(findings.len(), 1);
assert!(findings[0].waived);
}
#[test]
fn a_finding_is_always_one_line() {
let findings = parse_audit(MULTILINE_LABEL).expect("summary present");
assert_eq!(findings.len(), 1);
let finding = &findings[0];
assert!(!finding.title.contains('\n'), "title: {}", finding.title);
assert!(
!finding.package.contains('\n'),
"package: {}",
finding.package
);
assert!(
finding.title.chars().count() <= TITLE_MAX_CHARS,
"an unbounded title floods the line: {}",
finding.title
);
assert!(
finding.title.contains("base64"),
"the crate must survive the trim: {}",
finding.title
);
}
#[test]
fn a_waived_finding_is_graded_by_its_own_severity() {
let findings = parse_audit(WAIVED_VULNERABILITY).expect("summary present");
assert_eq!(findings.len(), 1);
let finding = &findings[0];
assert!(finding.waived);
assert!(!finding.blocking);
assert_eq!(finding.severity, Severity::Critical);
assert!(dev_lines(&audited(findings)).is_empty());
}
#[test]
fn an_unscorable_vulnerability_is_treated_as_high_not_as_low() {
let findings = parse_audit(V4_VULNERABILITY).expect("summary present");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::High);
assert!(findings[0].blocking);
}
#[test]
fn a_malformed_policy_is_reported_before_the_optional_tool_checks() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(
root.path().join(POLICY_FILE),
"[advisories]\nthis is not toml =\n",
)
.expect("policy");
match evaluate(root.path()) {
Evaluation::Unavailable { reason, .. } => {
assert!(reason.contains("not valid TOML"), "{reason}");
}
other => panic!("a malformed policy must not read as evaluable: {other:?}"),
}
}
#[test]
fn a_spelling_only_doctor_can_see_is_reported_rather_than_diverging() {
let escaped = "[advisories]\n[\"ban\\u0073\"]\n";
assert!(
policy_checks(escaped).contains(&"bans".to_owned()),
"the TOML parse must decode the key"
);
assert_eq!(derivations_disagree(escaped), vec!["bans".to_owned()]);
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join(POLICY_FILE), escaped).expect("policy");
match evaluate(root.path()) {
Evaluation::Unavailable { reason, .. } => {
assert!(reason.contains("bans"), "{reason}");
assert!(reason.contains("[bans]"), "the fix must be named: {reason}");
}
other => panic!("a divergent spelling must not read as evaluable: {other:?}"),
}
}
#[test]
fn section_text_inside_a_string_is_reported_as_a_phantom() {
let phantom = "[advisories]\nignore = [\n { id = \"RUSTSEC-2020-0071\", reason = \"\"\"\nno fix; this is prose:\n[bans]\nreview-by 2026-12-01\"\"\" },\n]\n";
assert_eq!(policy_checks(phantom), vec!["advisories".to_owned()]);
assert_eq!(derivations_disagree(phantom), vec!["bans".to_owned()]);
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join(POLICY_FILE), phantom).expect("policy");
match evaluate(root.path()) {
Evaluation::Unavailable { reason, .. } => {
assert!(reason.contains("bans"), "{reason}");
assert!(reason.contains("strings"), "{reason}");
}
other => panic!("a phantom section must not read as evaluable: {other:?}"),
}
}
#[test]
fn a_header_quoted_in_any_string_is_a_phantom_for_every_optional_check() {
for section in OPTIONAL_CHECKS {
for quote in ["\"\"\"", "'''"] {
let policy = format!(
"[advisories]\nignore = [{{ id = \"X\", reason = {quote}\n[{section}]\n{quote} }}]\n"
);
assert_eq!(derivations_disagree(&policy), vec![(*section).to_owned()]);
}
}
}
#[test]
fn a_real_header_plus_quoted_text_is_not_a_split() {
let policy =
"[bans]\n[advisories]\nignore = [{ id = \"X\", reason = \"\"\"\n[bans]\n\"\"\" }]\n";
assert!(derivations_disagree(policy).is_empty());
assert!(derivations_disagree("[advisories]\n# [bans]\n").is_empty());
}
#[test]
fn unpaired_quotes_in_a_string_match_the_grep() {
let policy = "[advisories]\nnote = \"\"\"\n[\"bans']\n\"\"\"\n";
assert_eq!(derivations_disagree(policy), vec!["bans".to_owned()]);
}
#[test]
fn invalid_toml_falls_back_to_the_scan_and_agrees() {
assert!(derivations_disagree("[bans\n[sources]\n").is_empty());
}
#[test]
fn a_relative_db_path_is_resolved_against_the_policy_root() {
let root = tempfile::tempdir().expect("tempdir");
let abs = tempfile::tempdir().expect("tempdir");
assert_eq!(
resolve_db_dir(Some("advisory-dbs".to_owned()), root.path()),
root.path().join("advisory-dbs")
);
let abs_path = abs.path().to_string_lossy().into_owned();
assert_eq!(resolve_db_dir(Some(abs_path), root.path()), abs.path());
assert_eq!(resolve_db_dir(None, root.path()), default_db_dir());
}
#[test]
fn every_hand_written_spelling_is_visible_to_both_derivations() {
for policy in [
"[bans]\n",
"[ bans ]\n",
"[bans] # note\n",
"[bans.build]\n",
"[[bans.deny]]\n",
"bans.deny = []\n",
"[\"bans\"]\n",
"['bans']\n",
] {
assert!(
derivations_disagree(policy).is_empty(),
"wrongly reported as a disagreement: {policy:?}"
);
}
}
#[cfg(unix)]
mod stoppable {
use super::*;
fn alive(pid: i32) -> bool {
nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).is_ok()
}
fn sh(script: &str) -> Command {
let mut command = Command::new("sh");
command.args(["-c", script]);
command
}
#[test]
fn a_finished_audit_returns_its_output_and_is_not_killed() {
let stop = StopHandle::default();
let output = run_stoppable(&mut sh("echo out; echo err >&2"), &stop).expect("run");
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "out");
assert_eq!(String::from_utf8_lossy(&output.stderr).trim(), "err");
assert!(!stop.is_stopped(), "the normal path never stops");
}
#[test]
fn stopping_kills_the_auditor_and_its_children() {
let dir = tempfile::tempdir().expect("tempdir");
let pid_file = dir.path().join("grandchild.pid");
let tmp_file = dir.path().join("grandchild.tmp");
let script = format!(
"sleep 60 & echo $! > {tmp}; mv {tmp} {pid}; wait",
tmp = tmp_file.display(),
pid = pid_file.display()
);
let stop = StopHandle::default();
let stopper = stop.clone();
let runner = std::thread::spawn(move || run_stoppable(&mut sh(&script), &stopper));
let mut grandchild = None;
for _ in 0..500 {
if let Ok(text) = std::fs::read_to_string(&pid_file) {
grandchild = text.trim().parse::<i32>().ok();
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let Some(grandchild) = grandchild else {
stop.stop();
panic!("the auditor never started its child");
};
let _cleanup = Cleanup(grandchild);
assert!(alive(grandchild));
stop.stop();
let result = runner.join().expect("join");
assert_eq!(
result.expect_err("stopped").kind(),
std::io::ErrorKind::Interrupted
);
for _ in 0..1000 {
if !alive(grandchild) {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("the auditor's child outlived the stop");
}
struct Cleanup(i32);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(self.0),
nix::sys::signal::Signal::SIGKILL,
);
}
}
#[test]
fn stop_all_stops_every_registered_audit() {
let registry = Registry(std::sync::Mutex::new(Vec::new()));
let (first, second) = (StopHandle::default(), StopHandle::default());
registry.register(&first);
registry.register(&second);
registry.stop_all();
assert!(first.is_stopped() && second.is_stopped());
}
#[test]
fn the_watchdog_stops_an_audit_that_outlives_its_deadline() {
let stop = StopHandle::default();
let _finished = watchdog(stop.clone(), Duration::from_millis(20));
for _ in 0..500 {
if stop.is_stopped() {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("the deadline did not stop the audit");
}
#[test]
fn the_watchdog_leaves_a_finished_audit_alone() {
let stop = StopHandle::default();
drop(watchdog(stop.clone(), Duration::from_millis(50)));
std::thread::sleep(Duration::from_millis(200));
assert!(!stop.is_stopped());
}
#[test]
fn a_stop_twice_is_harmless() {
let stop = StopHandle::default();
stop.stop();
stop.stop();
assert!(stop.is_stopped());
}
#[test]
fn output_larger_than_a_pipe_buffer_is_drained() {
let stop = StopHandle::default();
let output = run_stoppable(&mut sh("head -c 300000 /dev/zero"), &stop).expect("run");
assert_eq!(output.stdout.len(), 300_000);
}
#[test]
fn a_budget_that_expires_stops_the_audit() {
let (_sender, receiver) = mpsc::channel();
let stop = StopHandle::default();
let running = Running {
receiver,
stop: stop.clone(),
};
let eval = await_or_stop(&running, Duration::from_millis(10));
assert!(matches!(eval, Evaluation::Unavailable { .. }));
assert!(stop.is_stopped(), "expiry must stop the auditor");
}
#[test]
fn a_stop_before_the_start_prevents_the_run() {
let stop = StopHandle::default();
stop.stop();
let error = run_stoppable(&mut sh("sleep 60"), &stop).expect_err("stopped");
assert_eq!(error.kind(), std::io::ErrorKind::Interrupted);
}
}
#[test]
fn the_auditor_is_never_invoked_through_the_rustup_shim() {
let source = include_str!("deps.rs");
let code = &source[..source.find("#[cfg(test)]").expect("test module")];
assert!(
!code.contains("Command::new(\"cargo\")"),
"this module must not run anything through the `cargo` shim"
);
assert_eq!(AUDITOR, "cargo-deny");
assert_eq!(
code.matches("Command::new(").count(),
code.matches("no_toolchain_installs(&mut").count(),
"every command this module spawns must be guarded"
);
}
#[test]
fn a_read_only_check_never_installs_a_toolchain() {
let mut command = Command::new(AUDITOR);
no_toolchain_installs(&mut command);
let set: Vec<_> = command
.get_envs()
.filter(|(key, _)| *key == "RUSTUP_AUTO_INSTALL")
.collect();
assert_eq!(set.len(), 1, "the guard must be set exactly once");
assert_eq!(set[0].1, Some(std::ffi::OsStr::new("0")));
}
#[test]
fn the_two_minor_versions_disagree_across_a_band_boundary() {
const METRICS: &str = "AV:P/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:L";
assert_eq!(cvss3_base_score(&format!("CVSS:3.0/{METRICS}")), Some(7.0));
assert_eq!(cvss3_base_score(&format!("CVSS:3.1/{METRICS}")), Some(6.9));
assert_eq!(
severity_from_cvss(Some(&format!("CVSS:3.0/{METRICS}"))),
Some(Severity::High)
);
assert_eq!(
severity_from_cvss(Some(&format!("CVSS:3.1/{METRICS}"))),
Some(Severity::Medium)
);
}
#[test]
fn a_broken_auditor_is_not_reported_as_a_missing_one() {
let broken = Evaluation::Unavailable {
reason: format!("{AUDITOR} is installed but not usable: Exec format error"),
checks: vec!["advisories".to_owned()],
};
let result = crate::doctor::check_dependencies_impl(&broken);
assert_eq!(result.status, crate::doctor::CheckStatus::Warn);
let absent = Evaluation::AuditorMissing {
checks: vec!["advisories".to_owned()],
};
assert_eq!(
crate::doctor::check_dependencies_impl(&absent).status,
crate::doctor::CheckStatus::Pass
);
}
#[test]
fn a_verdict_that_arrives_in_time_is_returned() {
let (sender, receiver) = mpsc::channel();
sender.send(Evaluation::NoPolicy).expect("send");
assert_eq!(
await_evaluation(&receiver, Duration::from_secs(5)),
Some(Evaluation::NoPolicy)
);
}
#[test]
fn a_verdict_that_outruns_its_budget_is_not_returned() {
let (sender, receiver) = mpsc::channel::<Evaluation>();
let result = await_evaluation(&receiver, Duration::from_millis(20));
assert_eq!(result, None);
drop(sender);
}
#[test]
fn an_expired_budget_never_reads_as_a_clean_tree() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(
evaluate_within(dir.path(), Duration::from_secs(5)),
Evaluation::NoPolicy
);
assert!(
dev_lines(&Evaluation::Unavailable {
reason: "the audit did not finish within 30s".to_owned(),
checks: Vec::new(),
})
.is_empty()
);
}
#[test]
fn the_doctor_budget_is_bounded() {
assert!(DOCTOR_BUDGET <= Duration::from_secs(60));
}
#[test]
fn a_config_diagnostic_the_policy_promoted_is_a_finding() {
let findings = parse_audit(PROMOTED_CONFIG_DIAGNOSTIC).expect("summary present");
assert_eq!(findings.len(), 1, "got {findings:?}");
assert!(findings[0].blocking);
assert!(findings[0].severity >= Severity::High);
}
#[test]
fn the_same_config_diagnostic_below_error_is_still_noise() {
let warned = PROMOTED_CONFIG_DIAGNOSTIC
.replace(r#""severity":"error""#, r#""severity":"warning""#)
.replace(r#""errors":1"#, r#""errors":0"#);
assert_eq!(parse_audit(&warned), Some(Vec::new()));
}
#[test]
fn every_toml_spelling_of_a_section_widens_the_check_list() {
for policy in [
"[bans]\n",
"[ bans ]\n",
"[bans] # inline note\n",
"[bans.build]\n",
"[[bans.deny]]\n",
"bans.deny = []\n",
"bans = { multiple-versions = \"allow\" }\n",
"[\"bans\"]\n",
"['bans']\n",
] {
assert!(
policy_checks(policy).contains(&"bans".to_owned()),
"not detected: {policy:?}"
);
}
for policy in ["[bansible]\n", "[advisories]\n", "# [bans]\n", "#[bans]\n"] {
assert_eq!(
policy_checks(policy),
vec!["advisories".to_owned()],
"wrongly detected: {policy:?}"
);
}
}
#[test]
fn an_unparseable_policy_falls_back_to_a_line_scan() {
let broken = "[bans]\nthis is not toml =\n";
assert!(broken.parse::<toml::Table>().is_err());
assert!(policy_checks(broken).contains(&"bans".to_owned()));
}
#[test]
fn the_line_scan_fallback_ignores_non_ascii_indentation() {
assert!(!line_declares(code_of("\u{00A0}[bans]"), "bans"));
assert!(line_declares(code_of(" [bans]"), "bans"));
}
#[test]
fn a_home_or_cargo_home_prefix_is_expanded() {
let home = Some("/home/tester");
let cargo = Some("/home/tester/.cargo");
assert_eq!(
expand_path_with("~/mirror", home, cargo),
"/home/tester/mirror"
);
assert_eq!(
expand_path_with("$CARGO_HOME/advisory-dbs", home, cargo),
"/home/tester/.cargo/advisory-dbs"
);
assert_eq!(
expand_path_with("${CARGO_HOME}/advisory-dbs", home, cargo),
"/home/tester/.cargo/advisory-dbs"
);
assert_eq!(expand_path_with("/srv/db", home, cargo), "/srv/db");
assert_eq!(expand_path_with("~/mirror", None, None), "~/mirror");
}
#[test]
fn a_db_path_is_read_through_quoting_and_comments() {
assert_eq!(
policy_db_path("[advisories]\ndb-path = '/srv/db#1'\n").as_deref(),
Some("/srv/db#1")
);
assert_eq!(
policy_db_path("[advisories]\ndb-path = \"/srv/db\" # mirrored nightly\n").as_deref(),
Some("/srv/db")
);
assert_eq!(policy_db_path("db-path = \"/srv/db\"\n"), None);
assert_eq!(policy_db_path("[advisories]\n"), None);
assert_eq!(policy_db_path("# db-path = \"/nope\"\n"), None);
}
#[test]
fn the_policy_is_found_from_inside_a_workspace_member() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join(POLICY_FILE), "[advisories]\n").expect("policy");
let member = root.path().join("crates").join("app");
std::fs::create_dir_all(&member).expect("member");
assert_eq!(
find_policy_root(&member).map(|found| found.canonicalize().expect("canonical")),
Some(root.path().canonicalize().expect("canonical"))
);
}
#[test]
fn the_outermost_policy_wins_over_a_workspace_members() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(root.path().join(".git")).expect("git");
std::fs::write(root.path().join(POLICY_FILE), "[advisories]\n").expect("policy");
let member = root.path().join("crates").join("app");
std::fs::create_dir_all(&member).expect("member");
std::fs::write(member.join(POLICY_FILE), "[bans]\n").expect("member policy");
assert_eq!(
find_policy_root(&member).map(|found| found.canonicalize().expect("canonical")),
Some(root.path().canonicalize().expect("canonical"))
);
}
#[test]
fn the_outermost_policy_search_still_stops_at_the_repository_root() {
let outer = tempfile::tempdir().expect("tempdir");
std::fs::write(outer.path().join(POLICY_FILE), "[bans]\n").expect("outer policy");
let repo = outer.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("git");
std::fs::write(repo.join(POLICY_FILE), "[advisories]\n").expect("policy");
assert_eq!(
find_policy_root(&repo).map(|found| found.canonicalize().expect("canonical")),
Some(repo.canonicalize().expect("canonical"))
);
}
#[test]
fn without_a_repository_root_the_nearest_policy_wins() {
let outer = tempfile::tempdir().expect("tempdir");
std::fs::write(outer.path().join(POLICY_FILE), "[bans]\n").expect("outer policy");
let app = outer.path().join("app");
std::fs::create_dir_all(&app).expect("app");
std::fs::write(app.join(POLICY_FILE), "[advisories]\n").expect("policy");
assert_eq!(
find_policy_root(&app).map(|found| found.canonicalize().expect("canonical")),
Some(app.canonicalize().expect("canonical"))
);
}
#[test]
fn the_outermost_of_three_policies_wins() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(root.path().join(".git")).expect("git");
let member = root.path().join("crates").join("app");
std::fs::create_dir_all(&member).expect("member");
for dir in [root.path(), &root.path().join("crates"), &member] {
std::fs::write(dir.join(POLICY_FILE), "[advisories]\n").expect("policy");
}
assert_eq!(
find_policy_root(&member).map(|found| found.canonicalize().expect("canonical")),
Some(root.path().canonicalize().expect("canonical"))
);
}
#[test]
fn a_nested_workspace_keeps_its_own_policy() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(root.path().join(".git")).expect("git");
std::fs::write(root.path().join(POLICY_FILE), "[bans]\n").expect("policy");
let nested = root.path().join("examples").join("flock");
std::fs::create_dir_all(&nested).expect("nested");
std::fs::write(nested.join(POLICY_FILE), "[advisories]\n").expect("nested policy");
std::fs::write(nested.join("Cargo.toml"), "[workspace]\nmembers = []\n").expect("manifest");
assert_eq!(
find_policy_root(&nested).map(|found| found.canonicalize().expect("canonical")),
Some(nested.canonicalize().expect("canonical"))
);
}
#[test]
fn the_search_for_a_policy_stops_at_the_repository_root() {
let outer = tempfile::tempdir().expect("tempdir");
std::fs::write(outer.path().join(POLICY_FILE), "[advisories]\n").expect("policy");
let repo = outer.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("git");
assert_eq!(find_policy_root(&repo), None);
}
#[test]
fn a_fetch_marker_newer_than_the_checkout_sets_the_age() {
let dbs = tempfile::tempdir().expect("tempdir");
let git = dbs.path().join("advisory-db-abc").join(".git");
std::fs::create_dir_all(&git).expect("git dir");
let from_dirs = newest_db_mtime(dbs.path()).expect("mtime");
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(git.join("FETCH_HEAD"), "ref").expect("fetch marker");
let from_marker = newest_db_mtime(dbs.path()).expect("mtime");
assert!(
from_marker >= from_dirs,
"the fetch marker must not read older than the checkout"
);
}
#[test]
fn a_fetch_into_an_existing_checkout_refreshes_the_age() {
let dbs = tempfile::tempdir().expect("tempdir");
let checkout = dbs.path().join("advisory-db-abc");
std::fs::create_dir(&checkout).expect("checkout");
let root_mtime = newest_db_mtime(dbs.path()).expect("mtime");
std::fs::create_dir(checkout.join(".git")).expect("git");
let fetched = newest_db_mtime(dbs.path()).expect("mtime");
assert!(
fetched >= root_mtime,
"a fetch must not read as older than the clone"
);
}
#[test]
fn age_is_whole_days_and_never_negative() {
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_hours(90 * 24);
let then = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(88 * 86_400 + 100);
assert_eq!(age_days(then, now), 1);
assert_eq!(age_days(now, then), 0);
}
#[test]
fn a_missing_database_directory_has_no_mtime() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(newest_db_mtime(&dir.path().join("absent")), None);
assert_eq!(newest_db_mtime(dir.path()), None);
}
#[test]
fn the_newest_database_checkout_sets_the_age() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join("advisory-db-1")).expect("mkdir");
assert!(newest_db_mtime(dir.path()).is_some());
}
#[test]
fn a_clean_tree_adds_no_lines_to_dev_output() {
assert!(dev_lines(&audited(Vec::new())).is_empty());
}
#[test]
fn dev_stays_silent_when_the_policy_cannot_be_evaluated() {
for eval in [
Evaluation::NoPolicy,
Evaluation::AuditorMissing { checks: Vec::new() },
Evaluation::DatabaseMissing { checks: Vec::new() },
Evaluation::Unavailable {
reason: "cargo metadata failed".to_owned(),
checks: Vec::new(),
},
] {
assert!(dev_lines(&eval).is_empty(), "{eval:?} must be silent");
}
}
#[test]
fn dev_stays_silent_when_every_finding_is_waived() {
let eval = audited(vec![finding("RUSTSEC-1", Severity::Critical, true, false)]);
assert!(dev_lines(&eval).is_empty());
}
#[test]
fn a_non_critical_tree_gets_exactly_one_dev_line() {
let eval = audited(vec![
finding("RUSTSEC-1", Severity::High, false, true),
finding("RUSTSEC-2", Severity::Low, false, false),
]);
let lines = dev_lines(&eval);
assert_eq!(lines.len(), 1, "got {lines:?}");
assert!(
lines[0].contains('1'),
"the count must appear: {}",
lines[0]
);
assert!(
lines[0].contains("high"),
"the worst severity must appear: {}",
lines[0]
);
assert!(
lines[0].contains("autumn doctor"),
"the line must say where detail lives: {}",
lines[0]
);
}
#[test]
fn a_critical_finding_gets_a_loud_banner() {
let eval = audited(vec![finding(
"RUSTSEC-2099-0001",
Severity::Critical,
false,
true,
)]);
let lines = dev_lines(&eval);
assert!(
lines.len() > 1,
"a critical finding is not a one-liner: {lines:?}"
);
let banner = lines.join("\n");
assert!(banner.contains("CRITICAL"), "{banner}");
assert!(banner.contains("RUSTSEC-2099-0001"), "{banner}");
assert!(banner.contains("autumn doctor"), "{banner}");
}
}