373 lines
12 KiB
Rust
373 lines
12 KiB
Rust
use super::{MatrixError, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fs::{self, OpenOptions};
|
|
use std::io::Write as _;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
const POLICY_PATH: &str = "ci/dependency-policy.json";
|
|
const REWRITE_PATH: &str = "RUSTREWRITE.md";
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DependencyPolicy {
|
|
schema: u32,
|
|
current: String,
|
|
reviewed_on: String,
|
|
review_by: String,
|
|
direct: Vec<DirectPolicy>,
|
|
approved_duplicates: Vec<DuplicatePolicy>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DirectPolicy {
|
|
name: String,
|
|
versions: Vec<String>,
|
|
purpose: String,
|
|
maintenance: String,
|
|
transitive_cost: String,
|
|
native: bool,
|
|
rewrite_anchor: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DuplicatePolicy {
|
|
name: String,
|
|
versions: Vec<String>,
|
|
reason: String,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize)]
|
|
struct ObservedDirect {
|
|
versions: BTreeSet<String>,
|
|
consumers: BTreeSet<String>,
|
|
scopes: BTreeSet<String>,
|
|
}
|
|
|
|
struct ObservedGraph {
|
|
direct: BTreeMap<String, ObservedDirect>,
|
|
resolved_external_packages: usize,
|
|
duplicates: BTreeMap<String, BTreeSet<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct DependencyEvidence<'a> {
|
|
schema: u32,
|
|
current: &'a str,
|
|
reviewed_on: &'a str,
|
|
review_by: &'a str,
|
|
recorded_unix_seconds: u64,
|
|
direct_dependencies: &'a BTreeMap<String, ObservedDirect>,
|
|
direct_dependency_count: usize,
|
|
resolved_external_package_count: usize,
|
|
approved_duplicates: &'a BTreeMap<String, BTreeSet<String>>,
|
|
native_dependencies: Vec<&'a str>,
|
|
status: &'static str,
|
|
}
|
|
|
|
/// Audits the complete resolved dependency graph against its reviewed policy.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error for unreviewed direct crates or versions, stale duplicate
|
|
/// approvals, wildcard or non-registry dependencies, incomplete rationale, or
|
|
/// a metadata/evidence I/O failure.
|
|
pub fn audit_dependencies(root: &Path, evidence: &Path) -> Result<()> {
|
|
if evidence.exists() {
|
|
return Err(MatrixError::new(format!(
|
|
"{} already exists; preserve or remove it before rerunning the audit",
|
|
evidence.display()
|
|
)));
|
|
}
|
|
let policy: DependencyPolicy = serde_json::from_slice(&fs::read(root.join(POLICY_PATH))?)?;
|
|
validate_policy(&policy, &fs::read_to_string(root.join(REWRITE_PATH))?)?;
|
|
let metadata = cargo_metadata(root)?;
|
|
let observed = observe(&metadata)?;
|
|
compare_direct(&policy.direct, &observed.direct)?;
|
|
compare_duplicates(&policy.approved_duplicates, &observed.duplicates)?;
|
|
|
|
let native_dependencies = policy
|
|
.direct
|
|
.iter()
|
|
.filter(|dependency| dependency.native)
|
|
.map(|dependency| dependency.name.as_str())
|
|
.collect();
|
|
let evidence_record = DependencyEvidence {
|
|
schema: 1,
|
|
current: &policy.current,
|
|
reviewed_on: &policy.reviewed_on,
|
|
review_by: &policy.review_by,
|
|
recorded_unix_seconds: SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_err(|_| MatrixError::new("system clock predates Unix epoch"))?
|
|
.as_secs(),
|
|
direct_dependencies: &observed.direct,
|
|
direct_dependency_count: observed.direct.len(),
|
|
resolved_external_package_count: observed.resolved_external_packages,
|
|
approved_duplicates: &observed.duplicates,
|
|
native_dependencies,
|
|
status: "ok",
|
|
};
|
|
if let Some(parent) = evidence.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let mut file = OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(evidence)?;
|
|
serde_json::to_writer_pretty(&mut file, &evidence_record)?;
|
|
file.write_all(b"\n")?;
|
|
file.sync_all()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_policy(policy: &DependencyPolicy, rewrite: &str) -> Result<()> {
|
|
if policy.schema != 1 || policy.current != "stable" {
|
|
return Err(MatrixError::new(
|
|
"dependency policy must use schema 1 and current stable Rust",
|
|
));
|
|
}
|
|
if !date_shape(&policy.reviewed_on) || !date_shape(&policy.review_by) {
|
|
return Err(MatrixError::new(
|
|
"dependency review dates must use YYYY-MM-DD",
|
|
));
|
|
}
|
|
let mut names = BTreeSet::new();
|
|
for dependency in &policy.direct {
|
|
if dependency.name.trim().is_empty()
|
|
|| !names.insert(dependency.name.as_str())
|
|
|| dependency.versions.is_empty()
|
|
|| dependency.purpose.trim().is_empty()
|
|
|| dependency.rewrite_anchor.trim().is_empty()
|
|
|| !rewrite.contains(&dependency.rewrite_anchor)
|
|
|| !matches!(
|
|
dependency.maintenance.as_str(),
|
|
"active" | "stable" | "monitored-native"
|
|
)
|
|
|| !matches!(
|
|
dependency.transitive_cost.as_str(),
|
|
"low" | "medium" | "high"
|
|
)
|
|
{
|
|
return Err(MatrixError::new(format!(
|
|
"dependency {} has incomplete or invalid review metadata",
|
|
dependency.name
|
|
)));
|
|
}
|
|
unique_versions(&dependency.name, &dependency.versions)?;
|
|
}
|
|
let mut duplicate_names = BTreeSet::new();
|
|
for duplicate in &policy.approved_duplicates {
|
|
if duplicate.name.trim().is_empty()
|
|
|| !duplicate_names.insert(duplicate.name.as_str())
|
|
|| duplicate.versions.len() < 2
|
|
|| duplicate.reason.trim().len() < 20
|
|
{
|
|
return Err(MatrixError::new(format!(
|
|
"duplicate {} needs unique versions and a substantive reason",
|
|
duplicate.name
|
|
)));
|
|
}
|
|
unique_versions(&duplicate.name, &duplicate.versions)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn date_shape(value: &str) -> bool {
|
|
value.len() == 10
|
|
&& value.as_bytes()[4] == b'-'
|
|
&& value.as_bytes()[7] == b'-'
|
|
&& value
|
|
.bytes()
|
|
.enumerate()
|
|
.all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
|
|
}
|
|
|
|
fn unique_versions(name: &str, versions: &[String]) -> Result<()> {
|
|
let unique = versions.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
|
if unique.len() != versions.len() || unique.iter().any(|version| version.trim().is_empty()) {
|
|
return Err(MatrixError::new(format!(
|
|
"dependency {name} versions must be nonempty and unique"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cargo_metadata(root: &Path) -> Result<Value> {
|
|
let output = Command::new(super::cargo_program())
|
|
.args([
|
|
"metadata",
|
|
"--locked",
|
|
"--all-features",
|
|
"--format-version",
|
|
"1",
|
|
])
|
|
.current_dir(root)
|
|
.output()?;
|
|
if !output.status.success() {
|
|
return Err(MatrixError::new(format!(
|
|
"cargo metadata failed during dependency audit: {}",
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
)));
|
|
}
|
|
Ok(serde_json::from_slice(&output.stdout)?)
|
|
}
|
|
|
|
fn observe(metadata: &Value) -> Result<ObservedGraph> {
|
|
let packages = metadata["packages"]
|
|
.as_array()
|
|
.ok_or_else(|| MatrixError::new("cargo metadata has no package array"))?;
|
|
let mut package_by_id = BTreeMap::new();
|
|
let mut all_external = BTreeMap::<String, BTreeSet<String>>::new();
|
|
for package in packages {
|
|
let id = string(package, "id")?;
|
|
let name = string(package, "name")?;
|
|
let version = string(package, "version")?;
|
|
let source = package["source"].as_str();
|
|
if let Some(source) = source {
|
|
if !source.starts_with("registry+https://github.com/rust-lang/crates.io-index") {
|
|
return Err(MatrixError::new(format!(
|
|
"package {name} {version} uses unapproved source {source}"
|
|
)));
|
|
}
|
|
all_external
|
|
.entry(name.to_owned())
|
|
.or_default()
|
|
.insert(version.to_owned());
|
|
}
|
|
if package["dependencies"]
|
|
.as_array()
|
|
.is_some_and(|dependencies| {
|
|
dependencies.iter().any(|dependency| {
|
|
dependency["source"].is_string() && dependency["req"].as_str() == Some("*")
|
|
})
|
|
})
|
|
{
|
|
return Err(MatrixError::new(format!(
|
|
"package {name} contains a wildcard dependency"
|
|
)));
|
|
}
|
|
package_by_id.insert(
|
|
id.to_owned(),
|
|
(name.to_owned(), version.to_owned(), source.is_some()),
|
|
);
|
|
}
|
|
|
|
let nodes = metadata["resolve"]["nodes"]
|
|
.as_array()
|
|
.ok_or_else(|| MatrixError::new("cargo metadata has no resolve nodes"))?;
|
|
let mut direct = BTreeMap::<String, ObservedDirect>::new();
|
|
for node in nodes {
|
|
let id = string(node, "id")?;
|
|
let Some((consumer, _, false)) = package_by_id.get(id) else {
|
|
continue;
|
|
};
|
|
let dependencies = node["deps"]
|
|
.as_array()
|
|
.ok_or_else(|| MatrixError::new("cargo metadata node has no dependencies"))?;
|
|
for dependency in dependencies {
|
|
let package_id = string(dependency, "pkg")?;
|
|
let Some((name, version, true)) = package_by_id.get(package_id) else {
|
|
continue;
|
|
};
|
|
let observed = direct.entry(name.clone()).or_default();
|
|
observed.versions.insert(version.clone());
|
|
observed.consumers.insert(consumer.clone());
|
|
for kind in dependency["dep_kinds"].as_array().into_iter().flatten() {
|
|
observed
|
|
.scopes
|
|
.insert(kind["kind"].as_str().unwrap_or("normal").to_owned());
|
|
}
|
|
}
|
|
}
|
|
let duplicates = all_external
|
|
.iter()
|
|
.filter(|(_, versions)| versions.len() > 1)
|
|
.map(|(name, versions)| (name.clone(), versions.clone()))
|
|
.collect();
|
|
Ok(ObservedGraph {
|
|
direct,
|
|
resolved_external_packages: all_external.values().map(BTreeSet::len).sum(),
|
|
duplicates,
|
|
})
|
|
}
|
|
|
|
fn string<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
|
|
value[key]
|
|
.as_str()
|
|
.ok_or_else(|| MatrixError::new(format!("cargo metadata field {key} is not a string")))
|
|
}
|
|
|
|
fn compare_direct(
|
|
policy: &[DirectPolicy],
|
|
observed: &BTreeMap<String, ObservedDirect>,
|
|
) -> Result<()> {
|
|
let expected = policy
|
|
.iter()
|
|
.map(|dependency| {
|
|
(
|
|
dependency.name.as_str(),
|
|
dependency.versions.iter().cloned().collect::<BTreeSet<_>>(),
|
|
)
|
|
})
|
|
.collect::<BTreeMap<_, _>>();
|
|
let expected_names = expected.keys().copied().collect::<BTreeSet<_>>();
|
|
let observed_names = observed.keys().map(String::as_str).collect::<BTreeSet<_>>();
|
|
if expected_names != observed_names {
|
|
return Err(MatrixError::new(format!(
|
|
"direct dependency policy mismatch; expected {expected_names:?}, observed {observed_names:?}"
|
|
)));
|
|
}
|
|
for (name, versions) in expected {
|
|
if observed[name].versions != versions {
|
|
return Err(MatrixError::new(format!(
|
|
"direct dependency {name} version mismatch; expected {versions:?}, observed {:?}",
|
|
observed[name].versions
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn compare_duplicates(
|
|
policy: &[DuplicatePolicy],
|
|
observed: &BTreeMap<String, BTreeSet<String>>,
|
|
) -> Result<()> {
|
|
let expected = policy
|
|
.iter()
|
|
.map(|duplicate| {
|
|
(
|
|
duplicate.name.clone(),
|
|
duplicate.versions.iter().cloned().collect::<BTreeSet<_>>(),
|
|
)
|
|
})
|
|
.collect::<BTreeMap<_, _>>();
|
|
if expected != *observed {
|
|
return Err(MatrixError::new(format!(
|
|
"resolved duplicate policy mismatch; expected {expected:?}, observed {observed:?}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn checked_in_dependency_policy_matches_the_locked_graph() {
|
|
let root = super::super::workspace_root(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
|
|
let evidence = std::env::temp_dir().join(format!(
|
|
"metacrate-dependency-audit-{}.json",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_file(&evidence);
|
|
audit_dependencies(&root, &evidence).unwrap();
|
|
let contents = fs::read_to_string(&evidence).unwrap();
|
|
assert!(contents.contains("\"status\": \"ok\""));
|
|
fs::remove_file(evidence).unwrap();
|
|
}
|
|
}
|