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::glob_validation::compile_user_glob;
9use crate::config::{BoundaryConfig, Severity};
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 BannedEffect,
30 BannedExport,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
36#[serde(rename_all = "kebab-case")]
37pub enum EffectKind {
38 Pure,
40 Read,
42 Write,
44 Network,
46 Storage,
48 Process,
50 Shell,
52 Crypto,
54 Randomness,
56 Dom,
58 Database,
60 FrameworkCallback,
62 Unknown,
64}
65
66impl EffectKind {
67 #[must_use]
70 pub const fn as_str(self) -> &'static str {
71 match self {
72 Self::Pure => "pure",
73 Self::Read => "read",
74 Self::Write => "write",
75 Self::Network => "network",
76 Self::Storage => "storage",
77 Self::Process => "process",
78 Self::Shell => "shell",
79 Self::Crypto => "crypto",
80 Self::Randomness => "randomness",
81 Self::Dom => "dom",
82 Self::Database => "database",
83 Self::FrameworkCallback => "framework-callback",
84 Self::Unknown => "unknown",
85 }
86 }
87}
88
89#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
98#[serde(deny_unknown_fields, rename_all = "camelCase")]
99pub struct RulePackRule {
100 pub id: String,
104 pub kind: RulePackRuleKind,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub callees: Vec<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub specifiers: Vec<String>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub effects: Vec<EffectKind>,
126 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub exports: Vec<String>,
132 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
137 pub ignore_type_only: bool,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub files: Vec<String>,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub exclude: Vec<String>,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub zones: Vec<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub message: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub severity: Option<Severity>,
159}
160
161#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
200#[serde(deny_unknown_fields, rename_all = "camelCase")]
201pub struct RulePackDef {
202 #[serde(rename = "$schema", default, skip_serializing)]
204 #[schemars(skip)]
205 pub schema: Option<String>,
206 pub version: u32,
209 pub name: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub description: Option<String>,
216 pub rules: Vec<RulePackRule>,
219}
220
221impl RulePackDef {
222 #[must_use]
225 pub fn json_schema() -> serde_json::Value {
226 serde_json::to_value(schemars::schema_for!(RulePackDef)).unwrap_or_default()
227 }
228}
229
230#[derive(Debug, Clone)]
233pub struct RulePackError {
234 pub path: PathBuf,
236 pub message: String,
238}
239
240impl std::fmt::Display for RulePackError {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 write!(f, "{}: {}", self.path.display(), self.message)
243 }
244}
245
246pub fn load_rule_packs(
258 root: &Path,
259 pack_paths: &[String],
260) -> Result<Vec<RulePackDef>, Vec<RulePackError>> {
261 let mut packs = Vec::new();
262 let mut errors = Vec::new();
263 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
264
265 for path_str in pack_paths {
266 load_one_rule_pack(root, path_str, &canonical_root, &mut packs, &mut errors);
267 }
268
269 push_duplicate_pack_name_errors(root, &packs, &mut errors);
270
271 if errors.is_empty() {
272 Ok(packs)
273 } else {
274 Err(errors)
275 }
276}
277
278#[must_use]
286pub fn validate_rule_pack_zones(
287 root: &Path,
288 boundaries: &BoundaryConfig,
289 pack_paths: &[String],
290 packs: &[RulePackDef],
291) -> Vec<RulePackError> {
292 if packs
293 .iter()
294 .all(|pack| pack.rules.iter().all(|rule| rule.zones.is_empty()))
295 {
296 return Vec::new();
297 }
298 let zone_names = expanded_zone_names(boundaries.clone(), root);
299 zone_reference_errors(root, pack_paths, packs, &zone_names)
300}
301
302fn expanded_zone_names(mut boundaries: BoundaryConfig, root: &Path) -> Vec<String> {
303 if boundaries.preset.is_some() {
304 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
305 .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
306 .unwrap_or_else(|| "src".to_owned());
307 boundaries.expand(&source_root);
308 }
309 let _logical_groups = boundaries.expand_auto_discover(root);
310 boundaries.zones.into_iter().map(|zone| zone.name).collect()
311}
312
313fn zone_reference_errors(
314 root: &Path,
315 pack_paths: &[String],
316 packs: &[RulePackDef],
317 zone_names: &[String],
318) -> Vec<RulePackError> {
319 let configured_zones: FxHashSet<&str> = zone_names.iter().map(String::as_str).collect();
320 let configured_zone_list = if configured_zones.is_empty() {
321 "none".to_owned()
322 } else {
323 let mut zones: Vec<&str> = configured_zones.iter().copied().collect();
324 zones.sort_unstable();
325 zones.join(", ")
326 };
327
328 let mut errors = Vec::new();
329 for (pack_index, pack) in packs.iter().enumerate() {
330 let path = pack_paths
331 .get(pack_index)
332 .map_or_else(|| root.to_path_buf(), |path| root.join(path));
333 for rule in &pack.rules {
334 if rule.zones.is_empty() {
335 continue;
336 }
337 if configured_zones.is_empty() {
338 errors.push(RulePackError {
339 path: path.clone(),
340 message: format!(
341 "rule '{}': `zones` requires configured boundary zones, but none are configured",
342 rule.id
343 ),
344 });
345 continue;
346 }
347 for zone in &rule.zones {
348 if !configured_zones.contains(zone.as_str()) {
349 errors.push(RulePackError {
350 path: path.clone(),
351 message: format!(
352 "rule '{}': unknown zone '{}' in `zones`; configured zones: {}",
353 rule.id, zone, configured_zone_list
354 ),
355 });
356 }
357 }
358 }
359 }
360 errors
361}
362
363fn load_one_rule_pack(
365 root: &Path,
366 path_str: &str,
367 canonical_root: &Path,
368 packs: &mut Vec<RulePackDef>,
369 errors: &mut Vec<RulePackError>,
370) {
371 let path = root.join(path_str);
372 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
373 if !RULE_PACK_EXTENSIONS.contains(&ext) {
374 errors.push(RulePackError {
375 path: path.clone(),
376 message: format!("unsupported rule pack extension '.{ext}'; expected .json or .jsonc"),
377 });
378 return;
379 }
380 let content = match std::fs::read_to_string(&path) {
381 Ok(content) => content,
382 Err(e) => {
383 errors.push(RulePackError {
384 path,
385 message: format!("failed to read rule pack: {e}"),
386 });
387 return;
388 }
389 };
390 if !crate::external_plugin::is_within_root(&path, canonical_root) {
393 errors.push(RulePackError {
394 path,
395 message: "resolves outside the project root".to_owned(),
396 });
397 return;
398 }
399 let parsed: Result<RulePackDef, String> = if ext == "jsonc" {
400 crate::jsonc::parse_to_value::<RulePackDef>(&content).map_err(|e| e.to_string())
401 } else {
402 serde_json::from_str::<RulePackDef>(&content).map_err(|e| e.to_string())
403 };
404 match parsed {
405 Ok(pack) => {
406 let before = errors.len();
407 validate_pack(&pack, &path, errors);
408 if errors.len() == before {
409 packs.push(pack);
410 }
411 }
412 Err(message) => {
413 errors.push(RulePackError {
414 path,
415 message: format!("failed to parse rule pack: {message}"),
416 });
417 }
418 }
419}
420
421fn push_duplicate_pack_name_errors(
423 root: &Path,
424 packs: &[RulePackDef],
425 errors: &mut Vec<RulePackError>,
426) {
427 let mut seen_names: FxHashSet<&str> = FxHashSet::default();
428 for pack in packs {
429 if !seen_names.insert(pack.name.as_str()) {
430 errors.push(RulePackError {
431 path: root.to_path_buf(),
432 message: format!(
433 "rule pack name '{}' is declared by more than one pack; pack names must be \
434 unique because findings are identified as '<pack>/<rule-id>'",
435 pack.name
436 ),
437 });
438 }
439 }
440}
441
442fn validate_pack(pack: &RulePackDef, path: &Path, errors: &mut Vec<RulePackError>) {
445 let err = |message: String| RulePackError {
446 path: path.to_path_buf(),
447 message,
448 };
449
450 if pack.version != SUPPORTED_PACK_VERSION {
451 errors.push(err(format!(
452 "unsupported rule pack version {}; this fallow build supports version \
453 {SUPPORTED_PACK_VERSION}",
454 pack.version
455 )));
456 }
457 if pack.name.trim().is_empty() {
458 errors.push(err("pack `name` must not be empty".to_owned()));
459 } else if !is_valid_policy_identifier(&pack.name) {
460 errors.push(err(format!(
461 "pack `name` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
462 pack.name
463 )));
464 }
465 if pack.rules.is_empty() {
466 errors.push(err(
467 "pack declares no rules; an empty pack would silently enforce nothing".to_owned(),
468 ));
469 }
470
471 let mut seen_ids: FxHashSet<&str> = FxHashSet::default();
472 for rule in &pack.rules {
473 if rule.id.trim().is_empty() {
474 errors.push(err("rule `id` must not be empty".to_owned()));
475 continue;
476 }
477 if !is_valid_policy_identifier(&rule.id) {
478 errors.push(err(format!(
479 "rule `id` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
480 rule.id
481 )));
482 continue;
483 }
484 if !seen_ids.insert(rule.id.as_str()) {
485 errors.push(err(format!(
486 "duplicate rule id '{}'; rule ids must be unique within a pack",
487 rule.id
488 )));
489 }
490 validate_rule(rule, path, errors);
491 }
492}
493
494fn validate_rule(rule: &RulePackRule, path: &Path, errors: &mut Vec<RulePackError>) {
496 let err = |message: String| RulePackError {
497 path: path.to_path_buf(),
498 message: format!("rule '{}': {message}", rule.id),
499 };
500
501 match rule.kind {
502 RulePackRuleKind::BannedCall => validate_banned_call_rule(rule, &err, errors),
503 RulePackRuleKind::BannedImport => validate_banned_import_rule(rule, &err, errors),
504 RulePackRuleKind::BannedEffect => validate_banned_effect_rule(rule, &err, errors),
505 RulePackRuleKind::BannedExport => validate_banned_export_rule(rule, &err, errors),
506 }
507
508 validate_rule_file_globs(rule, &err, errors);
509}
510
511fn validate_banned_call_rule(
513 rule: &RulePackRule,
514 err: &impl Fn(String) -> RulePackError,
515 errors: &mut Vec<RulePackError>,
516) {
517 if rule.callees.is_empty() {
518 errors.push(err(
519 "banned-call rules must list at least one `callees` pattern".to_owned(),
520 ));
521 }
522 if !rule.specifiers.is_empty() {
523 errors.push(err(
524 "`specifiers` applies only to banned-import rules".to_owned()
525 ));
526 }
527 if !rule.effects.is_empty() {
528 errors.push(err(
529 "`effects` applies only to banned-effect rules".to_owned()
530 ));
531 }
532 if !rule.exports.is_empty() {
533 errors.push(err(
534 "`exports` applies only to banned-export rules".to_owned()
535 ));
536 }
537 if rule.ignore_type_only {
538 errors.push(err(
539 "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
540 ));
541 }
542 for pattern in &rule.callees {
543 if let Some(reason) = callee_pattern_error(pattern) {
544 errors.push(err(format!("callee pattern `{pattern}` {reason}")));
545 }
546 }
547}
548
549fn validate_banned_import_rule(
551 rule: &RulePackRule,
552 err: &impl Fn(String) -> RulePackError,
553 errors: &mut Vec<RulePackError>,
554) {
555 if rule.specifiers.is_empty() {
556 errors.push(err(
557 "banned-import rules must list at least one `specifiers` entry".to_owned(),
558 ));
559 }
560 if !rule.callees.is_empty() {
561 errors.push(err("`callees` applies only to banned-call rules".to_owned()));
562 }
563 if !rule.effects.is_empty() {
564 errors.push(err(
565 "`effects` applies only to banned-effect rules".to_owned()
566 ));
567 }
568 if !rule.exports.is_empty() {
569 errors.push(err(
570 "`exports` applies only to banned-export rules".to_owned()
571 ));
572 }
573 for specifier in &rule.specifiers {
574 if specifier.trim().is_empty() {
575 errors.push(err("specifier must not be empty".to_owned()));
576 } else if let Some(prefix) = specifier.strip_suffix("/*") {
577 if prefix.is_empty() || prefix.contains('*') {
578 errors.push(err(format!(
579 "specifier `{specifier}` contains `*`; specifier matching is segment-aware, \
580 not glob. Only a single trailing `/*` deep-import form is allowed"
581 )));
582 }
583 } else if specifier.contains('*') {
584 errors.push(err(format!(
585 "specifier `{specifier}` contains `*`; specifier matching is \
586 segment-aware, not glob. List the package or path prefix; subpaths are \
587 covered automatically, or use a single trailing `/*` to match subpaths only"
588 )));
589 }
590 }
591}
592
593fn validate_banned_effect_rule(
595 rule: &RulePackRule,
596 err: &impl Fn(String) -> RulePackError,
597 errors: &mut Vec<RulePackError>,
598) {
599 if rule.effects.is_empty() {
600 errors.push(err(
601 "banned-effect rules must list at least one `effects` entry".to_owned(),
602 ));
603 }
604 if !rule.callees.is_empty() {
605 errors.push(err("`callees` applies only to banned-call rules".to_owned()));
606 }
607 if !rule.specifiers.is_empty() {
608 errors.push(err(
609 "`specifiers` applies only to banned-import rules".to_owned()
610 ));
611 }
612 if !rule.exports.is_empty() {
613 errors.push(err(
614 "`exports` applies only to banned-export rules".to_owned()
615 ));
616 }
617 if rule.ignore_type_only {
618 errors.push(err(
619 "`ignoreTypeOnly` applies only to banned-import and banned-export rules".to_owned(),
620 ));
621 }
622}
623
624fn validate_banned_export_rule(
626 rule: &RulePackRule,
627 err: &impl Fn(String) -> RulePackError,
628 errors: &mut Vec<RulePackError>,
629) {
630 if rule.exports.is_empty() {
631 errors.push(err(
632 "banned-export rules must list at least one `exports` entry".to_owned(),
633 ));
634 }
635 if !rule.callees.is_empty() {
636 errors.push(err("`callees` applies only to banned-call rules".to_owned()));
637 }
638 if !rule.specifiers.is_empty() {
639 errors.push(err(
640 "`specifiers` applies only to banned-import rules".to_owned()
641 ));
642 }
643 if !rule.effects.is_empty() {
644 errors.push(err(
645 "`effects` applies only to banned-effect rules".to_owned()
646 ));
647 }
648 for export in &rule.exports {
649 if export.trim().is_empty() {
650 errors.push(err("export pattern must not be empty".to_owned()));
651 } else if let Some(stripped) = export.strip_suffix('*') {
652 if stripped.is_empty() || stripped.contains('*') {
653 errors.push(err(format!(
654 "export pattern `{export}` may only use a single trailing `*` after a prefix"
655 )));
656 }
657 } else if export.contains('*') {
658 errors.push(err(format!(
659 "export pattern `{export}` may only use `*` as a single trailing prefix wildcard"
660 )));
661 }
662 }
663}
664
665fn validate_rule_file_globs(
667 rule: &RulePackRule,
668 err: &impl Fn(String) -> RulePackError,
669 errors: &mut Vec<RulePackError>,
670) {
671 for (field, patterns) in [("files", &rule.files), ("exclude", &rule.exclude)] {
672 for pattern in patterns {
673 if let Err(e) = compile_user_glob(pattern, "rulePacks rules[].files/exclude") {
674 errors.push(err(format!("invalid `{field}` glob `{pattern}`: {e}")));
675 }
676 }
677 }
678}
679
680fn callee_pattern_error(pattern: &str) -> Option<String> {
683 let trimmed = pattern.trim();
684 if trimmed.is_empty() {
685 return Some("must not be empty".to_owned());
686 }
687 if trimmed == "*" {
688 return Some(
689 "matches nothing: a bare `*` has no callee segments. Name a specific callee such as \
690 `console.*` or `child_process.exec`"
691 .to_owned(),
692 );
693 }
694 if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
695 return Some("contains an empty path segment".to_owned());
696 }
697 crate::config::wildcard_placement_error(trimmed)
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 fn write_pack(dir: &Path, name: &str, content: &str) -> String {
705 std::fs::write(dir.join(name), content).unwrap();
706 name.to_owned()
707 }
708
709 fn valid_pack_json() -> &'static str {
710 r#"{
711 "version": 1,
712 "name": "team-policy",
713 "description": "House rules",
714 "rules": [
715 {
716 "id": "no-child-process",
717 "kind": "banned-call",
718 "callees": ["child_process.*", "execa"],
719 "files": ["src/**"],
720 "exclude": ["src/tooling/**"],
721 "message": "Use the sandboxed runner instead.",
722 "severity": "error"
723 },
724 {
725 "id": "no-network",
726 "kind": "banned-effect",
727 "effects": ["network"],
728 "message": "Keep this package side-effect free."
729 },
730 {
731 "id": "no-moment",
732 "kind": "banned-import",
733 "specifiers": ["moment"],
734 "ignoreTypeOnly": true,
735 "message": "Use date-fns."
736 }
737 ]
738 }"#
739 }
740
741 #[test]
742 fn loads_valid_json_pack() {
743 let dir = tempfile::tempdir().unwrap();
744 let path = write_pack(dir.path(), "policy.json", valid_pack_json());
745 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
746 assert_eq!(packs.len(), 1);
747 assert_eq!(packs[0].name, "team-policy");
748 assert_eq!(packs[0].rules.len(), 3);
749 assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedCall);
750 assert_eq!(packs[0].rules[0].severity, Some(Severity::Error));
751 assert_eq!(packs[0].rules[1].kind, RulePackRuleKind::BannedEffect);
752 assert_eq!(packs[0].rules[1].effects, vec![EffectKind::Network]);
753 assert_eq!(packs[0].rules[2].kind, RulePackRuleKind::BannedImport);
754 assert!(packs[0].rules[2].ignore_type_only);
755 assert_eq!(packs[0].rules[2].severity, None);
756 }
757
758 #[test]
759 fn loads_jsonc_pack_with_comments() {
760 let dir = tempfile::tempdir().unwrap();
761 let path = write_pack(
762 dir.path(),
763 "policy.jsonc",
764 r#"{
765 // why: keep the domain layer pure
766 "version": 1,
767 "name": "jsonc-policy",
768 "rules": [
769 { "id": "no-console", "kind": "banned-call", "callees": ["console.*"] },
770 ]
771 }"#,
772 );
773 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
774 assert_eq!(packs[0].name, "jsonc-policy");
775 }
776
777 #[test]
778 fn parses_zone_scoped_rules() {
779 let dir = tempfile::tempdir().unwrap();
780 let path = write_pack(
781 dir.path(),
782 "policy.json",
783 r#"{ "version": 1, "name": "p", "rules": [
784 { "id": "domain-network", "kind": "banned-effect",
785 "effects": ["network"], "zones": ["domain"] }
786 ] }"#,
787 );
788 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
789 assert_eq!(packs[0].rules[0].zones, vec!["domain"]);
790 }
791
792 #[test]
793 fn validates_rule_pack_zones_against_resolved_boundaries() {
794 let dir = tempfile::tempdir().unwrap();
795 let path = write_pack(
796 dir.path(),
797 "policy.json",
798 r#"{ "version": 1, "name": "p", "rules": [
799 { "id": "domain-network", "kind": "banned-effect",
800 "effects": ["network"], "zones": ["unknown"] }
801 ] }"#,
802 );
803 let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
804 let boundaries = BoundaryConfig {
805 zones: vec![crate::config::BoundaryZone {
806 name: "domain".to_owned(),
807 patterns: vec!["src/domain/**".to_owned()],
808 auto_discover: Vec::new(),
809 root: None,
810 }],
811 ..BoundaryConfig::default()
812 };
813
814 let errors = validate_rule_pack_zones(dir.path(), &boundaries, &[path], &packs);
815 assert_eq!(errors.len(), 1);
816 assert!(errors[0].message.contains("unknown zone 'unknown'"));
817 assert!(errors[0].message.contains("configured zones: domain"));
818 }
819
820 #[test]
821 fn accepts_rule_pack_zones_that_match_configured_zones() {
822 let dir = tempfile::tempdir().unwrap();
823 let path = write_pack(
824 dir.path(),
825 "policy.json",
826 r#"{ "version": 1, "name": "p", "rules": [
827 { "id": "domain-network", "kind": "banned-effect",
828 "effects": ["network"], "zones": ["domain"] }
829 ] }"#,
830 );
831 let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
832 let boundaries = BoundaryConfig {
833 zones: vec![crate::config::BoundaryZone {
834 name: "domain".to_owned(),
835 patterns: vec!["src/domain/**".to_owned()],
836 auto_discover: Vec::new(),
837 root: None,
838 }],
839 ..BoundaryConfig::default()
840 };
841
842 assert!(validate_rule_pack_zones(dir.path(), &boundaries, &[path], &packs).is_empty());
843 }
844
845 #[test]
846 fn rule_packs_without_zone_scopes_need_no_boundaries() {
847 let dir = tempfile::tempdir().unwrap();
848 let path = write_pack(
849 dir.path(),
850 "policy.json",
851 r#"{ "version": 1, "name": "p", "rules": [
852 { "id": "no-network", "kind": "banned-effect", "effects": ["network"] }
853 ] }"#,
854 );
855 let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
856
857 assert!(
858 validate_rule_pack_zones(dir.path(), &BoundaryConfig::default(), &[path], &packs)
859 .is_empty()
860 );
861 }
862
863 #[test]
864 fn rejects_rule_pack_zones_when_boundaries_are_empty() {
865 let dir = tempfile::tempdir().unwrap();
866 let path = write_pack(
867 dir.path(),
868 "policy.json",
869 r#"{ "version": 1, "name": "p", "rules": [
870 { "id": "domain-network", "kind": "banned-effect",
871 "effects": ["network"], "zones": ["domain"] }
872 ] }"#,
873 );
874 let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
875 let errors =
876 validate_rule_pack_zones(dir.path(), &BoundaryConfig::default(), &[path], &packs);
877 assert_eq!(errors.len(), 1);
878 assert!(
879 errors[0]
880 .message
881 .contains("`zones` requires configured boundary zones")
882 );
883 }
884
885 #[test]
886 fn rejects_unsupported_version() {
887 let dir = tempfile::tempdir().unwrap();
888 let path = write_pack(
889 dir.path(),
890 "policy.json",
891 r#"{ "version": 2, "name": "p", "rules": [
892 { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
893 ] }"#,
894 );
895 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
896 assert!(
897 errors[0]
898 .message
899 .contains("unsupported rule pack version 2")
900 );
901 }
902
903 #[test]
904 fn rejects_unknown_kind_with_expected_list() {
905 let dir = tempfile::tempdir().unwrap();
906 let path = write_pack(
907 dir.path(),
908 "policy.json",
909 r#"{ "version": 1, "name": "p", "rules": [
910 { "id": "a", "kind": "banned-thing", "callees": ["fetch"] }
911 ] }"#,
912 );
913 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
914 assert!(errors[0].message.contains("banned-thing"));
915 assert!(errors[0].message.contains("banned-effect"));
916 assert!(errors[0].message.contains("banned-call"));
917 assert!(errors[0].message.contains("banned-import"));
918 assert!(errors[0].message.contains("banned-export"));
919 }
920
921 #[test]
922 fn rejects_unknown_field() {
923 let dir = tempfile::tempdir().unwrap();
924 let path = write_pack(
925 dir.path(),
926 "policy.json",
927 r#"{ "version": 1, "name": "p", "rules": [
928 { "id": "a", "kind": "banned-call", "callees": ["fetch"], "file": ["src/**"] }
929 ] }"#,
930 );
931 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
932 assert!(errors[0].message.contains("file"));
933 }
934
935 #[test]
936 fn rejects_empty_rules_and_empty_pack_name() {
937 let dir = tempfile::tempdir().unwrap();
938 let path = write_pack(
939 dir.path(),
940 "policy.json",
941 r#"{ "version": 1, "name": " ", "rules": [] }"#,
942 );
943 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
944 let joined = errors
945 .iter()
946 .map(|e| e.message.clone())
947 .collect::<Vec<_>>()
948 .join("\n");
949 assert!(joined.contains("declares no rules"));
950 assert!(joined.contains("`name` must not be empty"));
951 }
952
953 #[test]
954 fn rejects_pack_names_that_cannot_be_scoped_suppression_tokens() {
955 let dir = tempfile::tempdir().unwrap();
956 let path = write_pack(
957 dir.path(),
958 "policy.json",
959 r#"{ "version": 1, "name": "team/policy", "rules": [
960 { "id": "no-child-process", "kind": "banned-call", "callees": ["fetch"] }
961 ] }"#,
962 );
963 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
964 assert!(errors[0].message.contains("pack `name` 'team/policy'"));
965 assert!(errors[0].message.contains("ASCII letters"));
966 }
967
968 #[test]
969 fn rejects_rule_ids_that_cannot_be_scoped_suppression_tokens() {
970 let dir = tempfile::tempdir().unwrap();
971 let path = write_pack(
972 dir.path(),
973 "policy.json",
974 r#"{ "version": 1, "name": "team-policy", "rules": [
975 { "id": "no:child-process", "kind": "banned-call", "callees": ["fetch"] }
976 ] }"#,
977 );
978 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
979 assert!(errors[0].message.contains("rule `id` 'no:child-process'"));
980 assert!(errors[0].message.contains("ASCII letters"));
981 }
982
983 #[test]
984 fn rejects_duplicate_rule_ids_within_pack() {
985 let dir = tempfile::tempdir().unwrap();
986 let path = write_pack(
987 dir.path(),
988 "policy.json",
989 r#"{ "version": 1, "name": "p", "rules": [
990 { "id": "a", "kind": "banned-call", "callees": ["fetch"] },
991 { "id": "a", "kind": "banned-import", "specifiers": ["moment"] }
992 ] }"#,
993 );
994 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
995 assert!(errors[0].message.contains("duplicate rule id 'a'"));
996 }
997
998 #[test]
999 fn rejects_duplicate_pack_names() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let a = write_pack(
1002 dir.path(),
1003 "a.json",
1004 r#"{ "version": 1, "name": "p", "rules": [
1005 { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
1006 ] }"#,
1007 );
1008 let b = write_pack(
1009 dir.path(),
1010 "b.json",
1011 r#"{ "version": 1, "name": "p", "rules": [
1012 { "id": "b", "kind": "banned-call", "callees": ["eval"] }
1013 ] }"#,
1014 );
1015 let errors = load_rule_packs(dir.path(), &[a, b]).unwrap_err();
1016 assert!(errors[0].message.contains("rule pack name 'p'"));
1017 }
1018
1019 #[test]
1020 fn rejects_cross_kind_fields() {
1021 let dir = tempfile::tempdir().unwrap();
1022 let path = write_pack(
1023 dir.path(),
1024 "policy.json",
1025 r#"{ "version": 1, "name": "p", "rules": [
1026 { "id": "a", "kind": "banned-call", "callees": ["fetch"],
1027 "specifiers": ["moment"], "effects": ["network"], "exports": ["default"],
1028 "ignoreTypeOnly": true },
1029 { "id": "b", "kind": "banned-import", "specifiers": ["moment"],
1030 "callees": ["fetch"], "effects": ["network"], "exports": ["default"] },
1031 { "id": "c", "kind": "banned-effect", "effects": ["network"],
1032 "callees": ["fetch"], "specifiers": ["moment"], "exports": ["default"],
1033 "ignoreTypeOnly": true },
1034 { "id": "d", "kind": "banned-export", "exports": ["default"],
1035 "callees": ["fetch"], "specifiers": ["moment"], "effects": ["network"] }
1036 ] }"#,
1037 );
1038 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1039 let joined = errors
1040 .iter()
1041 .map(|e| e.message.clone())
1042 .collect::<Vec<_>>()
1043 .join("\n");
1044 assert!(joined.contains("`specifiers` applies only to banned-import"));
1045 assert!(
1046 joined.contains("`ignoreTypeOnly` applies only to banned-import and banned-export")
1047 );
1048 assert!(joined.contains("`callees` applies only to banned-call"));
1049 assert!(joined.contains("`effects` applies only to banned-effect"));
1050 assert!(joined.contains("`exports` applies only to banned-export"));
1051 }
1052
1053 #[test]
1054 fn rejects_missing_kind_fields() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let path = write_pack(
1057 dir.path(),
1058 "policy.json",
1059 r#"{ "version": 1, "name": "p", "rules": [
1060 { "id": "a", "kind": "banned-call" },
1061 { "id": "b", "kind": "banned-import" },
1062 { "id": "c", "kind": "banned-effect" },
1063 { "id": "d", "kind": "banned-export" }
1064 ] }"#,
1065 );
1066 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1067 let joined = errors
1068 .iter()
1069 .map(|e| e.message.clone())
1070 .collect::<Vec<_>>()
1071 .join("\n");
1072 assert!(joined.contains("must list at least one `callees` pattern"));
1073 assert!(joined.contains("must list at least one `specifiers` entry"));
1074 assert!(joined.contains("must list at least one `effects` entry"));
1075 assert!(joined.contains("must list at least one `exports` entry"));
1076 }
1077
1078 #[test]
1079 fn loads_banned_export_rule() {
1080 let dir = tempfile::tempdir().unwrap();
1081 let path = write_pack(
1082 dir.path(),
1083 "policy.json",
1084 r#"{ "version": 1, "name": "p", "rules": [
1085 { "id": "no-default", "kind": "banned-export",
1086 "exports": ["default", "internal*"], "ignoreTypeOnly": true }
1087 ] }"#,
1088 );
1089 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
1090 assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedExport);
1091 assert_eq!(packs[0].rules[0].exports, vec!["default", "internal*"]);
1092 assert!(packs[0].rules[0].ignore_type_only);
1093 }
1094
1095 #[test]
1096 fn rejects_invalid_banned_export_patterns() {
1097 let dir = tempfile::tempdir().unwrap();
1098 let path = write_pack(
1099 dir.path(),
1100 "policy.json",
1101 r#"{ "version": 1, "name": "p", "rules": [
1102 { "id": "bad", "kind": "banned-export",
1103 "exports": ["", "*", "a*b"] }
1104 ] }"#,
1105 );
1106 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1107 let joined = errors
1108 .iter()
1109 .map(|e| e.message.clone())
1110 .collect::<Vec<_>>()
1111 .join("\n");
1112 assert!(joined.contains("export pattern must not be empty"));
1113 assert!(joined.contains("may only use a single trailing `*` after a prefix"));
1114 assert!(joined.contains("may only use `*` as a single trailing prefix wildcard"));
1115 }
1116
1117 #[test]
1118 fn rejects_inert_callee_patterns() {
1119 let dir = tempfile::tempdir().unwrap();
1120 let path = write_pack(
1121 dir.path(),
1122 "policy.json",
1123 r#"{ "version": 1, "name": "p", "rules": [
1124 { "id": "a", "kind": "banned-call",
1125 "callees": ["*", "a..b", "child*", "a.*.b"] }
1126 ] }"#,
1127 );
1128 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1129 assert_eq!(errors.len(), 4);
1130 }
1131
1132 #[test]
1133 fn rejects_glob_specifiers() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let path = write_pack(
1136 dir.path(),
1137 "policy.json",
1138 r#"{ "version": 1, "name": "p", "rules": [
1139 { "id": "a", "kind": "banned-import", "specifiers": ["moment/**"] }
1140 ] }"#,
1141 );
1142 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1143 assert!(errors[0].message.contains("segment-aware, not glob"));
1144 }
1145
1146 #[test]
1147 fn accepts_trailing_star_deep_import_specifier() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let path = write_pack(
1150 dir.path(),
1151 "policy.json",
1152 r#"{ "version": 1, "name": "p", "rules": [
1153 { "id": "no-ui-deep-imports", "kind": "banned-import",
1154 "specifiers": ["@org/ui/*"] }
1155 ] }"#,
1156 );
1157 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
1158 assert_eq!(packs[0].rules[0].specifiers, vec!["@org/ui/*"]);
1159 }
1160
1161 #[test]
1162 fn rejects_non_trailing_star_import_specifier() {
1163 let dir = tempfile::tempdir().unwrap();
1164 let path = write_pack(
1165 dir.path(),
1166 "policy.json",
1167 r#"{ "version": 1, "name": "p", "rules": [
1168 { "id": "bad-deep-imports", "kind": "banned-import",
1169 "specifiers": ["@org/*/x"] }
1170 ] }"#,
1171 );
1172 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1173 assert!(errors[0].message.contains("single trailing `/*`"));
1174 }
1175
1176 #[test]
1177 fn rejects_traversal_globs() {
1178 let dir = tempfile::tempdir().unwrap();
1179 let path = write_pack(
1180 dir.path(),
1181 "policy.json",
1182 r#"{ "version": 1, "name": "p", "rules": [
1183 { "id": "a", "kind": "banned-call", "callees": ["fetch"],
1184 "files": ["../outside/**"] }
1185 ] }"#,
1186 );
1187 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1188 assert!(errors[0].message.contains("invalid `files` glob"));
1189 }
1190
1191 #[test]
1192 fn rejects_missing_pack_file_and_bad_extension() {
1193 let dir = tempfile::tempdir().unwrap();
1194 write_pack(dir.path(), "policy.toml", "version = 1");
1195 let errors = load_rule_packs(
1196 dir.path(),
1197 &["missing.json".to_owned(), "policy.toml".to_owned()],
1198 )
1199 .unwrap_err();
1200 assert_eq!(errors.len(), 2);
1201 assert!(errors[0].message.contains("failed to read rule pack"));
1202 assert!(
1203 errors[1]
1204 .message
1205 .contains("unsupported rule pack extension")
1206 );
1207 }
1208
1209 #[test]
1210 fn rejects_paths_outside_root() {
1211 let dir = tempfile::tempdir().unwrap();
1212 let inner = dir.path().join("project");
1213 std::fs::create_dir_all(&inner).unwrap();
1214 std::fs::write(
1215 dir.path().join("outside.json"),
1216 r#"{ "version": 1, "name": "p", "rules": [
1217 { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
1218 ] }"#,
1219 )
1220 .unwrap();
1221 let errors = load_rule_packs(&inner, &["../outside.json".to_owned()]).unwrap_err();
1222 assert!(errors[0].message.contains("outside the project root"));
1223 }
1224
1225 #[test]
1226 fn schema_validates_doc_example_shape() {
1227 let schema = RulePackDef::json_schema();
1228 let properties = schema
1229 .get("properties")
1230 .and_then(|p| p.as_object())
1231 .expect("schema should expose properties");
1232 assert!(properties.contains_key("version"));
1233 assert!(properties.contains_key("name"));
1234 assert!(properties.contains_key("rules"));
1235
1236 let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
1239 assert_eq!(pack.version, 1);
1240 }
1241}