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, 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
370impl Default for Shields {
371 fn default() -> Self {
372 Shields { trusted: TrustedBar::default() }
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377pub struct Policy {
378 pub format: u32,
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
383 pub requires: Vec<String>,
384 pub name: String,
385 #[serde(default, skip_serializing_if = "Vec::is_empty")]
388 pub emoji_codes: Vec<String>,
389 #[serde(default)]
390 pub shields: Shields,
391 #[serde(default)]
392 pub window: Window,
393 #[serde(default)]
394 pub exempt: Exempt,
395 pub rules: Vec<Rule>,
396}
397
398pub const FORMAT: u32 = 1;
399
400const KNOWN_REQUIRED: &[&str] = &["emoji_codes", "shields", "window", "exempt", "repeat_within", "armed_by_any"];
402
403impl Policy {
404 pub fn validate(&self) -> Result<(), InertReason> {
408 if self.format != FORMAT {
409 return Err(InertReason::UnknownFormat { found: self.format });
410 }
411 for key in &self.requires {
412 if !KNOWN_REQUIRED.contains(&key.as_str()) {
413 return Err(InertReason::UnknownRequiredKey { key: key.clone() });
414 }
415 }
416 self.check().map_err(|c| InertReason::ValidationFailed { code: c.to_string() })
417 }
418
419 fn check(&self) -> Result<(), &'static str> {
420 if self.rules.len() > caps::MAX_RULES_PER_POLICY || self.emoji_codes.len() > caps::MAX_EMOJI_CODES {
421 return Err(code::CAP_EXCEEDED);
422 }
423 if self.window.hours == 0
424 || self.window.hours > caps::WINDOW_MAX_HOURS
425 || self.window.max_messages == 0
426 || self.window.max_messages > caps::WINDOW_MAX_MESSAGES
427 {
428 return Err(code::WINDOW_OUT_OF_RANGE);
429 }
430 let mut seen: Vec<&str> = Vec::with_capacity(self.rules.len());
431 for r in &self.rules {
432 if !r.id.is_ascii() {
433 return Err(code::RULE_ID_NOT_ASCII);
434 }
435 if r.id.is_empty() || r.id.len() > caps::MAX_RULE_ID_LEN {
436 return Err(code::CAP_EXCEEDED);
437 }
438 if seen.contains(&r.id.as_str()) {
439 return Err(code::RULE_ID_DUPLICATE);
440 }
441 seen.push(&r.id);
442 self.check_rule(r)?;
443 }
444 for r in &self.rules {
446 let Some(a) = &r.armed_by else { continue };
447 for id in std::iter::once(&a.rule).chain(a.also.iter()) {
451 let Some(target) = self.rules.iter().find(|t| &t.id == id) else {
452 return Err(code::ARMED_BY_UNKNOWN_RULE);
453 };
454 if target.armed_by.is_some() {
455 return Err(code::ARMED_BY_NESTED);
456 }
457 }
458 if a.scope == ArmScope::Subject && a.min_subjects.is_some() {
459 return Err(code::ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE);
460 }
461 }
462 Ok(())
463 }
464
465 fn check_rule(&self, r: &Rule) -> Result<(), &'static str> {
466 let m = &r.matcher;
467 if m.patterns().len() > caps::MAX_PATTERNS_PER_RULE
468 || m.patterns().iter().any(|p| p.chars().count() > caps::MAX_PATTERN_LEN)
469 {
470 return Err(code::CAP_EXCEEDED);
471 }
472 if let Match::Cohort { min, short_factor, thin_ratio, .. } = m {
473 if *min == 0 || *short_factor == 0 {
474 return Err(code::MISSING_REQUIRED_PARAMETER);
475 }
476 if thin_ratio.is_some_and(|t| t.denom == 0) {
477 return Err(code::MISSING_REQUIRED_PARAMETER);
478 }
479 }
480 if let Match::JoinBurst { gap_secs, min } = m {
481 if *gap_secs == 0 || *min == 0 {
482 return Err(code::MISSING_REQUIRED_PARAMETER);
483 }
484 }
485 if let Match::Rate { per_secs } = m {
486 if *per_secs == 0 {
487 return Err(code::MISSING_REQUIRED_PARAMETER);
488 }
489 }
490
491 let has_direct = r.severity.is_some() || r.weight.is_some();
493 let has_tiers = r.tiers.as_ref().is_some_and(|t| !t.per_message.is_empty() || !t.per_window.is_empty());
494 match (has_tiers, has_direct) {
495 (true, true) => return Err(code::TIERS_AND_DIRECT_FORM),
496 (false, false) => return Err(code::MISSING_REQUIRED_PARAMETER),
497 (false, true) => {
498 if !m.admits(Scope::Whole) {
499 return Err(code::DIRECT_FORM_NOT_ADMISSIBLE);
500 }
501 let w = r.weight.ok_or(code::MISSING_REQUIRED_PARAMETER)?;
502 if !(caps::WEIGHT_MIN..=caps::WEIGHT_MAX).contains(&w) {
503 return Err(code::WEIGHT_OUT_OF_RANGE);
504 }
505 if r.severity.is_none() {
506 return Err(code::MISSING_REQUIRED_PARAMETER);
507 }
508 if r.pierces_trusted && r.severity != Some(Severity::Severe) {
509 return Err(code::PIERCES_BELOW_SEVERE);
510 }
511 }
512 (true, false) => {
513 let t = r.tiers.as_ref().expect("has_tiers");
514 for (scope, rungs) in [(Scope::PerMessage, &t.per_message), (Scope::PerWindow, &t.per_window)] {
515 if rungs.is_empty() {
516 continue;
517 }
518 if !m.admits(scope) {
519 return Err(code::SCOPE_NOT_ADMISSIBLE);
520 }
521 let mut prev = 0u32;
522 for rung in rungs {
523 if rung.hits <= prev {
524 return Err(code::RUNG_ORDER_NOT_ASCENDING);
525 }
526 prev = rung.hits;
527 if !(caps::WEIGHT_MIN..=caps::WEIGHT_MAX).contains(&rung.weight) {
528 return Err(code::WEIGHT_OUT_OF_RANGE);
529 }
530 if rung.pierces_trusted && rung.severity != Severity::Severe {
531 return Err(code::PIERCES_BELOW_SEVERE);
532 }
533 }
534 }
535 if r.family.is_some() {
539 return Err(code::FAMILY_ON_TIERED_RULE);
540 }
541 }
542 }
543
544 if let Some(builtin) = m.builtin_family() {
547 if r.family.as_deref().is_some_and(|f| f != builtin) {
548 return Err(code::FAMILY_REASSIGNED_BUILTIN);
549 }
550 }
551
552 if let Some(n) = m.normalize() {
555 if n.strips_separators() {
556 if matches!(m, Match::Regex { boundary_word: true, .. }) {
557 return Err(code::BOUNDARY_ON_STRIPPING_NORMALIZER);
558 }
559 if matches!(m, Match::Keyword { patterns, .. } if patterns.iter().any(|p| !is_double_wildcard(p))) {
560 return Err(code::BOUNDARY_ON_STRIPPING_NORMALIZER);
561 }
562 let domain_exempt = r
563 .exempt
564 .patterns
565 .iter()
566 .chain(self.exempt.patterns.iter())
567 .any(|p| p.kind == Some(ExemptKind::Domain));
568 if domain_exempt {
569 return Err(code::DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER);
570 }
571 }
572 }
573
574 if r.enforcement == Enforcement::Refuse {
575 if m.basis() != Basis::Deterministic {
576 return Err(code::REFUSE_ON_HEURISTIC);
577 }
578 match m {
582 Match::Keyword { patterns, .. } | Match::Regex { patterns, .. } => {
583 if patterns.iter().any(|p| min_branch_literals(p) < caps::REFUSE_MIN_LITERAL_CHARS) {
584 return Err(code::REFUSE_TOO_BROAD);
585 }
586 }
587 Match::Link { patterns } => {
591 let narrowed = !patterns.is_empty()
592 || r.exempt.patterns.iter().chain(self.exempt.patterns.iter()).any(|p| !p.values.is_empty());
593 if !narrowed {
594 return Err(code::REFUSE_ON_UNNARROWED_RULE);
595 }
596 }
597 Match::Rate { .. } | Match::Repeat { .. } | Match::Mentions {} => {
598 let rung1_needs_more_than_one = r
599 .tiers
600 .as_ref()
601 .and_then(|t| t.per_window.first().or(t.per_message.first()))
602 .is_some_and(|g| g.hits > 1);
603 if !rung1_needs_more_than_one {
604 return Err(code::REFUSE_ON_UNNARROWED_RULE);
605 }
606 }
607 _ => {}
608 }
609 }
610 Ok(())
611 }
612}
613
614fn is_double_wildcard(p: &str) -> bool {
617 let unescaped_star = |s: &str, at_start: bool| {
618 if at_start {
619 s.starts_with('*')
620 } else {
621 s.ends_with('*') && !s.ends_with("\\*")
622 }
623 };
624 unescaped_star(p, true) && unescaped_star(p, false) && p.chars().count() > 2
625}
626
627fn min_branch_literals(pattern: &str) -> usize {
630 pattern
631 .split('|')
632 .map(|branch| {
633 let mut n = 0usize;
634 let mut chars = branch.chars().peekable();
635 while let Some(c) = chars.next() {
636 match c {
637 '\\' => {
638 if let Some(next) = chars.next() {
640 if !next.is_ascii_alphanumeric() {
641 n += 1;
642 }
643 }
644 }
645 '[' => {
646 for c2 in chars.by_ref() {
647 if c2 == ']' {
648 break;
649 }
650 }
651 }
652 '.' | '*' | '+' | '?' | '(' | ')' | '{' | '}' | '^' | '$' => {}
653 _ => n += 1,
654 }
655 }
656 n
657 })
658 .min()
659 .unwrap_or(0)
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 fn base(rules: Vec<Rule>) -> Policy {
667 Policy {
668 format: FORMAT,
669 requires: vec![],
670 name: "p".into(),
671 emoji_codes: vec![],
672 shields: Shields::default(),
673 window: Window::default(),
674 exempt: Exempt::default(),
675 rules,
676 }
677 }
678
679 fn keyword_rule() -> Rule {
680 Rule {
681 id: "swears".into(),
682 matcher: Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Fold },
683 tiers: Some(Tiers {
684 per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
685 per_window: vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: true }],
686 }),
687 severity: None,
688 weight: None,
689 pierces_trusted: false,
690 family: None,
691 armed_by: None,
692 exempt: Exempt::default(),
693 enforcement: Enforcement::Advisory,
694 }
695 }
696
697 fn cohort_rule() -> Rule {
698 Rule {
699 id: "cohort".into(),
700 matcher: Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
701 tiers: None,
702 severity: Some(Severity::Severe),
703 weight: Some(85),
704 pierces_trusted: false,
705 family: None,
706 armed_by: None,
707 exempt: Exempt::default(),
708 enforcement: Enforcement::Advisory,
709 }
710 }
711
712 fn err_code(p: &Policy) -> String {
713 match p.validate() {
714 Err(InertReason::ValidationFailed { code }) => code,
715 other => panic!("expected a validation failure, got {other:?}"),
716 }
717 }
718
719 #[test]
720 fn a_well_formed_policy_validates() {
721 assert!(base(vec![keyword_rule(), cohort_rule()]).validate().is_ok());
722 }
723
724 #[test]
725 fn format_and_required_keys_make_a_policy_inert_not_merely_invalid() {
726 let mut p = base(vec![keyword_rule()]);
727 p.format = 2;
728 assert!(matches!(p.validate(), Err(InertReason::UnknownFormat { found: 2 })));
729
730 let mut p = base(vec![keyword_rule()]);
731 p.requires = vec!["quarantine".into()];
732 assert!(matches!(p.validate(), Err(InertReason::UnknownRequiredKey { key }) if key == "quarantine"));
733 }
734
735 #[test]
736 fn weights_and_rungs_are_policed() {
737 let mut p = base(vec![keyword_rule()]);
738 p.rules[0].tiers.as_mut().unwrap().per_message[0].weight = 100;
739 assert_eq!(err_code(&p), code::WEIGHT_OUT_OF_RANGE, "100 is reserved");
740
741 let mut p = base(vec![keyword_rule()]);
742 p.rules[0].tiers.as_mut().unwrap().per_window =
743 vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: false },
744 Rung { hits: 3, severity: Severity::Severe, weight: 80, pierces_trusted: false }];
745 assert_eq!(err_code(&p), code::RUNG_ORDER_NOT_ASCENDING, "'highest rung reached' needs an order");
746
747 let mut p = base(vec![keyword_rule()]);
748 p.rules[0].tiers.as_mut().unwrap().per_message[0].pierces_trusted = true;
749 assert_eq!(err_code(&p), code::PIERCES_BELOW_SEVERE, "only Severe may pierce");
750 }
751
752 #[test]
753 fn scope_and_form_admissibility() {
754 let mut r = keyword_rule();
756 r.matcher = Match::Repeat { normalize: Normalize::Fold, within_secs: None };
757 let p = base(vec![r]);
758 assert_eq!(err_code(&p), code::SCOPE_NOT_ADMISSIBLE);
759
760 let mut r = keyword_rule();
762 r.tiers = None;
763 r.severity = Some(Severity::Major);
764 r.weight = Some(40);
765 let p = base(vec![r]);
766 assert_eq!(err_code(&p), code::DIRECT_FORM_NOT_ADMISSIBLE);
767
768 let mut r = keyword_rule();
770 r.severity = Some(Severity::Major);
771 let p = base(vec![r]);
772 assert_eq!(err_code(&p), code::TIERS_AND_DIRECT_FORM);
773 }
774
775 #[test]
776 fn families_are_immutable_on_builtins_and_banned_on_ladders() {
777 let mut r = keyword_rule();
778 r.family = Some("mine".into());
779 assert_eq!(err_code(&base(vec![r])), code::FAMILY_ON_TIERED_RULE);
780
781 let tenure = Rule {
782 id: "tenure".into(),
783 matcher: Match::TenureLt { secs: 86_400 },
784 tiers: None,
785 severity: Some(Severity::Notice),
786 weight: Some(20),
787 pierces_trusted: false,
788 family: Some("not-fresh-account".into()),
789 armed_by: None,
790 exempt: Exempt::default(),
791 enforcement: Enforcement::Advisory,
792 };
793 assert_eq!(err_code(&base(vec![tenure])), code::FAMILY_REASSIGNED_BUILTIN);
794 }
795
796 #[test]
797 fn refuse_is_deterministic_only_and_narrow() {
798 let mut r = cohort_rule();
800 r.enforcement = Enforcement::Refuse;
801 assert_eq!(err_code(&base(vec![r])), code::REFUSE_ON_HEURISTIC);
802
803 let mut r = keyword_rule();
806 r.matcher = Match::Regex { patterns: vec!["abcd|.*".into()], normalize: Normalize::Fold, boundary_word: false };
807 r.enforcement = Enforcement::Refuse;
808 assert_eq!(err_code(&base(vec![r])), code::REFUSE_TOO_BROAD);
809
810 let mut r = keyword_rule();
812 r.matcher = Match::Link { patterns: vec![] };
813 r.enforcement = Enforcement::Refuse;
814 assert_eq!(err_code(&base(vec![r.clone()])), code::REFUSE_ON_UNNARROWED_RULE);
815 r.exempt = Exempt {
816 patterns: vec![ExemptPatterns { kind: Some(ExemptKind::Domain), values: vec!["vectorapp.io".into()] }],
817 ..Default::default()
818 };
819 assert!(base(vec![r]).validate().is_ok(), "an allowlist IS a filter");
820 }
821
822 #[test]
823 fn skeleton_refuses_anchors_it_cannot_honour() {
824 let mut r = keyword_rule();
825 r.matcher = Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Skeleton };
826 assert_eq!(err_code(&base(vec![r.clone()])), code::BOUNDARY_ON_STRIPPING_NORMALIZER, "bare pattern needs separators");
827
828 r.matcher = Match::Keyword { patterns: vec!["*darn*".into()], normalize: Normalize::Skeleton };
829 assert!(base(vec![r.clone()]).validate().is_ok(), "*word* is the only skeleton-safe form");
830
831 r.exempt = Exempt {
832 patterns: vec![ExemptPatterns { kind: Some(ExemptKind::Domain), values: vec!["a.io".into()] }],
833 ..Default::default()
834 };
835 assert_eq!(err_code(&base(vec![r])), code::DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER);
836 }
837
838 #[test]
839 fn armed_by_resolves_one_level_within_the_policy() {
840 let mut burst = Rule {
841 id: "burst".into(),
842 matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
843 tiers: None,
844 severity: Some(Severity::Major),
845 weight: Some(40),
846 pierces_trusted: false,
847 family: None,
848 armed_by: Some(ArmedBy { rule: "nope".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] }),
849 exempt: Exempt::default(),
850 enforcement: Enforcement::Advisory,
851 };
852 assert_eq!(err_code(&base(vec![cohort_rule(), burst.clone()])), code::ARMED_BY_UNKNOWN_RULE);
853
854 burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Subject, min_subjects: Some(3), also: vec![] });
855 assert_eq!(
856 err_code(&base(vec![cohort_rule(), burst.clone()])),
857 code::ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE,
858 "a subject-scoped arm has no count to threshold"
859 );
860
861 burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
862 assert!(base(vec![cohort_rule(), burst]).validate().is_ok());
863 }
864
865 #[test]
866 fn caps_and_ids_are_enforced() {
867 let mut p = base(vec![keyword_rule(), keyword_rule()]);
868 assert_eq!(err_code(&p), code::RULE_ID_DUPLICATE);
869
870 p = base(vec![keyword_rule()]);
871 p.window.hours = caps::WINDOW_MAX_HOURS + 1;
872 assert_eq!(err_code(&p), code::WINDOW_OUT_OF_RANGE);
873
874 p = base(vec![keyword_rule()]);
875 p.rules[0].id = "sw€ars".into();
876 assert_eq!(err_code(&p), code::RULE_ID_NOT_ASCII, "rule_id enters every id preimage");
877 }
878
879 #[test]
880 fn min_branch_literals_counts_the_thinnest_branch() {
881 assert_eq!(min_branch_literals("scam"), 4);
882 assert_eq!(min_branch_literals("abcd|.*"), 0, "the escape hatch branch decides");
883 assert_eq!(min_branch_literals("[a-z]+word"), 4, "classes contribute nothing");
884 assert_eq!(min_branch_literals(r"\*sale"), 5, "an escaped literal counts");
885 }
886}