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::Multiple(_)
63                | RuleType::Alias(_)
64                | RuleType::Unknown(_) => {}
65            }
66        }
67        Ok(())
68    }
69}
70
71fn get_rule(config: &PiiConfig, id: &str) -> Option<RuleRef> {
72    if let Some(spec) = config.rules.get(id) {
73        Some(RuleRef::new(id.to_owned(), spec))
74    } else {
75        BUILTIN_RULES_MAP
76            .get(id)
77            .map(|spec| RuleRef::new(id.to_owned(), spec))
78    }
79}
80
81#[allow(clippy::mutable_key_type)]
82fn collect_rules(
83    config: &PiiConfig,
84    rules: &mut BTreeSet<RuleRef>,
85    seen_ids: &mut BTreeSet<Box<str>>,
86    rule_id: &str,
87    parent: Option<RuleRef>,
88    depth: usize,
89) {
90    if depth > MAX_DEPTH {
91        return;
92    }
93
94    let rule = match get_rule(config, rule_id) {
95        Some(rule) => rule,
96        None => return,
97    };
98
99    if !seen_ids.insert(rule_id.into()) {
100        return;
101    }
102
103    let rule = match parent {
104        Some(parent) => rule.for_parent(parent),
105        None => rule,
106    };
107
108    match rule.ty {
109        RuleType::Multiple(ref m) => {
110            let parent = if m.hide_inner {
111                Some(rule.clone())
112            } else {
113                None
114            };
115            for rule_id in &m.rules {
116                collect_rules(config, rules, seen_ids, rule_id, parent.clone(), depth + 1);
117            }
118        }
119        RuleType::Alias(ref a) => {
120            let parent = if a.hide_inner {
121                Some(rule.clone())
122            } else {
123                None
124            };
125            collect_rules(config, rules, seen_ids, &a.rule, parent, depth + 1);
126        }
127        RuleType::Unknown(_) => {}
128        _ => {
129            rules.insert(rule);
130        }
131    }
132}
133
134/// Reference to a PII rule.
135#[derive(Debug, Clone)]
136pub(super) struct RuleRef {
137    pub id: String,
138    pub origin: String,
139    pub ty: RuleType,
140    pub redaction: Redaction,
141}
142
143impl RuleRef {
144    fn new(id: String, spec: &RuleSpec) -> Self {
145        RuleRef {
146            origin: id.clone(),
147            id,
148            ty: spec.ty.clone(),
149            redaction: spec.redaction.clone(),
150        }
151    }
152
153    pub fn for_parent(self, parent: Self) -> Self {
154        RuleRef {
155            id: self.id,
156            origin: parent.origin,
157            ty: self.ty,
158            redaction: match parent.redaction {
159                Redaction::Default => self.redaction,
160                _ => parent.redaction,
161            },
162        }
163    }
164}
165
166impl PartialEq for RuleRef {
167    fn eq(&self, other: &Self) -> bool {
168        self.id == other.id
169    }
170}
171
172impl Eq for RuleRef {}
173
174impl PartialOrd for RuleRef {
175    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
176        Some(self.cmp(other))
177    }
178}
179
180impl Ord for RuleRef {
181    fn cmp(&self, other: &Self) -> Ordering {
182        self.id.cmp(&other.id)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use std::collections::BTreeMap;
189
190    use crate::AliasRule;
191
192    use super::*;
193
194    #[test]
195    fn cycle_singleton() {
196        // a -> a
197        let config = PiiConfig {
198            rules: BTreeMap::from([(
199                "a".to_owned(),
200                RuleSpec {
201                    ty: RuleType::Alias(AliasRule {
202                        rule: "a".to_owned(),
203                        hide_inner: false,
204                    }),
205                    redaction: Redaction::Default,
206                },
207            )]),
208            ..Default::default()
209        };
210        #[allow(clippy::mutable_key_type)]
211        let mut collected_rules = Default::default();
212        let mut seen_ids = Default::default();
213        collect_rules(&config, &mut collected_rules, &mut seen_ids, "a", None, 0);
214
215        // The cycle has been removed:
216        assert!(collected_rules.is_empty());
217    }
218
219    #[test]
220    fn cycle_pair() {
221        // a -> b -> a
222        let config = PiiConfig {
223            rules: BTreeMap::from([
224                (
225                    "a".to_owned(),
226                    RuleSpec {
227                        ty: RuleType::Alias(AliasRule {
228                            rule: "b".to_owned(),
229                            hide_inner: false,
230                        }),
231                        redaction: Redaction::Default,
232                    },
233                ),
234                (
235                    "b".to_owned(),
236                    RuleSpec {
237                        ty: RuleType::Alias(AliasRule {
238                            rule: "a".to_owned(),
239                            hide_inner: false,
240                        }),
241                        redaction: Redaction::Default,
242                    },
243                ),
244            ]),
245            ..Default::default()
246        };
247        #[allow(clippy::mutable_key_type)]
248        let mut collected_rules = Default::default();
249        let mut seen_ids = Default::default();
250        collect_rules(&config, &mut collected_rules, &mut seen_ids, "a", None, 0);
251
252        // The cycle has been removed:
253        assert!(collected_rules.is_empty());
254    }
255
256    #[test]
257    fn only_one_shared_rule_survives() {
258        // When multiple aliases point to the same rule, only one of their names survives.
259        // a -> c
260        // b -> c
261        let config = PiiConfig {
262            rules: BTreeMap::from([
263                (
264                    "a".to_owned(),
265                    RuleSpec {
266                        ty: RuleType::Alias(AliasRule {
267                            rule: "c".to_owned(),
268                            hide_inner: true,
269                        }),
270                        redaction: Redaction::Default,
271                    },
272                ),
273                (
274                    "b".to_owned(),
275                    RuleSpec {
276                        ty: RuleType::Alias(AliasRule {
277                            rule: "c".to_owned(),
278                            hide_inner: true,
279                        }),
280                        redaction: Redaction::Default,
281                    },
282                ),
283                (
284                    "c".to_owned(),
285                    RuleSpec {
286                        ty: RuleType::Anything,
287                        redaction: Redaction::Default,
288                    },
289                ),
290            ]),
291            ..Default::default()
292        };
293        #[allow(clippy::mutable_key_type)]
294        let mut collected_rules = BTreeSet::new();
295        let mut seen_ids = BTreeSet::new();
296        collect_rules(&config, &mut collected_rules, &mut seen_ids, "a", None, 0);
297        collect_rules(&config, &mut collected_rules, &mut seen_ids, "b", None, 0);
298
299        let collected_rules: Vec<_> = collected_rules
300            .into_iter()
301            .map(|rr| (rr.origin, rr.id))
302            .collect();
303
304        insta::assert_debug_snapshot!(collected_rules, @r#"
305        [
306            (
307                "a",
308                "c",
309            ),
310        ]
311        "#);
312    }
313
314    #[test]
315    fn double_origin() {
316        // a -> b -> c
317        let config = PiiConfig {
318            rules: BTreeMap::from([
319                (
320                    "a".to_owned(),
321                    RuleSpec {
322                        ty: RuleType::Alias(AliasRule {
323                            rule: "b".to_owned(),
324                            hide_inner: true,
325                        }),
326                        redaction: Redaction::Default,
327                    },
328                ),
329                (
330                    "b".to_owned(),
331                    RuleSpec {
332                        ty: RuleType::Alias(AliasRule {
333                            rule: "c".to_owned(),
334                            hide_inner: true,
335                        }),
336                        redaction: Redaction::Default,
337                    },
338                ),
339                (
340                    "c".to_owned(),
341                    RuleSpec {
342                        ty: RuleType::Anything,
343                        redaction: Redaction::Default,
344                    },
345                ),
346            ]),
347            ..Default::default()
348        };
349        #[allow(clippy::mutable_key_type)]
350        let mut collected_rules = Default::default();
351        let mut seen_ids = Default::default();
352        collect_rules(&config, &mut collected_rules, &mut seen_ids, "a", None, 0);
353
354        let collected_rules: Vec<_> = collected_rules
355            .into_iter()
356            .map(|rr| (rr.origin, rr.id))
357            .collect();
358
359        insta::assert_debug_snapshot!(collected_rules, @r#"
360        [
361            (
362                "a",
363                "c",
364            ),
365        ]
366        "#);
367    }
368
369    #[test]
370    fn depth_bound() {
371        // 0 -> 1 -> 2 ...
372        let rule_fn = |i: usize| {
373            (
374                i.to_string(),
375                RuleSpec {
376                    ty: RuleType::Alias(AliasRule {
377                        rule: (i + 1).to_string(),
378                        hide_inner: false,
379                    }),
380                    redaction: Redaction::Default,
381                },
382            )
383        };
384        let config = PiiConfig {
385            rules: BTreeMap::from_iter((0..1000).map(rule_fn)),
386            ..Default::default()
387        };
388        #[allow(clippy::mutable_key_type)]
389        let mut collected_rules = Default::default();
390        let mut seen_ids = Default::default();
391        collect_rules(&config, &mut collected_rules, &mut seen_ids, "0", None, 0);
392
393        assert!(collected_rules.is_empty()); // does not crash
394    }
395}