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