use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Map, Value};
use crate::canonical::{canonicalize_value, sort_array_by_key, strip_keys, Envelope};
pub const CAPTURER: &str = "deps.cargo-audit";
pub const VERSION: &str = "1";
const ADVISORY_DB_TARBALL: &str =
"https://github.com/rustsec/advisory-db/archive/refs/heads/main.tar.gz";
pub fn capture(lockfile_path: &Path, db_path: Option<&Path>) -> Result<Envelope> {
let lockfile = rustsec::Lockfile::load(lockfile_path)
.with_context(|| format!("load Cargo.lock at {}", lockfile_path.display()))?;
let db = match resolve_db_path(db_path) {
Some(p) => rustsec::Database::open(&p)
.map_err(|e| anyhow!("open advisory db at {}: {e}", p.display()))?,
None => {
let cached = fetch_advisory_db().context("acquire RustSec advisory db over HTTPS")?;
rustsec::Database::open(&cached)
.map_err(|e| anyhow!("open advisory db at {}: {e}", cached.display()))?
}
};
let settings = rustsec::report::Settings::default();
let report = rustsec::report::Report::generate(&db, &lockfile, &settings);
let mut value = serde_json::to_value(&report).context("serialize rustsec report")?;
let result = canonicalize_cargo_audit(&mut value);
Ok(Envelope::new(
CAPTURER,
VERSION,
json!({
"lockfile": lockfile_path.display().to_string(),
"db_path": db_path.map(|p| p.display().to_string()),
}),
result,
))
}
fn resolve_db_path(explicit: Option<&Path>) -> Option<PathBuf> {
if let Some(p) = explicit {
return Some(p.to_path_buf());
}
if let Ok(s) = std::env::var("SECUNIT_RUSTSEC_DB") {
return Some(PathBuf::from(s));
}
None
}
fn fetch_advisory_db() -> Result<PathBuf> {
let cache_root = advisory_db_cache_dir()?;
std::fs::create_dir_all(&cache_root)
.with_context(|| format!("create advisory-db cache dir {}", cache_root.display()))?;
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("secunit/", env!("CARGO_PKG_VERSION")))
.build()
.context("build reqwest client")?;
let resp = client
.get(ADVISORY_DB_TARBALL)
.send()
.with_context(|| format!("GET {ADVISORY_DB_TARBALL}"))?;
let status = resp.status();
if !status.is_success() {
return Err(anyhow!(
"advisory-db download {ADVISORY_DB_TARBALL} returned HTTP {status}"
));
}
let bytes = resp.bytes().context("read advisory-db tarball body")?;
let staging = cache_root.join(".staging");
if staging.exists() {
std::fs::remove_dir_all(&staging)
.with_context(|| format!("clear staging dir {}", staging.display()))?;
}
std::fs::create_dir_all(&staging)
.with_context(|| format!("create staging dir {}", staging.display()))?;
let decoder = flate2::read::GzDecoder::new(&bytes[..]);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(&staging)
.context("unpack advisory-db tarball")?;
let repo_root = first_subdir(&staging)?
.ok_or_else(|| anyhow!("advisory-db tarball had no top-level directory"))?;
let dest = cache_root.join("advisory-db");
if dest.exists() {
std::fs::remove_dir_all(&dest)
.with_context(|| format!("clear stale cache {}", dest.display()))?;
}
std::fs::rename(&repo_root, &dest)
.with_context(|| format!("install advisory-db into {}", dest.display()))?;
let _ = std::fs::remove_dir_all(&staging);
Ok(dest)
}
fn advisory_db_cache_dir() -> Result<PathBuf> {
if let Ok(s) = std::env::var("SECUNIT_CACHE_DIR") {
return Ok(PathBuf::from(s).join("rustsec"));
}
let base = dirs_cache_dir().unwrap_or_else(std::env::temp_dir);
Ok(base.join("secunit").join("rustsec"))
}
fn dirs_cache_dir() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
}
#[cfg(not(target_os = "macos"))]
{
if let Some(x) = std::env::var_os("XDG_CACHE_HOME") {
return Some(PathBuf::from(x));
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))
}
}
fn first_subdir(dir: &Path) -> Result<Option<PathBuf>> {
for entry in
std::fs::read_dir(dir).with_context(|| format!("read staging dir {}", dir.display()))?
{
let entry = entry.context("read staging dir entry")?;
if entry.file_type().context("stat staging entry")?.is_dir() {
return Ok(Some(entry.path()));
}
}
Ok(None)
}
pub fn canonicalize_report(value: &mut Value) -> Value {
canonicalize_cargo_audit(value)
}
fn canonicalize_cargo_audit(value: &mut Value) -> Value {
strip_keys(value, &["last-commit", "last-updated", "advisory-count"]);
if let Some(vulns) = value
.pointer_mut("/vulnerabilities/list")
.and_then(|v| v.as_array_mut())
{
for entry in vulns.iter_mut() {
if let Some(id) = entry
.pointer("/advisory/id")
.and_then(|v| v.as_str())
.map(str::to_string)
{
if let Some(obj) = entry.as_object_mut() {
obj.insert("__sort_id".into(), Value::String(id));
}
}
}
sort_array_by_key(vulns, "__sort_id");
for entry in vulns.iter_mut() {
if let Some(obj) = entry.as_object_mut() {
obj.remove("__sort_id");
}
}
}
if let Some(Value::Object(map)) = value.get("warnings").cloned() {
let mut arr: Vec<Value> = map
.into_iter()
.map(|(kind, items)| json!({ "kind": kind, "items": items }))
.collect();
for w in arr.iter_mut() {
if let Some(items) = w.get_mut("items").and_then(|i| i.as_array_mut()) {
sort_array_by_key(items, "package");
}
}
sort_array_by_key(&mut arr, "kind");
if let Value::Object(m) = value {
m.insert("warnings".into(), Value::Array(arr));
}
}
if let Value::Object(m) = value {
m.remove("settings");
}
canonicalize_value(std::mem::replace(value, Value::Object(Map::new())))
}
#[cfg(test)]
mod tests {
use super::*;
fn synthetic_report() -> Value {
json!({
"database": {
"advisory-count": 999,
"last-commit": "abcdef1234",
"last-updated": "2026-04-30T12:00:00Z"
},
"lockfile": { "dependency-count": 14 },
"settings": { "ignore": [], "informational_warnings": [] },
"vulnerabilities": {
"found": true,
"count": 2,
"list": [
{
"advisory": {"id": "RUSTSEC-2024-0010", "title": "Z thing"},
"package": {"name": "zlib", "version": "1.0.0"},
"versions": {"patched": []},
"affected": null
},
{
"advisory": {"id": "RUSTSEC-2024-0001", "title": "A thing"},
"package": {"name": "alpha", "version": "0.1.0"},
"versions": {"patched": []},
"affected": null
}
]
},
"warnings": {
"unmaintained": [
{"package": {"name": "old-crate"}, "kind": "unmaintained"}
]
}
})
}
#[test]
fn cargo_audit_canonicalize_sorts_and_strips() {
let mut v = synthetic_report();
let out = canonicalize_cargo_audit(&mut v);
let body = serde_json::to_string(&out).unwrap();
assert!(!body.contains("last-commit"));
assert!(!body.contains("last-updated"));
assert!(!body.contains("settings"));
let alpha = body.find("RUSTSEC-2024-0001").unwrap();
let zlib = body.find("RUSTSEC-2024-0010").unwrap();
assert!(alpha < zlib, "list must sort by advisory id");
assert!(body.contains("\"warnings\":[{"));
assert!(body.contains("\"kind\":\"unmaintained\""));
}
#[test]
fn cargo_audit_envelope_byte_identical() {
let _g = crate::time::set_fixed_time_for_tests("2026-05-01T00:00:00Z");
let mut v1 = synthetic_report();
let r1 = canonicalize_cargo_audit(&mut v1);
let mut v2 = synthetic_report();
let r2 = canonicalize_cargo_audit(&mut v2);
let e1 = Envelope::new(CAPTURER, VERSION, json!({"lockfile": "a"}), r1);
let e2 = Envelope::new(CAPTURER, VERSION, json!({"lockfile": "a"}), r2);
assert_eq!(
e1.to_canonical_json().unwrap(),
e2.to_canonical_json().unwrap()
);
}
}