1use serde::{Deserialize, Serialize};
24
25use crate::Error;
26
27pub const DEFAULT_RULES_TOML: &str = include_str!("../rules/default.toml");
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum Category {
35 Pii,
37 Secret,
39 Injection,
41}
42
43impl Category {
44 pub fn label(self) -> &'static str {
46 match self {
47 Category::Pii => "PII",
48 Category::Secret => "SECRET",
49 Category::Injection => "INJECTION",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum Severity {
58 Low,
60 Medium,
62 High,
64 Critical,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71#[non_exhaustive]
72pub enum Validator {
73 Luhn,
75}
76
77impl Validator {
78 pub fn accepts(self, matched: &str) -> bool {
80 match self {
81 Validator::Luhn => luhn(matched),
82 }
83 }
84}
85
86fn luhn(s: &str) -> bool {
89 let digits: Vec<u32> = s.chars().filter_map(|c| c.to_digit(10)).collect();
90 if !(13..=19).contains(&digits.len()) {
91 return false;
92 }
93 let sum: u32 = digits
94 .iter()
95 .rev()
96 .enumerate()
97 .map(|(i, &d)| {
98 if i % 2 == 1 {
99 let dd = d * 2;
100 if dd > 9 { dd - 9 } else { dd }
101 } else {
102 d
103 }
104 })
105 .sum();
106 sum % 10 == 0
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct Rule {
112 pub id: String,
114 pub category: Category,
116 pub severity: Severity,
118 #[serde(default)]
120 pub description: String,
121 pub pattern: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub validator: Option<Validator>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub redact_with: Option<String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(default)]
135pub struct Policy {
136 pub block_at: Severity,
138 pub warn_at: Severity,
141 pub redact: Vec<Category>,
143}
144
145impl Default for Policy {
146 fn default() -> Self {
147 Self {
148 block_at: Severity::High,
149 warn_at: Severity::Medium,
150 redact: vec![Category::Pii, Category::Secret],
151 }
152 }
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(default)]
158pub struct RuleSet {
159 pub policy: Policy,
161 pub rules: Vec<Rule>,
163}
164
165impl RuleSet {
166 pub fn default_rules() -> Self {
172 Self::from_toml(DEFAULT_RULES_TOML).expect("bundled rules must parse")
173 }
174
175 pub fn from_toml(toml_text: &str) -> Result<Self, Error> {
178 Ok(toml::from_str(toml_text)?)
179 }
180
181 pub fn merge(&mut self, other: RuleSet) {
183 self.policy = other.policy;
184 self.rules.extend(other.rules);
185 }
186
187 pub(crate) fn validate_ids(&self) -> Result<(), Error> {
189 let mut seen = std::collections::HashSet::new();
190 for (i, rule) in self.rules.iter().enumerate() {
191 if rule.id.is_empty() {
192 return Err(Error::EmptyRuleId(i));
193 }
194 if !seen.insert(rule.id.as_str()) {
195 return Err(Error::DuplicateRuleId(rule.id.clone()));
196 }
197 }
198 Ok(())
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn luhn_accepts_known_test_numbers() {
208 assert!(luhn("4111 1111 1111 1111"));
209 assert!(luhn("5555-5555-5555-4444"));
210 assert!(luhn("378282246310005"));
211 }
212
213 #[test]
214 fn luhn_rejects_bad_checksum_and_wrong_length() {
215 assert!(!luhn("4111 1111 1111 1112"));
216 assert!(!luhn("1234"));
217 assert!(!luhn("12345678901234567890"));
218 }
219
220 #[test]
221 fn default_rules_parse_and_have_unique_ids() {
222 let rs = RuleSet::default_rules();
223 assert!(rs.rules.len() >= 10);
224 rs.validate_ids().unwrap();
225 }
226
227 #[test]
228 fn severity_is_ordered() {
229 assert!(Severity::Low < Severity::Medium);
230 assert!(Severity::Medium < Severity::High);
231 assert!(Severity::High < Severity::Critical);
232 }
233
234 #[test]
235 fn duplicate_ids_are_rejected() {
236 let rs = RuleSet::from_toml(
237 r#"
238 [[rules]]
239 id = "a"
240 category = "pii"
241 severity = "low"
242 pattern = "x"
243 [[rules]]
244 id = "a"
245 category = "pii"
246 severity = "low"
247 pattern = "y"
248 "#,
249 )
250 .unwrap();
251 assert!(matches!(rs.validate_ids(), Err(Error::DuplicateRuleId(id)) if id == "a"));
252 }
253}