Harden dependency and supply-chain policy (#100)
Some checks failed
Native code generation / deterministic (push) Failing after 2m6s
Imaging and meshing gate / native (push) Failing after 2m48s
JPEG 2000 feature / linux (push) Successful in 2m43s
Release platform and feature matrix / audit (push) Successful in 35s
Native Rust workspace compile / compile (push) Failing after 57s
Skia feature / linux (push) Successful in 31m0s
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
Some checks failed
Native code generation / deterministic (push) Failing after 2m6s
Imaging and meshing gate / native (push) Failing after 2m48s
JPEG 2000 feature / linux (push) Successful in 2m43s
Release platform and feature matrix / audit (push) Successful in 35s
Native Rust workspace compile / compile (push) Failing after 57s
Skia feature / linux (push) Successful in 31m0s
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
This commit is contained in:
375
tools/ci-matrix/src/dependency.rs
Normal file
375
tools/ci-matrix/src/dependency.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
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,
|
||||
msrv: String,
|
||||
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,
|
||||
msrv: &'a str,
|
||||
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,
|
||||
msrv: &policy.msrv,
|
||||
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.msrv != "1.96.0" || policy.current != "stable" {
|
||||
return Err(MatrixError::new(
|
||||
"dependency policy must use schema 1, MSRV 1.96.0, and current stable",
|
||||
));
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitStatus, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
mod dependency;
|
||||
|
||||
pub use dependency::audit_dependencies;
|
||||
|
||||
pub const MATRIX_PATH: &str = "ci/release-matrix.json";
|
||||
const WORKFLOW_PATH: &str = ".gitea/workflows/release-matrix.yml";
|
||||
const REQUIRED_PROFILES: [&str; 7] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use metacrate_ci_matrix::{audit, load, run, workspace_root};
|
||||
use metacrate_ci_matrix::{audit, audit_dependencies, load, run, workspace_root};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
@@ -32,7 +32,26 @@ fn execute() -> Result<(), Box<dyn std::error::Error>> {
|
||||
run(&root, &matrix, &profile, &evidence)?;
|
||||
println!("release CI profile {profile}: ok ({})", evidence.display());
|
||||
}
|
||||
_ => return Err("usage: ci-matrix audit | run PROFILE --evidence FILE".into()),
|
||||
Some("dependency-audit") => {
|
||||
let flag = arguments
|
||||
.next()
|
||||
.ok_or("dependency-audit requires --evidence FILE")?;
|
||||
let evidence = arguments
|
||||
.next()
|
||||
.ok_or("dependency-audit requires --evidence FILE")?;
|
||||
if flag != "--evidence" || arguments.next().is_some() {
|
||||
return Err("usage: ci-matrix dependency-audit --evidence FILE".into());
|
||||
}
|
||||
let evidence = absolute_or_rooted(&root, &evidence);
|
||||
audit_dependencies(&root, &evidence)?;
|
||||
println!("dependency policy: ok ({})", evidence.display());
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"usage: ci-matrix audit | run PROFILE --evidence FILE | dependency-audit --evidence FILE"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user