use std::path::{Path, PathBuf};
use rustc_hash::FxHashSet;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::config::Severity;
use crate::config::glob_validation::compile_user_glob;
const RULE_PACK_EXTENSIONS: &[&str] = &["json", "jsonc"];
const SUPPORTED_PACK_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum RulePackRuleKind {
BannedCall,
BannedImport,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RulePackRule {
pub id: String,
pub kind: RulePackRuleKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub callees: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub specifiers: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ignore_type_only: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub severity: Option<Severity>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RulePackDef {
#[serde(rename = "$schema", default, skip_serializing)]
#[schemars(skip)]
pub schema: Option<String>,
pub version: u32,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub rules: Vec<RulePackRule>,
}
impl RulePackDef {
#[must_use]
pub fn json_schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(RulePackDef)).unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct RulePackError {
pub path: PathBuf,
pub message: String,
}
impl std::fmt::Display for RulePackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.path.display(), self.message)
}
}
pub fn load_rule_packs(
root: &Path,
pack_paths: &[String],
) -> Result<Vec<RulePackDef>, Vec<RulePackError>> {
let mut packs = Vec::new();
let mut errors = Vec::new();
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
for path_str in pack_paths {
let path = root.join(path_str);
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !RULE_PACK_EXTENSIONS.contains(&ext) {
errors.push(RulePackError {
path: path.clone(),
message: format!(
"unsupported rule pack extension '.{ext}'; expected .json or .jsonc"
),
});
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(e) => {
errors.push(RulePackError {
path: path.clone(),
message: format!("failed to read rule pack: {e}"),
});
continue;
}
};
if !crate::external_plugin::is_within_root(&path, &canonical_root) {
errors.push(RulePackError {
path: path.clone(),
message: "resolves outside the project root".to_owned(),
});
continue;
}
let parsed: Result<RulePackDef, String> = if ext == "jsonc" {
crate::jsonc::parse_to_value::<RulePackDef>(&content).map_err(|e| e.to_string())
} else {
serde_json::from_str::<RulePackDef>(&content).map_err(|e| e.to_string())
};
match parsed {
Ok(pack) => {
let before = errors.len();
validate_pack(&pack, &path, &mut errors);
if errors.len() == before {
packs.push(pack);
}
}
Err(message) => {
errors.push(RulePackError {
path: path.clone(),
message: format!("failed to parse rule pack: {message}"),
});
}
}
}
let mut seen_names: FxHashSet<&str> = FxHashSet::default();
for pack in &packs {
if !seen_names.insert(pack.name.as_str()) {
errors.push(RulePackError {
path: root.to_path_buf(),
message: format!(
"rule pack name '{}' is declared by more than one pack; pack names must be \
unique because findings are identified as '<pack>/<rule-id>'",
pack.name
),
});
}
}
if errors.is_empty() {
Ok(packs)
} else {
Err(errors)
}
}
fn validate_pack(pack: &RulePackDef, path: &Path, errors: &mut Vec<RulePackError>) {
let err = |message: String| RulePackError {
path: path.to_path_buf(),
message,
};
if pack.version != SUPPORTED_PACK_VERSION {
errors.push(err(format!(
"unsupported rule pack version {}; this fallow build supports version \
{SUPPORTED_PACK_VERSION}",
pack.version
)));
}
if pack.name.trim().is_empty() {
errors.push(err("pack `name` must not be empty".to_owned()));
}
if pack.rules.is_empty() {
errors.push(err(
"pack declares no rules; an empty pack would silently enforce nothing".to_owned(),
));
}
let mut seen_ids: FxHashSet<&str> = FxHashSet::default();
for rule in &pack.rules {
if rule.id.trim().is_empty() {
errors.push(err("rule `id` must not be empty".to_owned()));
continue;
}
if !seen_ids.insert(rule.id.as_str()) {
errors.push(err(format!(
"duplicate rule id '{}'; rule ids must be unique within a pack",
rule.id
)));
}
validate_rule(rule, path, errors);
}
}
fn validate_rule(rule: &RulePackRule, path: &Path, errors: &mut Vec<RulePackError>) {
let err = |message: String| RulePackError {
path: path.to_path_buf(),
message: format!("rule '{}': {message}", rule.id),
};
match rule.kind {
RulePackRuleKind::BannedCall => {
if rule.callees.is_empty() {
errors.push(err(
"banned-call rules must list at least one `callees` pattern".to_owned(),
));
}
if !rule.specifiers.is_empty() {
errors.push(err(
"`specifiers` applies only to banned-import rules".to_owned()
));
}
if rule.ignore_type_only {
errors.push(err(
"`ignoreTypeOnly` applies only to banned-import rules".to_owned()
));
}
for pattern in &rule.callees {
if let Some(reason) = callee_pattern_error(pattern) {
errors.push(err(format!("callee pattern `{pattern}` {reason}")));
}
}
}
RulePackRuleKind::BannedImport => {
if rule.specifiers.is_empty() {
errors.push(err(
"banned-import rules must list at least one `specifiers` entry".to_owned(),
));
}
if !rule.callees.is_empty() {
errors.push(err("`callees` applies only to banned-call rules".to_owned()));
}
for specifier in &rule.specifiers {
if specifier.trim().is_empty() {
errors.push(err("specifier must not be empty".to_owned()));
} else if specifier.contains('*') {
errors.push(err(format!(
"specifier `{specifier}` contains `*`; specifier matching is \
segment-aware, not glob. List the package or path prefix; subpaths are \
covered automatically"
)));
}
}
}
}
for (field, patterns) in [("files", &rule.files), ("exclude", &rule.exclude)] {
for pattern in patterns {
if let Err(e) = compile_user_glob(pattern, "rulePacks rules[].files/exclude") {
errors.push(err(format!("invalid `{field}` glob `{pattern}`: {e}")));
}
}
}
}
fn callee_pattern_error(pattern: &str) -> Option<String> {
let trimmed = pattern.trim();
if trimmed.is_empty() {
return Some("must not be empty".to_owned());
}
if trimmed == "*" {
return Some(
"matches nothing: a bare `*` has no callee segments. Name a specific callee such as \
`console.*` or `child_process.exec`"
.to_owned(),
);
}
if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
return Some("contains an empty path segment".to_owned());
}
crate::config::wildcard_placement_error(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_pack(dir: &Path, name: &str, content: &str) -> String {
std::fs::write(dir.join(name), content).unwrap();
name.to_owned()
}
fn valid_pack_json() -> &'static str {
r#"{
"version": 1,
"name": "team-policy",
"description": "House rules",
"rules": [
{
"id": "no-child-process",
"kind": "banned-call",
"callees": ["child_process.*", "execa"],
"files": ["src/**"],
"exclude": ["src/tooling/**"],
"message": "Use the sandboxed runner instead.",
"severity": "error"
},
{
"id": "no-moment",
"kind": "banned-import",
"specifiers": ["moment"],
"ignoreTypeOnly": true,
"message": "Use date-fns."
}
]
}"#
}
#[test]
fn loads_valid_json_pack() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(dir.path(), "policy.json", valid_pack_json());
let packs = load_rule_packs(dir.path(), &[path]).unwrap();
assert_eq!(packs.len(), 1);
assert_eq!(packs[0].name, "team-policy");
assert_eq!(packs[0].rules.len(), 2);
assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedCall);
assert_eq!(packs[0].rules[0].severity, Some(Severity::Error));
assert_eq!(packs[0].rules[1].kind, RulePackRuleKind::BannedImport);
assert!(packs[0].rules[1].ignore_type_only);
assert_eq!(packs[0].rules[1].severity, None);
}
#[test]
fn loads_jsonc_pack_with_comments() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.jsonc",
r#"{
// why: keep the domain layer pure
"version": 1,
"name": "jsonc-policy",
"rules": [
{ "id": "no-console", "kind": "banned-call", "callees": ["console.*"] },
]
}"#,
);
let packs = load_rule_packs(dir.path(), &[path]).unwrap();
assert_eq!(packs[0].name, "jsonc-policy");
}
#[test]
fn rejects_unsupported_version() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 2, "name": "p", "rules": [
{ "id": "a", "kind": "banned-call", "callees": ["fetch"] }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
assert!(
errors[0]
.message
.contains("unsupported rule pack version 2")
);
}
#[test]
fn rejects_unknown_kind_with_expected_list() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-effect", "callees": ["fetch"] }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
assert!(errors[0].message.contains("banned-effect"));
assert!(errors[0].message.contains("banned-call"));
assert!(errors[0].message.contains("banned-import"));
}
#[test]
fn rejects_unknown_field() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-call", "callees": ["fetch"], "file": ["src/**"] }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
assert!(errors[0].message.contains("file"));
}
#[test]
fn rejects_empty_rules_and_empty_pack_name() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 1, "name": " ", "rules": [] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
let joined = errors
.iter()
.map(|e| e.message.clone())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("declares no rules"));
assert!(joined.contains("`name` must not be empty"));
}
#[test]
fn rejects_duplicate_rule_ids_within_pack() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-call", "callees": ["fetch"] },
{ "id": "a", "kind": "banned-import", "specifiers": ["moment"] }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
assert!(errors[0].message.contains("duplicate rule id 'a'"));
}
#[test]
fn rejects_duplicate_pack_names() {
let dir = tempfile::tempdir().unwrap();
let a = write_pack(
dir.path(),
"a.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-call", "callees": ["fetch"] }
] }"#,
);
let b = write_pack(
dir.path(),
"b.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "b", "kind": "banned-call", "callees": ["eval"] }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[a, b]).unwrap_err();
assert!(errors[0].message.contains("rule pack name 'p'"));
}
#[test]
fn rejects_cross_kind_fields() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-call", "callees": ["fetch"],
"specifiers": ["moment"], "ignoreTypeOnly": true },
{ "id": "b", "kind": "banned-import", "specifiers": ["moment"],
"callees": ["fetch"] }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
let joined = errors
.iter()
.map(|e| e.message.clone())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("`specifiers` applies only to banned-import"));
assert!(joined.contains("`ignoreTypeOnly` applies only to banned-import"));
assert!(joined.contains("`callees` applies only to banned-call"));
}
#[test]
fn rejects_missing_kind_fields() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-call" },
{ "id": "b", "kind": "banned-import" }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
let joined = errors
.iter()
.map(|e| e.message.clone())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("must list at least one `callees` pattern"));
assert!(joined.contains("must list at least one `specifiers` entry"));
}
#[test]
fn rejects_inert_callee_patterns() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-call",
"callees": ["*", "a..b", "child*", "a.*.b"] }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
assert_eq!(errors.len(), 4);
}
#[test]
fn rejects_glob_specifiers() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-import", "specifiers": ["moment/**"] }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
assert!(errors[0].message.contains("segment-aware, not glob"));
}
#[test]
fn rejects_traversal_globs() {
let dir = tempfile::tempdir().unwrap();
let path = write_pack(
dir.path(),
"policy.json",
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-call", "callees": ["fetch"],
"files": ["../outside/**"] }
] }"#,
);
let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
assert!(errors[0].message.contains("invalid `files` glob"));
}
#[test]
fn rejects_missing_pack_file_and_bad_extension() {
let dir = tempfile::tempdir().unwrap();
write_pack(dir.path(), "policy.toml", "version = 1");
let errors = load_rule_packs(
dir.path(),
&["missing.json".to_owned(), "policy.toml".to_owned()],
)
.unwrap_err();
assert_eq!(errors.len(), 2);
assert!(errors[0].message.contains("failed to read rule pack"));
assert!(
errors[1]
.message
.contains("unsupported rule pack extension")
);
}
#[test]
fn rejects_paths_outside_root() {
let dir = tempfile::tempdir().unwrap();
let inner = dir.path().join("project");
std::fs::create_dir_all(&inner).unwrap();
std::fs::write(
dir.path().join("outside.json"),
r#"{ "version": 1, "name": "p", "rules": [
{ "id": "a", "kind": "banned-call", "callees": ["fetch"] }
] }"#,
)
.unwrap();
let errors = load_rule_packs(&inner, &["../outside.json".to_owned()]).unwrap_err();
assert!(errors[0].message.contains("outside the project root"));
}
#[test]
fn schema_validates_doc_example_shape() {
let schema = RulePackDef::json_schema();
let properties = schema
.get("properties")
.and_then(|p| p.as_object())
.expect("schema should expose properties");
assert!(properties.contains_key("version"));
assert!(properties.contains_key("name"));
assert!(properties.contains_key("rules"));
let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
assert_eq!(pack.version, 1);
}
}