Skip to main content

relay_pii/
compiledconfig.rs

1use std::cmp::Ordering;
2use std::collections::BTreeSet;
3
4use crate::builtin::BUILTIN_RULES_MAP;
5use crate::{PiiConfig, PiiConfigError, Redaction, RuleSpec, RuleType, SelectorSpec};
6
7/// Maximum depth for recursive rules;
8const MAX_DEPTH: usize = 200;
9
10/// A representation of `PiiConfig` that is more (CPU-)efficient for use in `PiiProcessor`.
11///
12/// It is lossy in the sense that it cannot be consumed by downstream Relays, so both versions have
13/// to be kept around.
14#[derive(Debug, Clone)]
15pub struct CompiledPiiConfig {
16    pub(super) applications: Vec<(SelectorSpec, BTreeSet<RuleRef>)>,
17}
18
19impl CompiledPiiConfig {
20    /// Computes the compiled PII config.
21    pub fn new(config: &PiiConfig) -> Self {
22        let mut applications = Vec::new();
23        for (selector, rules) in &config.applications {
24            #[allow(clippy::mutable_key_type)]
25            let mut rule_set = BTreeSet::default();
26            let mut seen_ids = BTreeSet::default();
27            for rule_id in rules {
28                collect_rules(config, &mut rule_set, &mut seen_ids, rule_id, None, 0);
29            }
30            applications.push((selector.clone(), rule_set));
31        }
32
33        CompiledPiiConfig { applications }
34    }
35
36    /// Force compilation of all regex patterns in this config.
37    ///
38    /// Used to verify that all patterns are valid regex.
39    pub fn force_compile(&self) -> Result<(), PiiConfigError> {
40        for rule in self.applications.iter().flat_map(|(_, rules)| rules.iter()) {
41            match &rule.ty {
42                RuleType::Pattern(rule) => {
43                    rule.pattern.compiled().map_err(|e| e.clone())?;
44                }
45                RuleType::RedactPair(rule) => {
46                    rule.key_pattern.compiled().map_err(|e| e.clone())?;
47                }
48                RuleType::Anything
49                | RuleType::Imei
50                | RuleType::Mac
51                | RuleType::Uuid
52                | RuleType::Email
53                | RuleType::Ip
54                | RuleType::Creditcard
55                | RuleType::Iban
56                | RuleType::Userpath
57                | RuleType::Pemkey
58                | RuleType::UrlAuth
59                | RuleType::UsSsn
60                | RuleType::Bearer
61                | RuleType::Password
62                | RuleType::Cookies
63                | RuleType::Multiple(_)
64                | RuleType::Alias(_)
65                | RuleType::Unknown(_) => {}
66            }
67        }
68        Ok(())
69    }
70}
71
72fn get_rule(config: &PiiConfig, id: &str) -> Option<RuleRef> {
73    if let Some(spec) = config.rules.get(id) {
74        Some(RuleRef::new(id.to_owned(), spec))
75    } else {
76        BUILTIN_RULES_MAP
77            .get(id)
78            .map(|spec| RuleRef::new(id.to_owned(), spec))
79    }
80}
81
82#[allow(clippy::mutable_key_type)]
83fn collect_rules(
84    config: &PiiConfig,
85    rules: &mut BTreeSet<RuleRef>,
86    seen_ids: &mut BTreeSet<Box<str>>,
87    rule_id: &str,
88    parent: Option<RuleRef>,
89    depth: usize,
90) {
91    if depth > MAX_DEPTH {
92        return;
93    }
94
95    let rule = match get_rule(config, rule_id) {
96        Some(rule) => rule,
97        None => return,
98    };
99
100    if !seen_ids.insert(rule_id.into()) {
101        return;
102    }
103
104    let rule = match parent {
105        Some(parent) => rule.for_parent(parent),
106        None => rule,
107    };
108
109    match rule.ty {
110        RuleType::Multiple(ref m) => {
111            let parent = if m.hide_inner {
112                Some(rule.clone())
113            } else {
114                None
115            };
116            for rule_id in &m.rules {
117                collect_rules(config, rules, seen_ids, rule_id, parent.clone(), depth + 1);
118            }
119        }
120        RuleType::Alias(ref a) => {
121            let parent = if a.hide_inner {
122                Some(rule.clone())
123            } else {
124                None
125            };
126            collect_rules(config, rules, seen_ids, &a.rule, parent, depth + 1);
127        }
128        RuleType::Unknown(_) => {}
129        _ => {
130            rules.insert(rule);
131        }
132    }
133}
134
135/// Reference to a PII rule.
136#[derive(Debug, Clone)]
137pub(super) struct RuleRef {
138    pub id: String,
139    pub origin: String,
140    pub ty: RuleType,
141    pub redaction: Redaction,
142}
143
144impl RuleRef {
145    fn new(id: String, spec: &RuleSpec) -> Self {
146        RuleRef {
147            origin: id.clone(),
148            id,
149            ty: spec.ty.clone(),
150            redaction: spec.redaction.clone(),
151        }
152    }
153
154    pub fn for_parent(self, parent: Self) -> Self {
155        RuleRef {
156            id: self.id,
157            origin: parent.origin,
158            ty: self.ty,
159            redaction: match parent.redaction {
160                Redaction::Default => self.redaction,
161                _ => parent.redaction,
162            },
163        }
164    }
165}
166
167impl PartialEq for RuleRef {
168    fn eq(&self, other: &Self) -> bool {
169        self.id == other.id
170    }
171}
172
173impl Eq for RuleRef {}
174
175impl PartialOrd for RuleRef {
176    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
177        Some(self.cmp(other))
178    }
179}
180
181impl Ord for RuleRef {
182    fn cmp(&self, other: &Self) -> Ordering {
183        self.id.cmp(&other.id)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use std::collections::BTreeMap;
190
191    use crate::AliasRule;
192
193    use super::*;
194
195    #[test]
196    fn cycle_singleton() {
197        // a -> a
198        let config = PiiConfig {
199            rules: BTreeMap::from([(
200                "a".to_owned(),
201                RuleSpec {
202                    ty: RuleType::Alias(AliasRule {
203                        rule: "a".to_owned(),
204                        hide_inner: false,
205                    }),
206                    redaction: Redaction::Default,
207                },
208            )]),
209            ..Default::default()
210        };
211        #[allow(clippy::mutable_key_type)]
212        let mut collected_rules = Default::default();
213        let mut seen_ids = Default::default();
214        collect_rules(&config, &mut collected_rules, &mut seen_ids, "a", None, 0);
215
216        // The cycle has been removed:
217        assert!(collected_rules.is_empty());
218    }
219
220    #[test]
221    fn cycle_pair() {
222        // a -> b -> a
223        let config = PiiConfig {
224            rules: BTreeMap::from([
225                (
226                    "a".to_owned(),
227                    RuleSpec {
228                        ty: RuleType::Alias(AliasRule {
229                            rule: "b".to_owned(),
230                            hide_inner: false,
231                        }),
232                        redaction: Redaction::Default,
233                    },
234                ),
235                (
236                    "b".to_owned(),
237                    RuleSpec {
238                        ty: RuleType::Alias(AliasRule {
239                            rule: "a".to_owned(),
240                            hide_inner: false,
241                        }),
242                        redaction: Redaction::Default,
243                    },
244                ),
245            ]),
246            ..Default::default()
247        };
248        #[allow(clippy::mutable_key_type)]
249        let mut collected_rules = Default::default();
250        let mut seen_ids = Default::default();
251        collect_rules(&config, &mut collected_rules, &mut seen_ids, "a", None, 0);
252
253        // The cycle has been removed:
254        assert!(collected_rules.is_empty());
255    }
256
257    #[test]
258    fn only_one_shared_rule_survives() {
259        // When multiple aliases point to the same rule, only one of their names survives.
260        // a -> c
261        // b -> c
262        let config = PiiConfig {
263            rules: BTreeMap::from([
264                (
265                    "a".to_owned(),
266                    RuleSpec {
267                        ty: RuleType::Alias(AliasRule {
268                            rule: "c".to_owned(),
269                            hide_inner: true,
270                        }),
271                        redaction: Redaction::Default,
272                    },
273                ),
274                (
275                    "b".to_owned(),
276                    RuleSpec {
277                        ty: RuleType::Alias(AliasRule {
278                            rule: "c".to_owned(),
279                            hide_inner: true,
280                        }),
281                        redaction: Redaction::Default,
282                    },
283                ),
284                (
285                    "c".to_owned(),
286                    RuleSpec {
287                        ty: RuleType::Anything,
288                        redaction: Redaction::Default,
289                    },
290                ),
291            ]),
292            ..Default::default()
293        };
294        #[allow(clippy::mutable_key_type)]
295        let mut collected_rules = BTreeSet::new();
296        let mut seen_ids = BTreeSet::new();
297        collect_rules(&config, &mut collected_rules, &mut seen_ids, "a", None, 0);
298        collect_rules(&config, &mut collected_rules, &mut seen_ids, "b", None, 0);
299
300        let collected_rules: Vec<_> = collected_rules
301            .into_iter()
302            .map(|rr| (rr.origin, rr.id))
303            .collect();
304
305        insta::assert_debug_snapshot!(collected_rules, @r#"
306        [
307            (
308                "a",
309                "c",
310            ),
311        ]
312        "#);
313    }
314
315    #[test]
316    fn double_origin() {
317        // a -> b -> c
318        let config = PiiConfig {
319            rules: BTreeMap::from([
320                (
321                    "a".to_owned(),
322                    RuleSpec {
323                        ty: RuleType::Alias(AliasRule {
324                            rule: "b".to_owned(),
325                            hide_inner: true,
326                        }),
327                        redaction: Redaction::Default,
328                    },
329                ),
330                (
331                    "b".to_owned(),
332                    RuleSpec {
333                        ty: RuleType::Alias(AliasRule {
334                            rule: "c".to_owned(),
335                            hide_inner: true,
336                        }),
337                        redaction: Redaction::Default,
338                    },
339                ),
340                (
341                    "c".to_owned(),
342                    RuleSpec {
343                        ty: RuleType::Anything,
344                        redaction: Redaction::Default,
345                    },
346                ),
347            ]),
348            ..Default::default()
349        };
350        #[allow(clippy::mutable_key_type)]
351        let mut collected_rules = Default::default();
352        let mut seen_ids = Default::default();
353        collect_rules(&config, &mut collected_rules, &mut seen_ids, "a", None, 0);
354
355        let collected_rules: Vec<_> = collected_rules
356            .into_iter()
357            .map(|rr| (rr.origin, rr.id))
358            .collect();
359
360        insta::assert_debug_snapshot!(collected_rules, @r#"
361        [
362            (
363                "a",
364                "c",
365            ),
366        ]
367        "#);
368    }
369
370    #[test]
371    fn depth_bound() {
372        // 0 -> 1 -> 2 ...
373        let rule_fn = |i: usize| {
374            (
375                i.to_string(),
376                RuleSpec {
377                    ty: RuleType::Alias(AliasRule {
378                        rule: (i + 1).to_string(),
379                        hide_inner: false,
380                    }),
381                    redaction: Redaction::Default,
382                },
383            )
384        };
385        let config = PiiConfig {
386            rules: BTreeMap::from_iter((0..1000).map(rule_fn)),
387            ..Default::default()
388        };
389        #[allow(clippy::mutable_key_type)]
390        let mut collected_rules = Default::default();
391        let mut seen_ids = Default::default();
392        collect_rules(&config, &mut collected_rules, &mut seen_ids, "0", None, 0);
393
394        assert!(collected_rules.is_empty()); // does not crash
395    }
396}