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