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