1use std::path::{Path, PathBuf};
2
3use rustc_hash::FxHashSet;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::config::Severity;
8use crate::config::glob_validation::compile_user_glob;
9
10const RULE_PACK_EXTENSIONS: &[&str] = &["json", "jsonc"];
14
15const SUPPORTED_PACK_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
20#[serde(rename_all = "kebab-case")]
21pub enum RulePackRuleKind {
22 BannedCall,
24 BannedImport,
27}
28
29#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
35#[serde(deny_unknown_fields, rename_all = "camelCase")]
36pub struct RulePackRule {
37 pub id: String,
40 pub kind: RulePackRuleKind,
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub callees: Vec<String>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub specifiers: Vec<String>,
55 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
59 pub ignore_type_only: bool,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub files: Vec<String>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub exclude: Vec<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub message: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub severity: Option<Severity>,
76}
77
78#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(deny_unknown_fields, rename_all = "camelCase")]
111pub struct RulePackDef {
112 #[serde(rename = "$schema", default, skip_serializing)]
114 #[schemars(skip)]
115 pub schema: Option<String>,
116 pub version: u32,
119 pub name: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub description: Option<String>,
125 pub rules: Vec<RulePackRule>,
128}
129
130impl RulePackDef {
131 #[must_use]
134 pub fn json_schema() -> serde_json::Value {
135 serde_json::to_value(schemars::schema_for!(RulePackDef)).unwrap_or_default()
136 }
137}
138
139#[derive(Debug, Clone)]
142pub struct RulePackError {
143 pub path: PathBuf,
145 pub message: String,
147}
148
149impl std::fmt::Display for RulePackError {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 write!(f, "{}: {}", self.path.display(), self.message)
152 }
153}
154
155pub fn load_rule_packs(
167 root: &Path,
168 pack_paths: &[String],
169) -> Result<Vec<RulePackDef>, Vec<RulePackError>> {
170 let mut packs = Vec::new();
171 let mut errors = Vec::new();
172 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
173
174 for path_str in pack_paths {
175 let path = root.join(path_str);
176 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
177 if !RULE_PACK_EXTENSIONS.contains(&ext) {
178 errors.push(RulePackError {
179 path: path.clone(),
180 message: format!(
181 "unsupported rule pack extension '.{ext}'; expected .json or .jsonc"
182 ),
183 });
184 continue;
185 }
186 let content = match std::fs::read_to_string(&path) {
187 Ok(content) => content,
188 Err(e) => {
189 errors.push(RulePackError {
190 path: path.clone(),
191 message: format!("failed to read rule pack: {e}"),
192 });
193 continue;
194 }
195 };
196 if !crate::external_plugin::is_within_root(&path, &canonical_root) {
199 errors.push(RulePackError {
200 path: path.clone(),
201 message: "resolves outside the project root".to_owned(),
202 });
203 continue;
204 }
205 let parsed: Result<RulePackDef, String> = if ext == "jsonc" {
206 crate::jsonc::parse_to_value::<RulePackDef>(&content).map_err(|e| e.to_string())
207 } else {
208 serde_json::from_str::<RulePackDef>(&content).map_err(|e| e.to_string())
209 };
210 match parsed {
211 Ok(pack) => {
212 let before = errors.len();
213 validate_pack(&pack, &path, &mut errors);
214 if errors.len() == before {
215 packs.push(pack);
216 }
217 }
218 Err(message) => {
219 errors.push(RulePackError {
220 path: path.clone(),
221 message: format!("failed to parse rule pack: {message}"),
222 });
223 }
224 }
225 }
226
227 let mut seen_names: FxHashSet<&str> = FxHashSet::default();
228 for pack in &packs {
229 if !seen_names.insert(pack.name.as_str()) {
230 errors.push(RulePackError {
231 path: root.to_path_buf(),
232 message: format!(
233 "rule pack name '{}' is declared by more than one pack; pack names must be \
234 unique because findings are identified as '<pack>/<rule-id>'",
235 pack.name
236 ),
237 });
238 }
239 }
240
241 if errors.is_empty() {
242 Ok(packs)
243 } else {
244 Err(errors)
245 }
246}
247
248fn validate_pack(pack: &RulePackDef, path: &Path, errors: &mut Vec<RulePackError>) {
251 let err = |message: String| RulePackError {
252 path: path.to_path_buf(),
253 message,
254 };
255
256 if pack.version != SUPPORTED_PACK_VERSION {
257 errors.push(err(format!(
258 "unsupported rule pack version {}; this fallow build supports version \
259 {SUPPORTED_PACK_VERSION}",
260 pack.version
261 )));
262 }
263 if pack.name.trim().is_empty() {
264 errors.push(err("pack `name` must not be empty".to_owned()));
265 }
266 if pack.rules.is_empty() {
267 errors.push(err(
268 "pack declares no rules; an empty pack would silently enforce nothing".to_owned(),
269 ));
270 }
271
272 let mut seen_ids: FxHashSet<&str> = FxHashSet::default();
273 for rule in &pack.rules {
274 if rule.id.trim().is_empty() {
275 errors.push(err("rule `id` must not be empty".to_owned()));
276 continue;
277 }
278 if !seen_ids.insert(rule.id.as_str()) {
279 errors.push(err(format!(
280 "duplicate rule id '{}'; rule ids must be unique within a pack",
281 rule.id
282 )));
283 }
284 validate_rule(rule, path, errors);
285 }
286}
287
288fn validate_rule(rule: &RulePackRule, path: &Path, errors: &mut Vec<RulePackError>) {
290 let err = |message: String| RulePackError {
291 path: path.to_path_buf(),
292 message: format!("rule '{}': {message}", rule.id),
293 };
294
295 match rule.kind {
296 RulePackRuleKind::BannedCall => {
297 if rule.callees.is_empty() {
298 errors.push(err(
299 "banned-call rules must list at least one `callees` pattern".to_owned(),
300 ));
301 }
302 if !rule.specifiers.is_empty() {
303 errors.push(err(
304 "`specifiers` applies only to banned-import rules".to_owned()
305 ));
306 }
307 if rule.ignore_type_only {
308 errors.push(err(
309 "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
310 ));
311 }
312 for pattern in &rule.callees {
313 if let Some(reason) = callee_pattern_error(pattern) {
314 errors.push(err(format!("callee pattern `{pattern}` {reason}")));
315 }
316 }
317 }
318 RulePackRuleKind::BannedImport => {
319 if rule.specifiers.is_empty() {
320 errors.push(err(
321 "banned-import rules must list at least one `specifiers` entry".to_owned(),
322 ));
323 }
324 if !rule.callees.is_empty() {
325 errors.push(err("`callees` applies only to banned-call rules".to_owned()));
326 }
327 for specifier in &rule.specifiers {
328 if specifier.trim().is_empty() {
329 errors.push(err("specifier must not be empty".to_owned()));
330 } else if specifier.contains('*') {
331 errors.push(err(format!(
332 "specifier `{specifier}` contains `*`; specifier matching is \
333 segment-aware, not glob. List the package or path prefix; subpaths are \
334 covered automatically"
335 )));
336 }
337 }
338 }
339 }
340
341 for (field, patterns) in [("files", &rule.files), ("exclude", &rule.exclude)] {
342 for pattern in patterns {
343 if let Err(e) = compile_user_glob(pattern, "rulePacks rules[].files/exclude") {
344 errors.push(err(format!("invalid `{field}` glob `{pattern}`: {e}")));
345 }
346 }
347 }
348}
349
350fn callee_pattern_error(pattern: &str) -> Option<String> {
353 let trimmed = pattern.trim();
354 if trimmed.is_empty() {
355 return Some("must not be empty".to_owned());
356 }
357 if trimmed == "*" {
358 return Some(
359 "matches nothing: a bare `*` has no callee segments. Name a specific callee such as \
360 `console.*` or `child_process.exec`"
361 .to_owned(),
362 );
363 }
364 if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
365 return Some("contains an empty path segment".to_owned());
366 }
367 crate::config::wildcard_placement_error(trimmed)
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn write_pack(dir: &Path, name: &str, content: &str) -> String {
375 std::fs::write(dir.join(name), content).unwrap();
376 name.to_owned()
377 }
378
379 fn valid_pack_json() -> &'static str {
380 r#"{
381 "version": 1,
382 "name": "team-policy",
383 "description": "House rules",
384 "rules": [
385 {
386 "id": "no-child-process",
387 "kind": "banned-call",
388 "callees": ["child_process.*", "execa"],
389 "files": ["src/**"],
390 "exclude": ["src/tooling/**"],
391 "message": "Use the sandboxed runner instead.",
392 "severity": "error"
393 },
394 {
395 "id": "no-moment",
396 "kind": "banned-import",
397 "specifiers": ["moment"],
398 "ignoreTypeOnly": true,
399 "message": "Use date-fns."
400 }
401 ]
402 }"#
403 }
404
405 #[test]
406 fn loads_valid_json_pack() {
407 let dir = tempfile::tempdir().unwrap();
408 let path = write_pack(dir.path(), "policy.json", valid_pack_json());
409 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
410 assert_eq!(packs.len(), 1);
411 assert_eq!(packs[0].name, "team-policy");
412 assert_eq!(packs[0].rules.len(), 2);
413 assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedCall);
414 assert_eq!(packs[0].rules[0].severity, Some(Severity::Error));
415 assert_eq!(packs[0].rules[1].kind, RulePackRuleKind::BannedImport);
416 assert!(packs[0].rules[1].ignore_type_only);
417 assert_eq!(packs[0].rules[1].severity, None);
418 }
419
420 #[test]
421 fn loads_jsonc_pack_with_comments() {
422 let dir = tempfile::tempdir().unwrap();
423 let path = write_pack(
424 dir.path(),
425 "policy.jsonc",
426 r#"{
427 // why: keep the domain layer pure
428 "version": 1,
429 "name": "jsonc-policy",
430 "rules": [
431 { "id": "no-console", "kind": "banned-call", "callees": ["console.*"] },
432 ]
433 }"#,
434 );
435 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
436 assert_eq!(packs[0].name, "jsonc-policy");
437 }
438
439 #[test]
440 fn rejects_unsupported_version() {
441 let dir = tempfile::tempdir().unwrap();
442 let path = write_pack(
443 dir.path(),
444 "policy.json",
445 r#"{ "version": 2, "name": "p", "rules": [
446 { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
447 ] }"#,
448 );
449 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
450 assert!(
451 errors[0]
452 .message
453 .contains("unsupported rule pack version 2")
454 );
455 }
456
457 #[test]
458 fn rejects_unknown_kind_with_expected_list() {
459 let dir = tempfile::tempdir().unwrap();
460 let path = write_pack(
461 dir.path(),
462 "policy.json",
463 r#"{ "version": 1, "name": "p", "rules": [
464 { "id": "a", "kind": "banned-effect", "callees": ["fetch"] }
465 ] }"#,
466 );
467 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
468 assert!(errors[0].message.contains("banned-effect"));
469 assert!(errors[0].message.contains("banned-call"));
470 assert!(errors[0].message.contains("banned-import"));
471 }
472
473 #[test]
474 fn rejects_unknown_field() {
475 let dir = tempfile::tempdir().unwrap();
476 let path = write_pack(
477 dir.path(),
478 "policy.json",
479 r#"{ "version": 1, "name": "p", "rules": [
480 { "id": "a", "kind": "banned-call", "callees": ["fetch"], "file": ["src/**"] }
481 ] }"#,
482 );
483 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
484 assert!(errors[0].message.contains("file"));
485 }
486
487 #[test]
488 fn rejects_empty_rules_and_empty_pack_name() {
489 let dir = tempfile::tempdir().unwrap();
490 let path = write_pack(
491 dir.path(),
492 "policy.json",
493 r#"{ "version": 1, "name": " ", "rules": [] }"#,
494 );
495 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
496 let joined = errors
497 .iter()
498 .map(|e| e.message.clone())
499 .collect::<Vec<_>>()
500 .join("\n");
501 assert!(joined.contains("declares no rules"));
502 assert!(joined.contains("`name` must not be empty"));
503 }
504
505 #[test]
506 fn rejects_duplicate_rule_ids_within_pack() {
507 let dir = tempfile::tempdir().unwrap();
508 let path = write_pack(
509 dir.path(),
510 "policy.json",
511 r#"{ "version": 1, "name": "p", "rules": [
512 { "id": "a", "kind": "banned-call", "callees": ["fetch"] },
513 { "id": "a", "kind": "banned-import", "specifiers": ["moment"] }
514 ] }"#,
515 );
516 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
517 assert!(errors[0].message.contains("duplicate rule id 'a'"));
518 }
519
520 #[test]
521 fn rejects_duplicate_pack_names() {
522 let dir = tempfile::tempdir().unwrap();
523 let a = write_pack(
524 dir.path(),
525 "a.json",
526 r#"{ "version": 1, "name": "p", "rules": [
527 { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
528 ] }"#,
529 );
530 let b = write_pack(
531 dir.path(),
532 "b.json",
533 r#"{ "version": 1, "name": "p", "rules": [
534 { "id": "b", "kind": "banned-call", "callees": ["eval"] }
535 ] }"#,
536 );
537 let errors = load_rule_packs(dir.path(), &[a, b]).unwrap_err();
538 assert!(errors[0].message.contains("rule pack name 'p'"));
539 }
540
541 #[test]
542 fn rejects_cross_kind_fields() {
543 let dir = tempfile::tempdir().unwrap();
544 let path = write_pack(
545 dir.path(),
546 "policy.json",
547 r#"{ "version": 1, "name": "p", "rules": [
548 { "id": "a", "kind": "banned-call", "callees": ["fetch"],
549 "specifiers": ["moment"], "ignoreTypeOnly": true },
550 { "id": "b", "kind": "banned-import", "specifiers": ["moment"],
551 "callees": ["fetch"] }
552 ] }"#,
553 );
554 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
555 let joined = errors
556 .iter()
557 .map(|e| e.message.clone())
558 .collect::<Vec<_>>()
559 .join("\n");
560 assert!(joined.contains("`specifiers` applies only to banned-import"));
561 assert!(joined.contains("`ignoreTypeOnly` applies only to banned-import"));
562 assert!(joined.contains("`callees` applies only to banned-call"));
563 }
564
565 #[test]
566 fn rejects_missing_kind_fields() {
567 let dir = tempfile::tempdir().unwrap();
568 let path = write_pack(
569 dir.path(),
570 "policy.json",
571 r#"{ "version": 1, "name": "p", "rules": [
572 { "id": "a", "kind": "banned-call" },
573 { "id": "b", "kind": "banned-import" }
574 ] }"#,
575 );
576 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
577 let joined = errors
578 .iter()
579 .map(|e| e.message.clone())
580 .collect::<Vec<_>>()
581 .join("\n");
582 assert!(joined.contains("must list at least one `callees` pattern"));
583 assert!(joined.contains("must list at least one `specifiers` entry"));
584 }
585
586 #[test]
587 fn rejects_inert_callee_patterns() {
588 let dir = tempfile::tempdir().unwrap();
589 let path = write_pack(
590 dir.path(),
591 "policy.json",
592 r#"{ "version": 1, "name": "p", "rules": [
593 { "id": "a", "kind": "banned-call",
594 "callees": ["*", "a..b", "child*", "a.*.b"] }
595 ] }"#,
596 );
597 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
598 assert_eq!(errors.len(), 4);
599 }
600
601 #[test]
602 fn rejects_glob_specifiers() {
603 let dir = tempfile::tempdir().unwrap();
604 let path = write_pack(
605 dir.path(),
606 "policy.json",
607 r#"{ "version": 1, "name": "p", "rules": [
608 { "id": "a", "kind": "banned-import", "specifiers": ["moment/**"] }
609 ] }"#,
610 );
611 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
612 assert!(errors[0].message.contains("segment-aware, not glob"));
613 }
614
615 #[test]
616 fn rejects_traversal_globs() {
617 let dir = tempfile::tempdir().unwrap();
618 let path = write_pack(
619 dir.path(),
620 "policy.json",
621 r#"{ "version": 1, "name": "p", "rules": [
622 { "id": "a", "kind": "banned-call", "callees": ["fetch"],
623 "files": ["../outside/**"] }
624 ] }"#,
625 );
626 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
627 assert!(errors[0].message.contains("invalid `files` glob"));
628 }
629
630 #[test]
631 fn rejects_missing_pack_file_and_bad_extension() {
632 let dir = tempfile::tempdir().unwrap();
633 write_pack(dir.path(), "policy.toml", "version = 1");
634 let errors = load_rule_packs(
635 dir.path(),
636 &["missing.json".to_owned(), "policy.toml".to_owned()],
637 )
638 .unwrap_err();
639 assert_eq!(errors.len(), 2);
640 assert!(errors[0].message.contains("failed to read rule pack"));
641 assert!(
642 errors[1]
643 .message
644 .contains("unsupported rule pack extension")
645 );
646 }
647
648 #[test]
649 fn rejects_paths_outside_root() {
650 let dir = tempfile::tempdir().unwrap();
651 let inner = dir.path().join("project");
652 std::fs::create_dir_all(&inner).unwrap();
653 std::fs::write(
654 dir.path().join("outside.json"),
655 r#"{ "version": 1, "name": "p", "rules": [
656 { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
657 ] }"#,
658 )
659 .unwrap();
660 let errors = load_rule_packs(&inner, &["../outside.json".to_owned()]).unwrap_err();
661 assert!(errors[0].message.contains("outside the project root"));
662 }
663
664 #[test]
665 fn schema_validates_doc_example_shape() {
666 let schema = RulePackDef::json_schema();
667 let properties = schema
668 .get("properties")
669 .and_then(|p| p.as_object())
670 .expect("schema should expose properties");
671 assert!(properties.contains_key("version"));
672 assert!(properties.contains_key("name"));
673 assert!(properties.contains_key("rules"));
674
675 let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
678 assert_eq!(pack.version, 1);
679 }
680}