1use super::types::{caps, Basis, InertReason, Scope, Severity};
11use serde::{Deserialize, Serialize};
12
13pub mod code {
17 pub const UNKNOWN_RULE_TYPE: &str = "unknown_rule_type";
18 pub const UNKNOWN_NORMALIZE: &str = "unknown_normalize";
19 pub const RULE_ID_DUPLICATE: &str = "rule_id_duplicate";
20 pub const RULE_ID_NOT_ASCII: &str = "rule_id_not_ascii";
21 pub const CAP_EXCEEDED: &str = "cap_exceeded";
22 pub const WINDOW_OUT_OF_RANGE: &str = "window_out_of_range";
23 pub const WEIGHT_OUT_OF_RANGE: &str = "weight_out_of_range";
24 pub const TIERS_AND_DIRECT_FORM: &str = "tiers_and_direct_form";
25 pub const MISSING_REQUIRED_PARAMETER: &str = "missing_required_parameter";
26 pub const RUNG_ORDER_NOT_ASCENDING: &str = "rung_order_not_ascending";
27 pub const PIERCES_BELOW_SEVERE: &str = "pierces_below_severe";
28 pub const SCOPE_NOT_ADMISSIBLE: &str = "scope_not_admissible";
29 pub const DIRECT_FORM_NOT_ADMISSIBLE: &str = "direct_form_not_admissible";
30 pub const FAMILY_ON_TIERED_RULE: &str = "family_on_tiered_rule";
31 pub const FAMILY_REASSIGNED_BUILTIN: &str = "family_reassigned_builtin";
32 pub const BOUNDARY_ON_STRIPPING_NORMALIZER: &str = "boundary_on_stripping_normalizer";
33 pub const DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER: &str = "domain_exempt_on_stripping_normalizer";
34 pub const REFUSE_ON_HEURISTIC: &str = "refuse_on_heuristic";
35 pub const REFUSE_TOO_BROAD: &str = "refuse_too_broad";
36 pub const REFUSE_ON_UNNARROWED_RULE: &str = "refuse_on_unnarrowed_rule";
37 pub const ARMED_BY_UNKNOWN_RULE: &str = "armed_by_unknown_rule";
38 pub const ARMED_BY_NESTED: &str = "armed_by_nested";
39 pub const ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE: &str = "armed_by_min_subjects_with_subject_scope";
40 pub const CORROBORATOR_NOT_CONTENT_DERIVED: &str = "corroborator_not_content_derived";
41 pub const EID_EXPECTED: &str = "eid_expected";
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum Normalize {
49 None,
50 Fold,
51 Skeleton,
52 Confusables,
53}
54
55impl Normalize {
56 pub fn strips_separators(self) -> bool {
58 matches!(self, Normalize::Skeleton)
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum ExemptKind {
65 Literal,
66 Wildcard,
67 Domain,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
71pub struct ExemptPatterns {
72 pub kind: Option<ExemptKind>,
73 #[serde(default)]
74 pub values: Vec<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
81pub struct Exempt {
82 #[serde(default)]
85 pub patterns: Vec<ExemptPatterns>,
86 #[serde(default)]
87 pub roles: Vec<String>,
88 #[serde(default)]
89 pub channels: Vec<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct Rung {
96 pub hits: u32,
97 pub severity: Severity,
98 pub weight: u32,
99 #[serde(default)]
100 pub pierces_trusted: bool,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
104pub struct Tiers {
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub per_message: Vec<Rung>,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub per_window: Vec<Rung>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ArmedBy {
114 pub rule: String,
115 pub scope: ArmScope,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub min_subjects: Option<u32>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub also: Vec<String>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum ArmScope {
132 Subject,
133 Community,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(tag = "type", rename_all = "snake_case")]
141pub enum Match {
142 Keyword { patterns: Vec<String>, normalize: Normalize },
146 Regex {
147 patterns: Vec<String>,
148 normalize: Normalize,
149 #[serde(default)]
150 boundary_word: bool,
151 },
152 Link {
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
156 patterns: Vec<String>,
157 },
158 Repeat {
168 normalize: Normalize,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 within_secs: Option<u64>,
171 },
172 Rate { per_secs: u64 },
175 Mentions {},
177 Cohort { min: u32, quiet_max: u32, short_factor: u32, thin_ratio: Option<Ratio> },
179 JoinBurst { gap_secs: u64, min: u32 },
181 TenureLt { secs: u64 },
183 MessagesLte { n: u32 },
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187pub struct Ratio {
188 pub num: u32,
189 pub denom: u32,
190}
191
192impl Match {
193 pub fn type_name(&self) -> &'static str {
194 match self {
195 Match::Keyword { .. } => "keyword",
196 Match::Regex { .. } => "regex",
197 Match::Link { .. } => "link",
198 Match::Repeat { .. } => "repeat",
199 Match::Rate { .. } => "rate",
200 Match::Mentions {} => "mentions",
201 Match::Cohort { .. } => "cohort",
202 Match::JoinBurst { .. } => "join_burst",
203 Match::TenureLt { .. } => "tenure_lt",
204 Match::MessagesLte { .. } => "messages_lte",
205 }
206 }
207
208 pub fn basis(&self) -> Basis {
211 match self {
212 Match::Cohort { .. } | Match::JoinBurst { .. } => Basis::Heuristic,
213 _ => Basis::Deterministic,
214 }
215 }
216
217 pub fn admits(&self, scope: Scope) -> bool {
221 match self {
222 Match::Keyword { .. } | Match::Regex { .. } | Match::Link { .. } | Match::Mentions {} => {
223 matches!(scope, Scope::PerMessage | Scope::PerWindow)
224 }
225 Match::Repeat { .. } | Match::Rate { .. } => scope == Scope::PerWindow,
226 Match::Cohort { .. } | Match::JoinBurst { .. } | Match::TenureLt { .. } | Match::MessagesLte { .. } => {
227 scope == Scope::Whole
228 }
229 }
230 }
231
232 pub fn is_stateless(&self) -> bool {
242 matches!(self, Match::Keyword { .. } | Match::Regex { .. } | Match::Link { .. } | Match::Mentions {})
243 }
244
245 pub fn builtin_family(&self) -> Option<&'static str> {
248 match self {
249 Match::TenureLt { .. } | Match::MessagesLte { .. } => Some("fresh-account"),
250 _ => None,
251 }
252 }
253
254 pub fn is_content_derived(&self) -> bool {
257 !matches!(self, Match::TenureLt { .. } | Match::MessagesLte { .. })
258 }
259
260 fn patterns(&self) -> &[String] {
261 match self {
262 Match::Keyword { patterns, .. } | Match::Regex { patterns, .. } | Match::Link { patterns } => patterns,
263 _ => &[],
264 }
265 }
266
267 fn normalize(&self) -> Option<Normalize> {
268 match self {
269 Match::Keyword { normalize, .. } | Match::Regex { normalize, .. } | Match::Repeat { normalize, .. } => {
270 Some(*normalize)
271 }
272 _ => None,
273 }
274 }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
278#[serde(rename_all = "snake_case")]
279pub enum Enforcement {
280 #[default]
281 Advisory,
282 Warn,
283 Refuse,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct Rule {
291 pub id: String,
292 #[serde(rename = "match")]
293 pub matcher: Match,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub tiers: Option<Tiers>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub severity: Option<Severity>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub weight: Option<u32>,
301 #[serde(default)]
302 pub pierces_trusted: bool,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub family: Option<String>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub armed_by: Option<ArmedBy>,
307 #[serde(default)]
308 pub exempt: Exempt,
309 #[serde(default)]
310 pub enforcement: Enforcement,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314pub struct Window {
315 pub hours: u64,
316 pub max_messages: usize,
317}
318
319impl Default for Window {
320 fn default() -> Self {
321 Window { hours: 24, max_messages: 500 }
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
326pub struct Shields {
327 pub trusted: TrustedBar,
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347pub struct TrustedBar {
348 pub tenure_secs: u64,
350 pub messages: u64,
351 pub distinct: u64,
352 pub veteran_secs: u64,
354 pub roles_trust: bool,
356}
357
358impl Default for TrustedBar {
359 fn default() -> Self {
360 TrustedBar {
361 tenure_secs: 7 * 24 * 3600,
362 messages: 5,
363 distinct: 3,
364 veteran_secs: 30 * 24 * 3600,
365 roles_trust: true,
366 }
367 }
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct Policy {
372 pub format: u32,
373 #[serde(default, skip_serializing_if = "Vec::is_empty")]
377 pub requires: Vec<String>,
378 pub name: String,
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
382 pub emoji_codes: Vec<String>,
383 #[serde(default)]
384 pub shields: Shields,
385 #[serde(default)]
386 pub window: Window,
387 #[serde(default)]
388 pub exempt: Exempt,
389 pub rules: Vec<Rule>,
390}
391
392pub const FORMAT: u32 = 1;
393
394const KNOWN_REQUIRED: &[&str] = &["emoji_codes", "shields", "window", "exempt", "repeat_within", "armed_by_any"];
396
397impl Policy {
398 pub fn validate(&self) -> Result<(), InertReason> {
402 if self.format != FORMAT {
403 return Err(InertReason::UnknownFormat { found: self.format });
404 }
405 for key in &self.requires {
406 if !KNOWN_REQUIRED.contains(&key.as_str()) {
407 return Err(InertReason::UnknownRequiredKey { key: key.clone() });
408 }
409 }
410 self.check().map_err(|c| InertReason::ValidationFailed { code: c.to_string() })
411 }
412
413 fn check(&self) -> Result<(), &'static str> {
414 if self.rules.len() > caps::MAX_RULES_PER_POLICY || self.emoji_codes.len() > caps::MAX_EMOJI_CODES {
415 return Err(code::CAP_EXCEEDED);
416 }
417 if self.window.hours == 0
418 || self.window.hours > caps::WINDOW_MAX_HOURS
419 || self.window.max_messages == 0
420 || self.window.max_messages > caps::WINDOW_MAX_MESSAGES
421 {
422 return Err(code::WINDOW_OUT_OF_RANGE);
423 }
424 let mut seen: Vec<&str> = Vec::with_capacity(self.rules.len());
425 for r in &self.rules {
426 if !r.id.is_ascii() {
427 return Err(code::RULE_ID_NOT_ASCII);
428 }
429 if r.id.is_empty() || r.id.len() > caps::MAX_RULE_ID_LEN {
430 return Err(code::CAP_EXCEEDED);
431 }
432 if seen.contains(&r.id.as_str()) {
433 return Err(code::RULE_ID_DUPLICATE);
434 }
435 seen.push(&r.id);
436 self.check_rule(r)?;
437 }
438 for r in &self.rules {
440 let Some(a) = &r.armed_by else { continue };
441 for id in std::iter::once(&a.rule).chain(a.also.iter()) {
445 let Some(target) = self.rules.iter().find(|t| &t.id == id) else {
446 return Err(code::ARMED_BY_UNKNOWN_RULE);
447 };
448 if target.armed_by.is_some() {
449 return Err(code::ARMED_BY_NESTED);
450 }
451 }
452 if a.scope == ArmScope::Subject && a.min_subjects.is_some() {
453 return Err(code::ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE);
454 }
455 }
456 Ok(())
457 }
458
459 fn check_rule(&self, r: &Rule) -> Result<(), &'static str> {
460 let m = &r.matcher;
461 if m.patterns().len() > caps::MAX_PATTERNS_PER_RULE
462 || m.patterns().iter().any(|p| p.chars().count() > caps::MAX_PATTERN_LEN)
463 {
464 return Err(code::CAP_EXCEEDED);
465 }
466 if let Match::Cohort { min, short_factor, thin_ratio, .. } = m {
467 if *min == 0 || *short_factor == 0 {
468 return Err(code::MISSING_REQUIRED_PARAMETER);
469 }
470 if thin_ratio.is_some_and(|t| t.denom == 0) {
471 return Err(code::MISSING_REQUIRED_PARAMETER);
472 }
473 }
474 if let Match::JoinBurst { gap_secs, min } = m {
475 if *gap_secs == 0 || *min == 0 {
476 return Err(code::MISSING_REQUIRED_PARAMETER);
477 }
478 }
479 if let Match::Rate { per_secs } = m {
480 if *per_secs == 0 {
481 return Err(code::MISSING_REQUIRED_PARAMETER);
482 }
483 }
484
485 let has_direct = r.severity.is_some() || r.weight.is_some();
487 let has_tiers = r.tiers.as_ref().is_some_and(|t| !t.per_message.is_empty() || !t.per_window.is_empty());
488 match (has_tiers, has_direct) {
489 (true, true) => return Err(code::TIERS_AND_DIRECT_FORM),
490 (false, false) => return Err(code::MISSING_REQUIRED_PARAMETER),
491 (false, true) => {
492 if !m.admits(Scope::Whole) {
493 return Err(code::DIRECT_FORM_NOT_ADMISSIBLE);
494 }
495 let w = r.weight.ok_or(code::MISSING_REQUIRED_PARAMETER)?;
496 if !(caps::WEIGHT_MIN..=caps::WEIGHT_MAX).contains(&w) {
497 return Err(code::WEIGHT_OUT_OF_RANGE);
498 }
499 if r.severity.is_none() {
500 return Err(code::MISSING_REQUIRED_PARAMETER);
501 }
502 if r.pierces_trusted && r.severity != Some(Severity::Severe) {
503 return Err(code::PIERCES_BELOW_SEVERE);
504 }
505 }
506 (true, false) => {
507 let t = r.tiers.as_ref().expect("has_tiers");
508 for (scope, rungs) in [(Scope::PerMessage, &t.per_message), (Scope::PerWindow, &t.per_window)] {
509 if rungs.is_empty() {
510 continue;
511 }
512 if !m.admits(scope) {
513 return Err(code::SCOPE_NOT_ADMISSIBLE);
514 }
515 let mut prev = 0u32;
516 for rung in rungs {
517 if rung.hits <= prev {
518 return Err(code::RUNG_ORDER_NOT_ASCENDING);
519 }
520 prev = rung.hits;
521 if !(caps::WEIGHT_MIN..=caps::WEIGHT_MAX).contains(&rung.weight) {
522 return Err(code::WEIGHT_OUT_OF_RANGE);
523 }
524 if rung.pierces_trusted && rung.severity != Severity::Severe {
525 return Err(code::PIERCES_BELOW_SEVERE);
526 }
527 }
528 }
529 if r.family.is_some() {
533 return Err(code::FAMILY_ON_TIERED_RULE);
534 }
535 }
536 }
537
538 if let Some(builtin) = m.builtin_family() {
541 if r.family.as_deref().is_some_and(|f| f != builtin) {
542 return Err(code::FAMILY_REASSIGNED_BUILTIN);
543 }
544 }
545
546 if let Some(n) = m.normalize() {
549 if n.strips_separators() {
550 if matches!(m, Match::Regex { boundary_word: true, .. }) {
551 return Err(code::BOUNDARY_ON_STRIPPING_NORMALIZER);
552 }
553 if matches!(m, Match::Keyword { patterns, .. } if patterns.iter().any(|p| !is_double_wildcard(p))) {
554 return Err(code::BOUNDARY_ON_STRIPPING_NORMALIZER);
555 }
556 let domain_exempt = r
557 .exempt
558 .patterns
559 .iter()
560 .chain(self.exempt.patterns.iter())
561 .any(|p| p.kind == Some(ExemptKind::Domain));
562 if domain_exempt {
563 return Err(code::DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER);
564 }
565 }
566 }
567
568 if r.enforcement == Enforcement::Refuse {
569 if m.basis() != Basis::Deterministic {
570 return Err(code::REFUSE_ON_HEURISTIC);
571 }
572 match m {
576 Match::Keyword { patterns, .. } | Match::Regex { patterns, .. }
577 if patterns.iter().any(|p| min_branch_literals(p) < caps::REFUSE_MIN_LITERAL_CHARS) =>
578 {
579 return Err(code::REFUSE_TOO_BROAD);
580 }
581 Match::Link { patterns } => {
585 let narrowed = !patterns.is_empty()
586 || r.exempt.patterns.iter().chain(self.exempt.patterns.iter()).any(|p| !p.values.is_empty());
587 if !narrowed {
588 return Err(code::REFUSE_ON_UNNARROWED_RULE);
589 }
590 }
591 Match::Rate { .. } | Match::Repeat { .. } | Match::Mentions {} => {
592 let rung1_needs_more_than_one = r
593 .tiers
594 .as_ref()
595 .and_then(|t| t.per_window.first().or(t.per_message.first()))
596 .is_some_and(|g| g.hits > 1);
597 if !rung1_needs_more_than_one {
598 return Err(code::REFUSE_ON_UNNARROWED_RULE);
599 }
600 }
601 _ => {}
602 }
603 }
604 Ok(())
605 }
606}
607
608fn is_double_wildcard(p: &str) -> bool {
611 let unescaped_star = |s: &str, at_start: bool| {
612 if at_start {
613 s.starts_with('*')
614 } else {
615 s.ends_with('*') && !s.ends_with("\\*")
616 }
617 };
618 unescaped_star(p, true) && unescaped_star(p, false) && p.chars().count() > 2
619}
620
621fn min_branch_literals(pattern: &str) -> usize {
624 pattern
625 .split('|')
626 .map(|branch| {
627 let mut n = 0usize;
628 let mut chars = branch.chars().peekable();
629 while let Some(c) = chars.next() {
630 match c {
631 '\\' => {
632 if let Some(next) = chars.next() {
634 if !next.is_ascii_alphanumeric() {
635 n += 1;
636 }
637 }
638 }
639 '[' => {
640 for c2 in chars.by_ref() {
641 if c2 == ']' {
642 break;
643 }
644 }
645 }
646 '.' | '*' | '+' | '?' | '(' | ')' | '{' | '}' | '^' | '$' => {}
647 _ => n += 1,
648 }
649 }
650 n
651 })
652 .min()
653 .unwrap_or(0)
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 fn base(rules: Vec<Rule>) -> Policy {
661 Policy {
662 format: FORMAT,
663 requires: vec![],
664 name: "p".into(),
665 emoji_codes: vec![],
666 shields: Shields::default(),
667 window: Window::default(),
668 exempt: Exempt::default(),
669 rules,
670 }
671 }
672
673 fn keyword_rule() -> Rule {
674 Rule {
675 id: "swears".into(),
676 matcher: Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Fold },
677 tiers: Some(Tiers {
678 per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
679 per_window: vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: true }],
680 }),
681 severity: None,
682 weight: None,
683 pierces_trusted: false,
684 family: None,
685 armed_by: None,
686 exempt: Exempt::default(),
687 enforcement: Enforcement::Advisory,
688 }
689 }
690
691 fn cohort_rule() -> Rule {
692 Rule {
693 id: "cohort".into(),
694 matcher: Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
695 tiers: None,
696 severity: Some(Severity::Severe),
697 weight: Some(85),
698 pierces_trusted: false,
699 family: None,
700 armed_by: None,
701 exempt: Exempt::default(),
702 enforcement: Enforcement::Advisory,
703 }
704 }
705
706 fn err_code(p: &Policy) -> String {
707 match p.validate() {
708 Err(InertReason::ValidationFailed { code }) => code,
709 other => panic!("expected a validation failure, got {other:?}"),
710 }
711 }
712
713 #[test]
714 fn a_well_formed_policy_validates() {
715 assert!(base(vec![keyword_rule(), cohort_rule()]).validate().is_ok());
716 }
717
718 #[test]
719 fn format_and_required_keys_make_a_policy_inert_not_merely_invalid() {
720 let mut p = base(vec![keyword_rule()]);
721 p.format = 2;
722 assert!(matches!(p.validate(), Err(InertReason::UnknownFormat { found: 2 })));
723
724 let mut p = base(vec![keyword_rule()]);
725 p.requires = vec!["quarantine".into()];
726 assert!(matches!(p.validate(), Err(InertReason::UnknownRequiredKey { key }) if key == "quarantine"));
727 }
728
729 #[test]
730 fn weights_and_rungs_are_policed() {
731 let mut p = base(vec![keyword_rule()]);
732 p.rules[0].tiers.as_mut().unwrap().per_message[0].weight = 100;
733 assert_eq!(err_code(&p), code::WEIGHT_OUT_OF_RANGE, "100 is reserved");
734
735 let mut p = base(vec![keyword_rule()]);
736 p.rules[0].tiers.as_mut().unwrap().per_window =
737 vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: false },
738 Rung { hits: 3, severity: Severity::Severe, weight: 80, pierces_trusted: false }];
739 assert_eq!(err_code(&p), code::RUNG_ORDER_NOT_ASCENDING, "'highest rung reached' needs an order");
740
741 let mut p = base(vec![keyword_rule()]);
742 p.rules[0].tiers.as_mut().unwrap().per_message[0].pierces_trusted = true;
743 assert_eq!(err_code(&p), code::PIERCES_BELOW_SEVERE, "only Severe may pierce");
744 }
745
746 #[test]
747 fn scope_and_form_admissibility() {
748 let mut r = keyword_rule();
750 r.matcher = Match::Repeat { normalize: Normalize::Fold, within_secs: None };
751 let p = base(vec![r]);
752 assert_eq!(err_code(&p), code::SCOPE_NOT_ADMISSIBLE);
753
754 let mut r = keyword_rule();
756 r.tiers = None;
757 r.severity = Some(Severity::Major);
758 r.weight = Some(40);
759 let p = base(vec![r]);
760 assert_eq!(err_code(&p), code::DIRECT_FORM_NOT_ADMISSIBLE);
761
762 let mut r = keyword_rule();
764 r.severity = Some(Severity::Major);
765 let p = base(vec![r]);
766 assert_eq!(err_code(&p), code::TIERS_AND_DIRECT_FORM);
767 }
768
769 #[test]
770 fn families_are_immutable_on_builtins_and_banned_on_ladders() {
771 let mut r = keyword_rule();
772 r.family = Some("mine".into());
773 assert_eq!(err_code(&base(vec![r])), code::FAMILY_ON_TIERED_RULE);
774
775 let tenure = Rule {
776 id: "tenure".into(),
777 matcher: Match::TenureLt { secs: 86_400 },
778 tiers: None,
779 severity: Some(Severity::Notice),
780 weight: Some(20),
781 pierces_trusted: false,
782 family: Some("not-fresh-account".into()),
783 armed_by: None,
784 exempt: Exempt::default(),
785 enforcement: Enforcement::Advisory,
786 };
787 assert_eq!(err_code(&base(vec![tenure])), code::FAMILY_REASSIGNED_BUILTIN);
788 }
789
790 #[test]
791 fn refuse_is_deterministic_only_and_narrow() {
792 let mut r = cohort_rule();
794 r.enforcement = Enforcement::Refuse;
795 assert_eq!(err_code(&base(vec![r])), code::REFUSE_ON_HEURISTIC);
796
797 let mut r = keyword_rule();
800 r.matcher = Match::Regex { patterns: vec!["abcd|.*".into()], normalize: Normalize::Fold, boundary_word: false };
801 r.enforcement = Enforcement::Refuse;
802 assert_eq!(err_code(&base(vec![r])), code::REFUSE_TOO_BROAD);
803
804 let mut r = keyword_rule();
806 r.matcher = Match::Link { patterns: vec![] };
807 r.enforcement = Enforcement::Refuse;
808 assert_eq!(err_code(&base(vec![r.clone()])), code::REFUSE_ON_UNNARROWED_RULE);
809 r.exempt = Exempt {
810 patterns: vec![ExemptPatterns { kind: Some(ExemptKind::Domain), values: vec!["vectorapp.io".into()] }],
811 ..Default::default()
812 };
813 assert!(base(vec![r]).validate().is_ok(), "an allowlist IS a filter");
814 }
815
816 #[test]
817 fn skeleton_refuses_anchors_it_cannot_honour() {
818 let mut r = keyword_rule();
819 r.matcher = Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Skeleton };
820 assert_eq!(err_code(&base(vec![r.clone()])), code::BOUNDARY_ON_STRIPPING_NORMALIZER, "bare pattern needs separators");
821
822 r.matcher = Match::Keyword { patterns: vec!["*darn*".into()], normalize: Normalize::Skeleton };
823 assert!(base(vec![r.clone()]).validate().is_ok(), "*word* is the only skeleton-safe form");
824
825 r.exempt = Exempt {
826 patterns: vec![ExemptPatterns { kind: Some(ExemptKind::Domain), values: vec!["a.io".into()] }],
827 ..Default::default()
828 };
829 assert_eq!(err_code(&base(vec![r])), code::DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER);
830 }
831
832 #[test]
833 fn armed_by_resolves_one_level_within_the_policy() {
834 let mut burst = Rule {
835 id: "burst".into(),
836 matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
837 tiers: None,
838 severity: Some(Severity::Major),
839 weight: Some(40),
840 pierces_trusted: false,
841 family: None,
842 armed_by: Some(ArmedBy { rule: "nope".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] }),
843 exempt: Exempt::default(),
844 enforcement: Enforcement::Advisory,
845 };
846 assert_eq!(err_code(&base(vec![cohort_rule(), burst.clone()])), code::ARMED_BY_UNKNOWN_RULE);
847
848 burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Subject, min_subjects: Some(3), also: vec![] });
849 assert_eq!(
850 err_code(&base(vec![cohort_rule(), burst.clone()])),
851 code::ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE,
852 "a subject-scoped arm has no count to threshold"
853 );
854
855 burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
856 assert!(base(vec![cohort_rule(), burst]).validate().is_ok());
857 }
858
859 #[test]
860 fn caps_and_ids_are_enforced() {
861 let mut p = base(vec![keyword_rule(), keyword_rule()]);
862 assert_eq!(err_code(&p), code::RULE_ID_DUPLICATE);
863
864 p = base(vec![keyword_rule()]);
865 p.window.hours = caps::WINDOW_MAX_HOURS + 1;
866 assert_eq!(err_code(&p), code::WINDOW_OUT_OF_RANGE);
867
868 p = base(vec![keyword_rule()]);
869 p.rules[0].id = "sw€ars".into();
870 assert_eq!(err_code(&p), code::RULE_ID_NOT_ASCII, "rule_id enters every id preimage");
871 }
872
873 #[test]
874 fn min_branch_literals_counts_the_thinnest_branch() {
875 assert_eq!(min_branch_literals("scam"), 4);
876 assert_eq!(min_branch_literals("abcd|.*"), 0, "the escape hatch branch decides");
877 assert_eq!(min_branch_literals("[a-z]+word"), 4, "classes contribute nothing");
878 assert_eq!(min_branch_literals(r"\*sale"), 5, "an escaped literal counts");
879 }
880}