1use anyhow::{anyhow, Context, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13use log::{debug, info, warn};
14use std::fmt;
15use regex::Regex;
16use std::hash::{Hash, Hasher};
17
18pub const MAX_PATTERN_LENGTH: usize = 500;
20
21#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
23#[serde(default)]
24pub struct RedactionRule {
25 pub name: String,
27 pub description: Option<String>,
29 pub pattern: Option<String>,
31 pub pattern_type: String,
33 pub replace_with: String,
35 pub version: String,
36 pub created_at: String,
37 pub author: String,
38 pub updated_at: String,
39 pub multiline: bool,
41 pub dot_matches_new_line: bool,
43 pub opt_in: bool,
45 pub programmatic_validation: bool,
47 pub enabled: Option<bool>,
49 pub severity: Option<String>,
51 pub tags: Option<Vec<String>>,
53}
54
55impl Hash for RedactionRule {
56 fn hash<H: Hasher>(&self, state: &mut H) {
57 self.name.hash(state);
58 self.description.hash(state);
59 self.pattern.hash(state);
60 self.pattern_type.hash(state);
61 self.replace_with.hash(state);
62 self.version.hash(state);
63 self.created_at.hash(state);
64 self.author.hash(state);
65 self.updated_at.hash(state);
66 self.multiline.hash(state);
67 self.dot_matches_new_line.hash(state);
68 self.opt_in.hash(state);
69 self.programmatic_validation.hash(state);
70 self.enabled.hash(state);
71 self.severity.hash(state);
72 }
73}
74
75impl Default for RedactionRule {
76 fn default() -> Self {
77 Self {
78 name: String::new(),
79 description: None,
80 pattern: None,
81 pattern_type: "regex".to_string(),
82 replace_with: "[REDACTED]".to_string(),
83 version: "1.0.0".to_string(),
84 created_at: "1970-01-01T00:00:00Z".to_string(),
85 updated_at: "1970-01-01T00:00:00Z".to_string(),
86 author: "Relay Team".to_string(),
87 multiline: false,
88 dot_matches_new_line: false,
89 opt_in: false,
90 programmatic_validation: false,
91 enabled: None,
92 severity: None,
93 tags: None,
94 }
95 }
96}
97
98#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
100pub struct EntropyConfig {
101 pub threshold: Option<f64>,
103 pub window_size: Option<usize>,
106}
107
108impl Hash for EntropyConfig {
109 fn hash<H: Hasher>(&self, state: &mut H) {
110 if let Some(t) = self.threshold {
111 t.to_bits().hash(state);
112 } else {
113 0u64.hash(state);
114 }
115 self.window_size.hash(state);
116 }
117}
118
119#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Hash)]
121#[serde(default)]
122pub struct EngineConfig {
123 pub entropy: EntropyConfig,
124}
125
126#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
128pub struct RedactionConfig {
129 pub rules: Vec<RedactionRule>,
131 #[serde(default)]
133 pub engines: EngineConfig,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct RedactionSummaryItem {
139 pub rule_name: String,
140 pub occurrences: usize,
141 pub original_texts: Vec<String>,
142 pub sanitized_texts: Vec<String>,
143}
144
145#[derive(Debug)]
147pub struct RuleConfigNotFoundError {
148 pub config_name: String,
149}
150
151impl fmt::Display for RuleConfigNotFoundError {
152 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
153 write!(f, "Rule configuration '{}' not found.", self.config_name)
154 }
155}
156
157impl std::error::Error for RuleConfigNotFoundError {}
158
159impl RedactionConfig {
160 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
162 let path = path.as_ref();
163 info!("Loading custom rules from: {}", path.display());
164 let text = std::fs::read_to_string(path)
165 .with_context(|| format!("Failed to read config file {}", path.display()))?;
166 let config: RedactionConfig = serde_yml::from_str(&text)
167 .with_context(|| format!("Failed to parse config file {}", path.display()))?;
168
169 validate_rules(&config.rules)?;
170 info!("Loaded {} rules from file {}.", config.rules.len(), path.display());
171
172 Ok(config)
173 }
174
175 pub fn load_default_rules() -> Result<Self> {
177 debug!("Loading default rules from embedded string...");
178 let default_yaml = include_str!("../config/default_rules.yaml");
179 let config: RedactionConfig = serde_yml::from_str(default_yaml)
180 .context("Failed to parse default rules")?;
181
182 debug!("Loaded {} default rules.", config.rules.len());
183 Ok(config)
184 }
185
186 pub fn set_active_rules(&mut self, enable_rules: &[String], disable_rules: &[String]) {
188 let enable_set: HashSet<&str> = enable_rules.iter().map(String::as_str).collect();
189 let disable_set: HashSet<&str> = disable_rules.iter().map(String::as_str).collect();
190
191 debug!("Initial rules count before filtering: {}", self.rules.len());
192
193 let all_rule_names: HashSet<&str> = self.rules.iter().map(|r| r.name.as_str()).collect();
194
195 for rule_name in enable_set.difference(&all_rule_names) {
196 warn!("Rule '{}' in `enable_rules` list does not exist.", rule_name);
197 }
198
199 for rule_name in disable_set.difference(&all_rule_names) {
200 warn!("Rule '{}' in `disable_rules` list does not exist.", rule_name);
201 }
202
203 self.rules.retain(|rule| {
204 let rule_name_str = rule.name.as_str();
205 !disable_set.contains(rule_name_str) && (!rule.opt_in || enable_set.contains(rule_name_str))
206 });
207
208 debug!("Final active rules count after filtering: {}", self.rules.len());
209 }
210}
211
212pub fn merge_rules(
214 default_config: RedactionConfig,
215 user_config: Option<RedactionConfig>,
216) -> RedactionConfig {
217 debug!("merge_rules called. Initial default rules count: {}", default_config.rules.len());
218
219 let mut final_rules_map: HashMap<String, RedactionRule> = default_config.rules.into_iter()
220 .map(|rule| (rule.name.clone(), rule))
221 .collect();
222
223 let mut final_engines = default_config.engines;
224
225 if let Some(user_cfg) = user_config {
226 debug!("User config provided. Merging {} user rules.", user_cfg.rules.len());
227 for user_rule in user_cfg.rules {
228 final_rules_map.insert(user_rule.name.clone(), user_rule);
229 }
230
231 if let Some(user_threshold) = user_cfg.engines.entropy.threshold {
232 debug!("Overriding entropy threshold with user value: {}", user_threshold);
233 final_engines.entropy.threshold = Some(user_threshold);
234 }
235
236 if let Some(user_window) = user_cfg.engines.entropy.window_size {
237 debug!("Overriding entropy window size with user value: {}", user_window);
238 final_engines.entropy.window_size = Some(user_window);
239 }
240 }
241
242 let final_rules: Vec<RedactionRule> = final_rules_map.into_values().collect();
243 debug!("Final total rules after merge: {}", final_rules.len());
244
245 RedactionConfig {
246 rules: final_rules,
247 engines: final_engines,
248 }
249}
250
251fn validate_rules(rules: &[RedactionRule]) -> Result<()> {
253 let mut rule_names = HashSet::new();
254 let mut errors = Vec::new();
255 let capture_group_regex = Regex::new(r"\$(\d+)").unwrap();
256
257 for rule in rules {
258 if rule.name.is_empty() {
259 errors.push("A rule has an empty `name` field.".to_string());
260 } else if !rule_names.insert(rule.name.clone()) {
261 errors.push(format!("Duplicate rule name found: '{}'.", rule.name));
262 }
263
264 if rule.pattern_type == "regex" {
265 let pattern = match &rule.pattern {
266 Some(p) => p,
267 None => {
268 errors.push(format!("Rule '{}' is missing the `pattern` field.", rule.name));
269 continue;
270 }
271 };
272
273 if pattern.is_empty() {
274 errors.push(format!("Rule '{}' has an empty `pattern` field.", rule.name));
275 }
276
277 if let Err(e) = Regex::new(pattern) {
278 errors.push(format!("Rule '{}' has an invalid regex pattern: {}", rule.name, e));
279 continue;
280 }
281
282 let mut group_count = 0;
283 let mut is_escaped = false;
284 for c in pattern.chars() {
285 match c {
286 '\\' => is_escaped = !is_escaped,
287 '(' if !is_escaped => group_count += 1,
288 _ => is_escaped = false,
289 }
290 }
291
292 for cap in capture_group_regex.captures_iter(&rule.replace_with) {
293 if let Some(group_num_str) = cap.get(1) {
294 if let Ok(group_num) = group_num_str.as_str().parse::<usize>() {
295 if group_num > group_count {
296 errors.push(format!(
297 "Rule '{}': replacement references non-existent capture group '${}'.",
298 rule.name, group_num
299 ));
300 }
301 }
302 }
303 }
304 }
305 }
306
307 if !errors.is_empty() {
308 let full_error_message = format!("Rule validation failed:\n{}", errors.join("\n"));
309 Err(anyhow!(full_error_message))
310 } else {
311 Ok(())
312 }
313}