#!/usr/bin/env -S cargo +nightly -Zscript --- [package] edition = "2024" [dependencies] regex = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.9" --- use regex::Regex; use serde::Serialize; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::env; use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use toml::{Table, Value as TomlValue}; type AnyError = Box; const POLICY_RELATIVE: &str = "config/dependency-policy.toml"; #[derive(Debug, Clone, Serialize)] struct Issue { code: String, message: String, #[serde(skip_serializing_if = "Option::is_none")] package: Option, #[serde(skip_serializing_if = "Option::is_none")] path: Option, } impl Issue { fn new(code: impl Into, message: impl Into) -> Self { Self { code: code.into(), message: message.into(), package: None, path: None, } } fn package(mut self, package: impl Into) -> Self { self.package = Some(package.into()); self } fn path(mut self, path: impl AsRef) -> Self { self.path = Some(path.as_ref().display().to_string()); self } } #[derive(Debug, Clone)] struct Package { name: String, manifest_path: PathBuf, package_dir: PathBuf, metadata: Value, dependencies: Vec, } #[derive(Debug, Default)] struct Graph { normal: BTreeMap>, dev: BTreeMap>, } fn root_dir() -> Result { let start = env::current_dir()?; start .ancestors() .find(|directory| directory.join(POLICY_RELATIVE).is_file()) .map(Path::to_path_buf) .ok_or_else(|| "run from inside the sentry-chassis workspace".into()) } fn read_toml(path: &Path) -> Result { let text = fs::read_to_string(path)?; Ok(text.parse::()?) } fn run(command: &mut Command) -> Result { let output = command.output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("command failed: {stderr}").into()); } Ok(output) } fn cargo_metadata(root: &Path) -> Result { let output = run(Command::new("cargo") .current_dir(root) .args(["metadata", "--locked", "--format-version", "1", "--no-deps"]))?; Ok(serde_json::from_slice(&output.stdout)?) } fn string_array(value: Option<&TomlValue>) -> BTreeSet { value .and_then(TomlValue::as_array) .into_iter() .flatten() .filter_map(TomlValue::as_str) .map(str::to_owned) .collect() } fn toml_string<'a>(table: &'a Table, key: &str) -> Result<&'a str, AnyError> { table .get(key) .and_then(TomlValue::as_str) .ok_or_else(|| format!("policy field {key} must be a string").into()) } fn toml_i64(table: &Table, key: &str) -> Result { table .get(key) .and_then(TomlValue::as_integer) .ok_or_else(|| format!("policy field {key} must be an integer").into()) } fn toml_table<'a>(table: &'a Table, key: &str) -> Result<&'a Table, AnyError> { table .get(key) .and_then(TomlValue::as_table) .ok_or_else(|| format!("policy field {key} must be a table").into()) } fn package_map(metadata: &Value) -> Result, AnyError> { let members: HashSet<&str> = metadata["workspace_members"] .as_array() .ok_or("workspace_members must be an array")? .iter() .filter_map(Value::as_str) .collect(); let mut packages = BTreeMap::new(); for item in metadata["packages"] .as_array() .ok_or("packages must be an array")? { if !members.contains(item["id"].as_str().unwrap_or_default()) { continue; } let name = item["name"] .as_str() .ok_or("workspace package has no name")? .to_owned(); let manifest_path = PathBuf::from( item["manifest_path"] .as_str() .ok_or_else(|| format!("package {name} has no manifest_path"))?, ); let package_dir = manifest_path .parent() .ok_or_else(|| format!("package {name} has no package directory"))? .to_path_buf(); packages.insert( name.clone(), Package { name, package_dir, manifest_path, metadata: item.clone(), dependencies: item["dependencies"] .as_array() .cloned() .unwrap_or_default(), }, ); } Ok(packages) } fn package_name_by_path(packages: &BTreeMap) -> BTreeMap { packages .values() .map(|package| (package.package_dir.clone(), package.name.clone())) .collect() } fn dependency_target( dependency: &Value, by_path: &BTreeMap, ) -> Option { let path = dependency.get("path")?.as_str()?; by_path.get(&PathBuf::from(path)).cloned() } fn build_graph( packages: &BTreeMap, ) -> Graph { let by_path = package_name_by_path(packages); let mut graph = Graph::default(); for package in packages.values() { graph.normal.entry(package.name.clone()).or_default(); graph.dev.entry(package.name.clone()).or_default(); for dependency in &package.dependencies { let Some(target) = dependency_target(dependency, &by_path) else { continue; }; let kind = dependency["kind"].as_str().unwrap_or("normal"); let edges = if kind == "dev" { &mut graph.dev } else { &mut graph.normal }; edges .entry(package.name.clone()) .or_default() .insert(target); } } graph } fn check_rank(source: &str, target: &str, ranks: &BTreeMap) -> bool { match (ranks.get(source), ranks.get(target)) { (Some(source_rank), Some(target_rank)) => target_rank < source_rank, _ => false, } } fn check_layer_path(layer: &str, relative_path: &str) -> bool { let parts: Vec = Path::new(relative_path) .components() .map(|part| part.as_os_str().to_string_lossy().into_owned()) .collect(); parts.len() == 3 && parts[0] == "crates" && format!("{}/{}", parts[1], parts[2]) == layer } fn contains_forbidden_marker(text: &str) -> bool { let marker = Regex::new(r"\bunsafe\b|libc|no_std").expect("valid marker regex"); marker.is_match(text) } fn declares_trait(text: &str, symbol: &str) -> bool { let pattern = format!(r"\bpub trait {}\b", regex::escape(symbol)); Regex::new(&pattern) .expect("valid trait regex") .is_match(text) } fn contains_forbidden_symbol(text: &str, symbol: &str) -> bool { let pattern = format!(r"\b{}\b", regex::escape(symbol)); Regex::new(&pattern) .expect("valid symbol regex") .is_match(text) } fn dependency_issues( kind: &str, source: &str, actual: &BTreeSet, allowed: &BTreeSet, ) -> Vec { let mut issues = actual .difference(allowed) .map(|target| { Issue::new( "dependency-edge", format!("{kind} dependency {source} -> {target} is not declared"), ) .package(source) }) .collect::>(); issues.extend(allowed.difference(actual).map(|target| { Issue::new( "dependency-edge", format!("declared {kind} dependency {source} -> {target} is absent"), ) .package(source) })); issues } fn layer_declaration_matches(declared: Option<&str>, expected: &str) -> bool { declared == Some(expected) } fn find_cycles(edges: &BTreeMap>) -> Vec> { #[derive(Clone, Copy, PartialEq)] enum Color { White, Gray, Black, } fn visit( node: &str, edges: &BTreeMap>, colors: &mut BTreeMap, trail: &mut Vec, cycles: &mut Vec>, ) { colors.insert(node.to_owned(), Color::Gray); trail.push(node.to_owned()); for target in edges.get(node).into_iter().flatten() { match colors.get(target).copied().unwrap_or(Color::White) { Color::Gray => { let mut cycle = trail.clone(); cycle.push(target.clone()); cycles.push(cycle); } Color::White => visit(target, edges, colors, trail, cycles), Color::Black => {} } } trail.pop(); colors.insert(node.to_owned(), Color::Black); } let mut colors = BTreeMap::new(); let mut cycles = Vec::new(); for node in edges.keys() { if colors.get(node).copied().unwrap_or(Color::White) == Color::White { visit(node, edges, &mut colors, &mut Vec::new(), &mut cycles); } } cycles } fn package_declared_layer(package: &Package) -> Option { package .metadata .get("metadata") .and_then(|metadata| metadata.get("sentry-chassis")) .and_then(|metadata| metadata.get("layer")) .and_then(Value::as_str) .map(str::to_owned) } fn policy_packages(policy: &Table) -> Result<&Table, AnyError> { toml_table(policy, "packages") } fn policy_package<'a>(policy: &'a Table, name: &str) -> Result<&'a Table, AnyError> { policy_packages(policy)? .get(name) .and_then(TomlValue::as_table) .ok_or_else(|| format!("policy package {name} is missing").into()) } fn rust_files_in(directory: &Path) -> Vec { fs::read_dir(directory) .into_iter() .flatten() .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.extension().is_some_and(|extension| extension == "rs")) .collect() } fn check_layout_and_markers( root: &Path, packages: &BTreeMap, ) -> Result, AnyError> { let mut issues = Vec::new(); for package in packages.values() { let src = package.package_dir.join("src"); let tests = package.package_dir.join("tests"); let mut rust_files = rust_files_in(&src); rust_files.extend(rust_files_in(&tests)); if !src.is_dir() || rust_files.iter().all(|path| path.parent() != Some(src.as_path())) { issues.push( Issue::new("layout", "package must have src/*.rs") .package(&package.name) .path(&src), ); } for directory in [&src, &tests] { if !directory.is_dir() { continue; } for entry in fs::read_dir(directory)? { let entry = entry?; if entry.file_type()?.is_dir() { issues.push( Issue::new("layout", "source/test directories must remain flat") .package(&package.name) .path(entry.path()), ); } } } let mut scan_paths = vec![package.manifest_path.clone()]; scan_paths.extend(rust_files); for path in scan_paths { let text = fs::read_to_string(&path)?; if contains_forbidden_marker(&text) { issues.push( Issue::new("forbidden-marker", "unsafe/libc/no_std marker found") .package(&package.name) .path(path), ); } } } let _ = root; Ok(issues) } fn dependency_tables(manifest: &Table) -> Vec<&Table> { let mut tables = Vec::new(); for key in ["dependencies", "dev-dependencies"] { if let Some(table) = manifest.get(key).and_then(TomlValue::as_table) { tables.push(table); } } if let Some(targets) = manifest.get("target").and_then(TomlValue::as_table) { for target in targets.values().filter_map(TomlValue::as_table) { for key in ["dependencies", "dev-dependencies"] { if let Some(table) = target.get(key).and_then(TomlValue::as_table) { tables.push(table); } } } } tables } fn check_workspace_inheritance( root: &Path, packages: &BTreeMap, ) -> Result, AnyError> { let root_manifest = read_toml(&root.join("Cargo.toml"))?; let workspace = toml_table(&root_manifest, "workspace")?; let aliases = toml_table(workspace, "dependencies")? .keys() .cloned() .collect::>(); let mut issues = Vec::new(); for package in packages.values() { let manifest = read_toml(&package.manifest_path)?; for table in dependency_tables(&manifest) { for (dependency, specification) in table { if !aliases.contains(dependency) { issues.push( Issue::new( "workspace-inheritance", format!("{dependency} is not declared in workspace.dependencies"), ) .package(&package.name), ); } let Some(specification) = specification.as_table() else { continue; }; if !specification .get("workspace") .and_then(TomlValue::as_bool) .unwrap_or(false) { issues.push( Issue::new( "workspace-inheritance", format!("{dependency} must use workspace = true"), ) .package(&package.name), ); } if specification.contains_key("git") || specification.contains_key("path") { issues.push( Issue::new( "external-source", format!("{dependency} cannot use git/path directly"), ) .package(&package.name), ); } if specification.get("version").and_then(TomlValue::as_str) == Some("*") { issues.push( Issue::new( "version-policy", format!("{dependency} cannot use wildcard version"), ) .package(&package.name), ); } } } } Ok(issues) } fn check_trait_boundaries(root: &Path, policy: &Table) -> Result, AnyError> { let boundaries = toml_table(policy, "trait_boundaries")?; let required = [ ("protocol_motor", root.join("crates/protocol/motor/src/lib.rs")), ("device_motor", root.join("crates/device/motor/src/lib.rs")), ("model_motor", root.join("crates/model/motor/src/lib.rs")), ]; let mut issues = Vec::new(); for (key, path) in required { let text = fs::read_to_string(&path)?; for symbol in string_array(boundaries.get(key)) { if !declares_trait(&text, &symbol) { issues.push( Issue::new( "trait-boundary", format!("{symbol} must be declared in {}", path.display()), ) .path(&path), ); } } } let esc_path = root.join("crates/device/esc/src/lib.rs"); let esc_text = fs::read_to_string(&esc_path)?; for symbol in string_array(boundaries.get("esc_bus_forbidden_symbols")) { if contains_forbidden_symbol(&esc_text, &symbol) { issues.push( Issue::new( "trait-boundary", format!("{symbol} must not branch in esc-bus"), ) .path(&esc_path), ); } } Ok(issues) } fn external_dependencies(package: &Package) -> (BTreeSet, BTreeSet) { let mut normal = BTreeSet::new(); let mut dev = BTreeSet::new(); for dependency in &package.dependencies { if !dependency .get("source") .is_some_and(|source| !source.is_null()) { continue; } let Some(name) = dependency["name"].as_str() else { continue; }; if dependency["kind"].as_str() == Some("dev") { dev.insert(name.to_owned()); } else { normal.insert(name.to_owned()); } } (normal, dev) } fn audit(root: &Path) -> Result, AnyError> { let policy = read_toml(&root.join(POLICY_RELATIVE))?; let metadata = cargo_metadata(root)?; let packages = package_map(&metadata)?; let graph = build_graph(&packages); let mut issues = Vec::new(); let expected = policy_packages(&policy)? .keys() .cloned() .collect::>(); let actual = packages.keys().cloned().collect::>(); for name in expected.difference(&actual) { issues.push( Issue::new("workspace", format!("missing workspace package {name}")) .package(name), ); } for name in actual.difference(&expected) { issues.push( Issue::new("workspace", format!("undeclared workspace package {name}")) .package(name), ); } let layer_ranks = toml_table(&policy, "layers")? .iter() .map(|(layer, rank)| { Ok(( layer.clone(), rank.as_integer() .ok_or_else(|| format!("layer {layer} rank must be an integer"))?, )) }) .collect::, AnyError>>()?; let package_ranks = policy_packages(&policy)? .iter() .map(|(name, specification)| { let specification = specification .as_table() .ok_or_else(|| format!("package {name} policy must be a table"))?; Ok((name.clone(), toml_i64(specification, "rank")?)) }) .collect::, AnyError>>()?; for package in packages.values() { let specification = match policy_package(&policy, &package.name) { Ok(specification) => specification, Err(error) => { issues.push(Issue::new("workspace", error.to_string()).package(&package.name)); continue; } }; let expected_layer = toml_string(specification, "layer")?; let declared_layer = package_declared_layer(package); if !layer_declaration_matches(declared_layer.as_deref(), expected_layer) { issues.push( Issue::new( "layer-declaration", format!( "declared layer {:?} != policy {expected_layer}", declared_layer ), ) .package(&package.name), ); } let relative = package .package_dir .strip_prefix(root) .map_err(|_| format!("package path escapes workspace root"))? .display() .to_string(); let expected_path = toml_string(specification, "path")?; if relative != expected_path { issues.push( Issue::new( "layer-path", format!("path {relative} != policy {expected_path}"), ) .package(&package.name), ); } if !check_layer_path(expected_layer, &relative) { issues.push( Issue::new( "layer-path", format!("path does not match exactly two-part layer {expected_layer}"), ) .package(&package.name), ); } let rank = toml_i64(specification, "rank")?; if layer_ranks.get(expected_layer) != Some(&rank) { issues.push( Issue::new("layer-rank", "package rank differs from layer rank") .package(&package.name), ); } for (kind, actual_edges) in [ ("normal", graph.normal.get(&package.name).cloned().unwrap_or_default()), ("dev", graph.dev.get(&package.name).cloned().unwrap_or_default()), ] { let allowed = string_array(specification.get(kind)); issues.extend(dependency_issues( kind, &package.name, &actual_edges, &allowed, )); for target in &actual_edges { if !check_rank(&package.name, target, &package_ranks) { issues.push( Issue::new( "layer-order", format!("{kind} dependency {} -> {target} is not downward", package.name), ) .package(&package.name), ); } } } let (external, dev_external) = external_dependencies(package); for (key, actual_external) in [ ("external", external.clone()), ("dev_external", dev_external.clone()), ] { let allowed = string_array(specification.get(key)); for dependency in actual_external.difference(&allowed) { issues.push( Issue::new( "external-edge", format!("{key} dependency {dependency} is not declared"), ) .package(&package.name), ); } for dependency in allowed.difference(&actual_external) { issues.push( Issue::new( "external-edge", format!("declared {key} dependency {dependency} is absent"), ) .package(&package.name), ); } } let top_layer = expected_layer .split('/') .next() .ok_or("layer must contain a slash")?; let roles = toml_table(&policy, "external_roles")?; for dependency in external.union(&dev_external) { let Some(allowed_layers) = roles.get(dependency).and_then(TomlValue::as_array) else { issues.push( Issue::new( "external-role", format!("external dependency {dependency} has no role declaration"), ) .package(&package.name), ); continue; }; let allowed = allowed_layers .iter() .filter_map(TomlValue::as_str) .any(|layer| layer == top_layer); if !allowed { issues.push( Issue::new( "external-role", format!( "external dependency {dependency} is not allowed in {expected_layer}" ), ) .package(&package.name), ); } } } let forbidden = toml_table(&policy, "forbidden")?; let forbidden_normal = toml_table(forbidden, "normal")?; for (source, targets) in forbidden_normal { let source = source.replace('_', "-"); for target in string_array(Some(targets)) { if graph .normal .get(&source) .is_some_and(|edges| edges.contains(&target)) { issues.push( Issue::new( "forbidden-edge", format!("{source} -> {target} is forbidden"), ) .package(&source), ); } } } let mut all_edges = graph.normal.clone(); for (source, targets) in &graph.dev { all_edges.entry(source.clone()).or_default().extend(targets.clone()); } for cycle in find_cycles(&all_edges) { issues.push(Issue::new( "dependency-cycle", cycle.join(" -> "), )); } issues.extend(check_layout_and_markers(root, &packages)?); issues.extend(check_workspace_inheritance(root, &packages)?); issues.extend(check_trait_boundaries(root, &policy)?); Ok(issues) } fn git_lines(root: &Path, args: &[&str]) -> Result, AnyError> { let output = run(Command::new("git").current_dir(root).args(args))?; Ok(String::from_utf8(output.stdout)? .lines() .map(str::to_owned) .collect()) } fn is_audit_path(path: &str) -> bool { const EXACT: &[&str] = &[ "Cargo.toml", "Cargo.lock", "justfile", "ARCHITECTURE.md", "README.md", ]; const PREFIXES: &[&str] = &[ "crates/", "config/", "scripts/", ".githooks/", ".github/", "docs/", ]; EXACT.contains(&path) || PREFIXES.iter().any(|prefix| path.starts_with(prefix)) } fn staged_guard(root: &Path) -> Result, AnyError> { let staged: BTreeSet = git_lines( root, &["diff", "--cached", "--name-only", "--diff-filter=ACMR"], )? .into_iter() .collect(); let unstaged: BTreeSet = git_lines( root, &["diff", "--name-only", "--diff-filter=ACMR"], )? .into_iter() .collect(); Ok(staged .intersection(&unstaged) .filter(|path| is_audit_path(path)) .map(|path| { Issue::new( "staged-mixed", format!("{path} has both staged and unstaged changes; stage or restore it before committing"), ) .path(path) }) .collect()) } fn self_test() -> Result<(), AnyError> { let mut ranks = BTreeMap::new(); ranks.insert("device/esc".to_owned(), 4_i64); ranks.insert("control/motor".to_owned(), 6); ranks.insert("device/motor".to_owned(), 3); assert!(check_rank("device/esc", "device/motor", &ranks)); assert!(!check_rank("device/motor", "device/esc", &ranks)); assert!(!check_rank("device/esc", "device/esc", &ranks)); assert!(check_layer_path("protocol/motor", "crates/protocol/motor")); assert!(!check_layer_path( "protocol/motor", "crates/protocol/motor/motor-protocol" )); let mut edges = BTreeMap::new(); edges.insert("a".to_owned(), BTreeSet::from(["b".to_owned()])); edges.insert("b".to_owned(), BTreeSet::from(["a".to_owned()])); assert_eq!(find_cycles(&edges), vec![vec!["a", "b", "a"]]); let issues = dependency_issues( "dev", "device/esc", &BTreeSet::from(["control/motor".to_owned()]), &BTreeSet::new(), ); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, "dependency-edge"); assert!(layer_declaration_matches(Some("protocol/motor"), "protocol/motor")); assert!(!layer_declaration_matches(Some("device/motor"), "protocol/motor")); assert!(contains_forbidden_marker("use libc::c_int;")); assert!(!contains_forbidden_marker("use std::num::NonZeroU8;")); assert!(declares_trait("pub trait MotorProtocol {}", "MotorProtocol")); assert!(!declares_trait("struct MotorProtocol;", "MotorProtocol")); assert!(contains_forbidden_symbol("match EscModel {}", "EscModel")); println!("audit-rules self-test: ok"); Ok(()) } fn parse_args() -> Result<(bool, bool, String), AnyError> { let mut staged = false; let mut self_test_flag = false; let mut format = "text".to_owned(); let mut args = env::args().skip(1); while let Some(argument) = args.next() { match argument.as_str() { "--staged" => staged = true, "--self-test" => self_test_flag = true, "--format" => { format = args .next() .ok_or("--format requires text or json")?; if format != "text" && format != "json" { return Err("--format must be text or json".into()); } } other => return Err(format!("unknown argument: {other}").into()), } } Ok((staged, self_test_flag, format)) } fn main() -> Result<(), AnyError> { let (staged, self_test_flag, format) = parse_args()?; if self_test_flag { return self_test(); } let root = root_dir()?; let mut issues = if staged { staged_guard(&root)? } else { Vec::new() }; match audit(&root) { Ok(mut audit_issues) => issues.append(&mut audit_issues), Err(error) => issues.push(Issue::new("audit-failed", error.to_string())), } if format == "json" { println!("{}", serde_json::to_string_pretty(&issues)?); } else { for issue in &issues { let package = issue .package .as_ref() .map(|package| format!(" ({package})")) .unwrap_or_default(); let path = issue .path .as_ref() .map(|path| format!(" [{path}]")) .unwrap_or_default(); println!("{}{package}{path}: {}", issue.code, issue.message); } } if issues.is_empty() { Ok(()) } else { Err("audit rules found violations".into()) } }