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