Skip to main content

qubit_redact/policy/
redaction_policy.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Immutable field-classification and masking policy.
9
10use std::{
11    collections::{
12        BTreeMap,
13        BTreeSet,
14    },
15    ops::ControlFlow,
16    sync::{
17        Arc,
18        LazyLock,
19        OnceLock,
20    },
21};
22
23use super::{
24    AllowRule,
25    DiagnosticBudget,
26    FieldClassification,
27    FieldMatchKind,
28    FieldNameMatching,
29    GlobalDefaultAlreadySet,
30    MaskingPolicy,
31    RedactionPolicyBuilder,
32    SensitiveFieldPreset,
33    SensitiveFieldRule,
34    Sensitivity,
35    UnknownFieldPolicy,
36    internal::{
37        RedactionPolicyInner,
38        visit_canonical_field_candidates,
39    },
40};
41
42/// Built-in sensitive fields not owned by a named preset.
43const STANDARD_EXTRA_FIELDS: &[(&str, Sensitivity)] = &[
44    ("auth_app_token", Sensitivity::High),
45    ("auth_user_token", Sensitivity::High),
46    ("connection_string", Sensitivity::Secret),
47    ("database_uri", Sensitivity::Secret),
48    ("database_url", Sensitivity::Secret),
49    ("license_key", Sensitivity::Medium),
50    ("mysql_pwd", Sensitivity::Secret),
51    ("rediscli_auth", Sensitivity::Secret),
52    ("sig", Sensitivity::Secret),
53    ("signature", Sensitivity::Secret),
54];
55
56/// Lazily initialized built-in conservative policy.
57static STANDARD_POLICY: LazyLock<RedactionPolicy> =
58    LazyLock::new(RedactionPolicy::build_standard);
59
60/// Process-wide default policy installed at most once.
61static GLOBAL_DEFAULT: OnceLock<RedactionPolicy> = OnceLock::new();
62
63/// Immutable field-classification and value-masking policy.
64///
65/// Cloning a policy shares its complete configuration and has constant cost.
66#[must_use]
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct RedactionPolicy {
69    /// Shared immutable policy state.
70    inner: Arc<RedactionPolicyInner>,
71    /// Limits applied whenever this policy renders a diagnostic.
72    diagnostic_budget: DiagnosticBudget,
73}
74
75impl RedactionPolicy {
76    /// Returns the built-in conservative policy.
77    ///
78    /// # Returns
79    ///
80    /// A policy containing every built-in preset and extra sensitive field.
81    #[inline(always)]
82    pub fn standard() -> Self {
83        STANDARD_POLICY.clone()
84    }
85
86    /// Returns a snapshot of the process-wide default policy.
87    ///
88    /// Before a custom default is installed, this returns a new shared handle
89    /// to the built-in [`Self::standard`] policy. The returned snapshot never
90    /// changes after a later installation.
91    ///
92    /// # Returns
93    ///
94    /// A shared immutable snapshot of the current process-wide default.
95    #[inline]
96    pub fn global_default() -> Self {
97        GLOBAL_DEFAULT.get().cloned().unwrap_or_else(Self::standard)
98    }
99
100    /// Creates a builder without sensitive or allow rules.
101    ///
102    /// # Returns
103    ///
104    /// A mutable builder with default matching, masking, and diagnostic limits.
105    #[inline]
106    pub fn builder() -> RedactionPolicyBuilder {
107        RedactionPolicyBuilder::new()
108    }
109
110    /// Creates a builder initialized from the current default policy.
111    ///
112    /// # Returns
113    ///
114    /// A mutable builder containing a snapshot of the current default policy.
115    #[inline]
116    pub fn builder_from_default() -> RedactionPolicyBuilder {
117        RedactionPolicyBuilder::from_policy(&Self::default())
118    }
119
120    /// Creates a builder by copying one immutable policy snapshot.
121    ///
122    /// # Parameters
123    ///
124    /// * `base` - Policy whose complete configuration is copied.
125    ///
126    /// # Returns
127    ///
128    /// A mutable builder initialized from `base`.
129    #[inline]
130    pub fn builder_from(base: &Self) -> RedactionPolicyBuilder {
131        RedactionPolicyBuilder::from_policy(base)
132    }
133
134    /// Constructs the built-in policy without consulting `Default`.
135    ///
136    /// # Returns
137    ///
138    /// The complete built-in conservative policy.
139    pub(super) fn build_standard() -> Self {
140        let mut builder = RedactionPolicyBuilder::empty();
141        for preset in [
142            SensitiveFieldPreset::Credentials,
143            SensitiveFieldPreset::CredentialContainers,
144            SensitiveFieldPreset::AuthTokens,
145            SensitiveFieldPreset::Http,
146            SensitiveFieldPreset::Session,
147        ] {
148            builder = builder.include_preset(preset);
149        }
150        for &(field, level) in STANDARD_EXTRA_FIELDS {
151            builder = builder.raise(field, level);
152        }
153        builder.into_policy()
154    }
155
156    /// Creates an immutable policy from validated builder components.
157    ///
158    /// # Parameters
159    ///
160    /// * `sensitive` - Canonical sensitive fields and levels.
161    /// * `allow_exact` - Canonical exact-only allow rules.
162    /// * `allow_suffix` - Canonical suffix allow rules.
163    /// * `matching` - Sensitive-field matching breadth.
164    /// * `unknown_field_policy` - Fallback for fields with no matching rule.
165    /// * `masking` - Four-level value-masking policy.
166    /// * `diagnostic_budget` - Input and output limits for diagnostics.
167    ///
168    /// # Returns
169    ///
170    /// A cheap-clone immutable policy.
171    #[inline(always)]
172    pub(super) fn from_parts(
173        sensitive: BTreeMap<String, Sensitivity>,
174        allow_exact: BTreeSet<String>,
175        allow_suffix: BTreeSet<String>,
176        matching: FieldNameMatching,
177        unknown_field_policy: UnknownFieldPolicy,
178        masking: MaskingPolicy,
179        diagnostic_budget: DiagnosticBudget,
180    ) -> Self {
181        Self {
182            inner: Arc::new(RedactionPolicyInner {
183                sensitive,
184                allow_exact,
185                allow_suffix,
186                matching,
187                unknown_field_policy,
188                masking,
189            }),
190            diagnostic_budget,
191        }
192    }
193
194    /// Returns the hard limits for diagnostics rendered with this policy.
195    ///
196    /// # Returns
197    ///
198    /// The immutable diagnostic input and output budget.
199    #[must_use = "use the diagnostic budget to bound rendered diagnostics"]
200    #[inline(always)]
201    pub const fn diagnostic_budget(&self) -> DiagnosticBudget {
202        self.diagnostic_budget
203    }
204
205    /// Installs the process-wide default policy exactly once.
206    ///
207    /// The installed immutable policy affects later calls to [`Self::default`]
208    /// and [`RedactionPolicyBuilder::load_default`]. Previously created
209    /// snapshots remain unchanged.
210    ///
211    /// # Parameters
212    ///
213    /// * `policy` - Immutable policy to install as the process-wide default.
214    ///
215    /// # Returns
216    ///
217    /// `Ok(())` when this call installs the process-wide default.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`GlobalDefaultAlreadySet`] when a policy was installed by an
222    /// earlier successful call. The existing policy is never replaced.
223    #[inline]
224    pub fn set_global_default(
225        policy: Self,
226    ) -> Result<(), GlobalDefaultAlreadySet> {
227        GLOBAL_DEFAULT
228            .set(policy)
229            .map_err(|_| GlobalDefaultAlreadySet)
230    }
231
232    /// Classifies `field` and returns the configured rule that decided it.
233    ///
234    /// Candidates are examined from the complete canonical name to shorter
235    /// semantic token suffixes. An allow rule wins over a sensitive rule at
236    /// the same candidate, but exact allow rules apply only to the complete
237    /// input candidate.
238    ///
239    /// # Type Parameters
240    ///
241    /// * `'a` - Lifetime of rules borrowed from this policy in the result.
242    ///
243    /// # Parameters
244    ///
245    /// * `field` - Raw field name to classify.
246    ///
247    /// # Returns
248    ///
249    /// A borrowed sensitive or allow rule for the first matching candidate, or
250    /// [`FieldClassification::Unknown`] when no rule matches.
251    pub fn classify_field<'a>(
252        &'a self,
253        field: &str,
254    ) -> FieldClassification<'a> {
255        self.classify_field_with_matching(field, self.inner.matching)
256    }
257
258    /// Resolves the sensitivity configured for `field`.
259    ///
260    /// # Parameters
261    ///
262    /// * `field` - Raw field name to classify.
263    ///
264    /// # Returns
265    ///
266    /// `Some(level)` for a sensitive classification or configured unknown-field
267    /// fallback, or `None` when an allow rule wins or the fallback passes
268    /// unknown fields through.
269    #[must_use]
270    #[inline]
271    pub fn sensitivity_for(&self, field: &str) -> Option<Sensitivity> {
272        self.effective_sensitivity(self.classify_field(field))
273    }
274
275    /// Classifies a field using an explicit candidate-generation breadth.
276    ///
277    /// # Type Parameters
278    ///
279    /// * `'a` - Lifetime of rules borrowed from this policy in the result.
280    ///
281    /// # Parameters
282    ///
283    /// * `field` - Raw field name to classify.
284    /// * `matching` - Exact or semantic-suffix candidate generation.
285    ///
286    /// # Returns
287    ///
288    /// The first sensitive or allow rule in candidate order, otherwise
289    /// [`FieldClassification::Unknown`].
290    fn classify_field_with_matching<'a>(
291        &'a self,
292        field: &str,
293        matching: FieldNameMatching,
294    ) -> FieldClassification<'a> {
295        match visit_canonical_field_candidates(
296            field,
297            matching,
298            |is_exact, candidate| {
299                let match_kind = if is_exact {
300                    FieldMatchKind::Exact
301                } else {
302                    FieldMatchKind::TokenSuffix
303                };
304                if is_exact
305                    && let Some(field) = self.inner.allow_exact.get(candidate)
306                {
307                    return ControlFlow::Break(FieldClassification::Allowed {
308                        rule: AllowRule::new(field, FieldNameMatching::Exact),
309                        match_kind,
310                    });
311                }
312                if let Some(field) = self.inner.allow_suffix.get(candidate) {
313                    return ControlFlow::Break(FieldClassification::Allowed {
314                        rule: AllowRule::new(
315                            field,
316                            FieldNameMatching::ExactOrTokenSuffix,
317                        ),
318                        match_kind,
319                    });
320                }
321                if let Some((field, sensitivity)) =
322                    self.inner.sensitive.get_key_value(candidate)
323                {
324                    return ControlFlow::Break(
325                        FieldClassification::Sensitive {
326                            rule: SensitiveFieldRule::new(field, *sensitivity),
327                            match_kind,
328                        },
329                    );
330                }
331                ControlFlow::Continue(())
332            },
333        ) {
334            ControlFlow::Break(classification) => classification,
335            ControlFlow::Continue(()) => FieldClassification::Unknown,
336        }
337    }
338
339    /// Resolves sensitivity only for the complete canonical field name.
340    ///
341    /// This restricted lookup supports syntax adapters that must not interpret
342    /// compact values as semantic field-name suffixes.
343    ///
344    /// # Parameters
345    ///
346    /// * `field` - Raw field name to classify exactly.
347    ///
348    /// # Returns
349    ///
350    /// `Some(level)` for an exact sensitive rule or configured unknown-field
351    /// fallback, or `None` when an allow rule wins or the fallback passes
352    /// unknown fields through.
353    pub(crate) fn sensitivity_for_exact(
354        &self,
355        field: &str,
356    ) -> Option<Sensitivity> {
357        self.effective_sensitivity(
358            self.classify_field_with_matching(field, FieldNameMatching::Exact),
359        )
360    }
361
362    /// Returns the configured sensitive-field matching breadth.
363    ///
364    /// # Returns
365    ///
366    /// The matching mode used to generate lookup candidates.
367    #[inline(always)]
368    pub fn matching(&self) -> FieldNameMatching {
369        self.inner.matching
370    }
371
372    /// Returns fallback behavior for fields with no matching rule.
373    ///
374    /// # Returns
375    ///
376    /// The immutable unknown-field policy configured for this snapshot.
377    #[inline(always)]
378    pub fn unknown_field_policy(&self) -> UnknownFieldPolicy {
379        self.inner.unknown_field_policy
380    }
381
382    /// Returns the configured value-masking policy.
383    ///
384    /// # Returns
385    ///
386    /// The four-level immutable masking configuration.
387    #[inline(always)]
388    pub fn masking(&self) -> &MaskingPolicy {
389        &self.inner.masking
390    }
391
392    /// Iterates configured sensitive-field rules in canonical name order.
393    ///
394    /// # Returns
395    ///
396    /// Borrowed read-only views of all sensitive-field rules.
397    pub fn sensitive_rules(
398        &self,
399    ) -> impl Iterator<Item = SensitiveFieldRule<'_>> {
400        self.inner.sensitive.iter().map(|(field, sensitivity)| {
401            SensitiveFieldRule::new(field, *sensitivity)
402        })
403    }
404
405    /// Iterates exact allow rules followed by suffix allow rules.
406    ///
407    /// Each group is ordered by canonical field name.
408    ///
409    /// # Returns
410    ///
411    /// Borrowed read-only views of all allow rules.
412    pub fn allow_rules(&self) -> impl Iterator<Item = AllowRule<'_>> {
413        let exact = self
414            .inner
415            .allow_exact
416            .iter()
417            .map(|field| AllowRule::new(field, FieldNameMatching::Exact));
418        let suffix = self.inner.allow_suffix.iter().map(|field| {
419            AllowRule::new(field, FieldNameMatching::ExactOrTokenSuffix)
420        });
421        exact.chain(suffix)
422    }
423
424    /// Clones the canonical sensitive-field map for a new builder.
425    ///
426    /// # Returns
427    ///
428    /// An owned copy of all sensitive-field rules.
429    pub(super) fn clone_sensitive(&self) -> BTreeMap<String, Sensitivity> {
430        self.inner.sensitive.clone()
431    }
432
433    /// Clones the exact allow-rule set for a new builder.
434    ///
435    /// # Returns
436    ///
437    /// An owned copy of all exact allow rules.
438    pub(super) fn clone_allow_exact(&self) -> BTreeSet<String> {
439        self.inner.allow_exact.clone()
440    }
441
442    /// Clones the suffix allow-rule set for a new builder.
443    ///
444    /// # Returns
445    ///
446    /// An owned copy of all suffix allow rules.
447    pub(super) fn clone_allow_suffix(&self) -> BTreeSet<String> {
448        self.inner.allow_suffix.clone()
449    }
450
451    /// Applies fallback behavior after complete explicit-rule classification.
452    ///
453    /// # Parameters
454    ///
455    /// * classification - Result of the current candidate traversal.
456    ///
457    /// # Returns
458    ///
459    /// The matched sensitivity, no sensitivity for an explicit allow rule, or
460    /// the configured fallback for an unknown field.
461    #[inline(always)]
462    fn effective_sensitivity(
463        &self,
464        classification: FieldClassification<'_>,
465    ) -> Option<Sensitivity> {
466        match classification {
467            FieldClassification::Sensitive { rule, .. } => {
468                Some(rule.sensitivity())
469            }
470            FieldClassification::Allowed { .. } => None,
471            FieldClassification::Unknown => {
472                self.unknown_field_policy().sensitivity()
473            }
474        }
475    }
476}
477
478impl Default for RedactionPolicy {
479    /// Returns a snapshot of the current process-wide default policy.
480    ///
481    /// # Returns
482    ///
483    /// The installed global configuration, or [`RedactionPolicy::standard`]
484    /// before a custom default is installed.
485    #[inline(always)]
486    fn default() -> Self {
487        Self::global_default()
488    }
489}