Skip to main content

provide_telemetry/
classification.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5
6use std::sync::{Mutex, OnceLock};
7
8#[derive(Clone, Debug, Default, PartialEq, Eq)]
9pub enum DataClass {
10    #[default]
11    Public,
12    Internal,
13    Pii,
14    Phi,
15    Pci,
16    Secret,
17}
18
19impl DataClass {
20    pub fn as_str(&self) -> &'static str {
21        match self {
22            Self::Public => "PUBLIC",
23            Self::Internal => "INTERNAL",
24            Self::Pii => "PII",
25            Self::Phi => "PHI",
26            Self::Pci => "PCI",
27            Self::Secret => "SECRET", // pragma: allowlist secret
28        }
29    }
30}
31
32#[derive(Clone, Debug, Default, PartialEq, Eq)]
33pub struct ClassificationRule {
34    pub pattern: String,
35    pub classification: DataClass,
36}
37
38impl ClassificationRule {
39    pub fn new(pattern: impl Into<String>, classification: DataClass) -> Self {
40        Self {
41            pattern: pattern.into(),
42            classification,
43        }
44    }
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct ClassificationPolicy {
49    pub public: String,
50    pub internal: String,
51    pub pii: String,
52    pub phi: String,
53    pub pci: String,
54    pub secret: String,
55}
56
57impl ClassificationPolicy {
58    /// Return the action string for a given *label* (e.g. `"PII"`, `"PHI"`, …).
59    /// Labels are the upper-case strings returned by `DataClass::as_str()`.
60    /// Unknown labels fall through to `"pass"`.
61    pub fn lookup_action(&self, label: &str) -> &str {
62        match label {
63            "PUBLIC" => &self.public,
64            "INTERNAL" => &self.internal,
65            "PII" => &self.pii,
66            "PHI" => &self.phi,
67            "PCI" => &self.pci,
68            "SECRET" => &self.secret, // pragma: allowlist secret
69            _ => "pass",
70        }
71    }
72}
73
74impl Default for ClassificationPolicy {
75    fn default() -> Self {
76        Self {
77            public: "pass".to_string(),
78            internal: "pass".to_string(),
79            pii: "redact".to_string(),
80            phi: "drop".to_string(),
81            pci: "hash".to_string(),
82            secret: "drop".to_string(), // pragma: allowlist secret
83        }
84    }
85}
86
87static POLICY: OnceLock<Mutex<ClassificationPolicy>> = OnceLock::new();
88
89#[cfg_attr(test, mutants::skip)] // Equivalent mutants only swap in Mutex::default().
90fn default_policy_mutex() -> Mutex<ClassificationPolicy> {
91    Mutex::new(ClassificationPolicy::default())
92}
93
94fn policy() -> &'static Mutex<ClassificationPolicy> {
95    POLICY.get_or_init(default_policy_mutex)
96}
97
98pub fn set_classification_policy(p: ClassificationPolicy) {
99    *crate::_lock::lock(policy()) = p;
100}
101
102pub fn get_classification_policy() -> ClassificationPolicy {
103    crate::_lock::lock(policy()).clone()
104}
105
106static RULES: OnceLock<Mutex<Vec<ClassificationRule>>> = OnceLock::new();
107
108#[cfg_attr(test, mutants::skip)] // Equivalent mutants only rewrite Vec::new() syntax.
109fn empty_rules_mutex() -> Mutex<Vec<ClassificationRule>> {
110    Mutex::new(Vec::new())
111}
112
113fn rules() -> &'static Mutex<Vec<ClassificationRule>> {
114    RULES.get_or_init(empty_rules_mutex)
115}
116
117/// Match *key* against *pattern* using fnmatch semantics:
118/// `*` matches any sequence of characters (including empty); `?` matches
119/// exactly one character.  No character-class support is needed here.
120fn match_glob(pattern: &str, key: &str) -> bool {
121    // Fast path: no wildcards → exact match.
122    if !pattern.contains(['*', '?']) {
123        return pattern == key;
124    }
125    // One-pass recursive matcher on byte slices (all ASCII for key names).
126    fn glob_match(p: &[u8], s: &[u8]) -> bool {
127        match (p.first(), s.first()) {
128            // Both exhausted → full match.
129            (None, None) => true,
130            // Pattern exhausted but string remains → no match.
131            (None, Some(_)) => false,
132            // `*` — try skipping zero or more characters in s.
133            (Some(b'*'), _) => {
134                let p_rest = &p[1..];
135                // Try matching p_rest against every suffix of s (including empty).
136                for offset in 0..=s.len() {
137                    if glob_match(p_rest, &s[offset..]) {
138                        return true;
139                    }
140                }
141                false
142            }
143            // `?` matches any single character that exists.
144            (Some(b'?'), Some(_)) => glob_match(&p[1..], &s[1..]),
145            // `?` but string exhausted → no match.
146            (Some(b'?'), None) => false,
147            // Literal character must match exactly.
148            (Some(&pc), Some(&sc)) => pc == sc && glob_match(&p[1..], &s[1..]),
149            // Pattern has chars but string is empty (non-star, non-zero).
150            (Some(_), None) => false,
151        }
152    }
153    glob_match(pattern.as_bytes(), key.as_bytes())
154}
155
156pub fn register_classification_rule(rule: ClassificationRule) {
157    crate::_lock::lock(rules()).push(rule);
158}
159
160pub fn register_classification_rules(next: Vec<ClassificationRule>) {
161    crate::_lock::lock(rules()).extend(next);
162}
163
164pub fn clear_classification_rules() {
165    crate::_lock::lock(rules()).clear();
166}
167
168pub fn classify_key(key: &str) -> Option<String> {
169    let rules = crate::_lock::lock(rules());
170    for rule in rules.iter() {
171        if match_glob(&rule.pattern, key) {
172            return Some(rule.classification.as_str().to_string());
173        }
174    }
175    None
176}
177
178#[cfg(test)]
179#[path = "classification_tests.rs"]
180mod tests;