Skip to main content

qubit_redact/policy/field/
redaction_rules.rs

1// =============================================================================
2//    Copyright (c) 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 rules and their resolved floor protection.
9
10use std::ops::ControlFlow;
11use std::sync::Arc;
12
13use super::AllowRule;
14use super::FieldClassification;
15use super::FieldMatchKind;
16use super::FieldNameMatching;
17use super::RedactionFloor;
18use super::SensitiveFieldRule;
19use super::Sensitivity;
20use super::UnknownFieldPolicy;
21use crate::policy::ResolvedField;
22use crate::policy::internal::RedactionPolicyInner;
23use crate::policy::internal::visit_canonical_field_candidates;
24
25/// Immutable, cheap-to-clone field classification snapshot.
26///
27/// # Examples
28///
29/// ```
30/// use qubit_redact::RedactionPolicy;
31/// use qubit_redact::Sensitivity;
32///
33/// let policy = RedactionPolicy::standard();
34/// let rules = policy.rules();
35/// assert_eq!(rules.sensitivity_for("password"), Some(Sensitivity::Secret));
36/// ```
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct RedactionRules {
39    /// Immutable application-owned classification rules.
40    application: Arc<RedactionPolicyInner>,
41    /// Optional minimum protection evaluated after application rules.
42    floor: Option<RedactionFloor>,
43}
44
45impl RedactionRules {
46    /// Creates immutable rules from application rules and an optional floor.
47    #[must_use]
48    #[inline(always)]
49    pub(crate) fn new(application: RedactionPolicyInner, floor: Option<RedactionFloor>) -> Self {
50        Self {
51            application: Arc::new(application),
52            floor,
53        }
54    }
55
56    /// Returns the attached minimum floor, if enabled.
57    #[must_use]
58    #[inline(always)]
59    pub fn floor(&self) -> Option<&RedactionFloor> {
60        self.floor.as_ref()
61    }
62
63    /// Resolves final sensitivity from application and floor layers.
64    #[must_use]
65    #[inline(always)]
66    pub fn sensitivity_for(&self, field: &str) -> Option<Sensitivity> {
67        match self.resolve_field(field) {
68            ResolvedField::Sensitive { sensitivity } => Some(sensitivity),
69            ResolvedField::PassThrough => None,
70        }
71    }
72
73    /// Returns the application layer's field-name matching mode.
74    #[must_use]
75    #[inline(always)]
76    pub fn matching(&self) -> FieldNameMatching {
77        self.application.matching
78    }
79
80    /// Returns the application fallback for unclassified fields.
81    #[must_use]
82    #[inline(always)]
83    pub fn unknown_field_policy(&self) -> UnknownFieldPolicy {
84        self.application.unknown_field_policy
85    }
86
87    /// Iterates only application sensitive rules, never floor rules.
88    pub fn application_sensitive_rules(&self) -> impl Iterator<Item = SensitiveFieldRule<'_>> {
89        self.application
90            .sensitive
91            .iter()
92            .map(|(field, level)| SensitiveFieldRule::new(field, *level))
93    }
94
95    /// Iterates only application allow rules, never floor rules.
96    pub fn application_allow_rules(&self) -> impl Iterator<Item = AllowRule<'_>> {
97        self.application
98            .allow_exact
99            .iter()
100            .map(|field| AllowRule::new(field, FieldNameMatching::Exact))
101            .chain(
102                self.application
103                    .allow_suffix
104                    .iter()
105                    .map(|field| AllowRule::new(field, FieldNameMatching::ExactOrTokenSuffix)),
106            )
107    }
108
109    /// Replaces the floor for this rules snapshot.
110    #[must_use]
111    #[inline(always)]
112    pub fn with_floor(mut self, floor: RedactionFloor) -> Self {
113        self.floor = Some(floor);
114        self
115    }
116
117    /// Adds a floor without weakening an existing floor.
118    #[must_use]
119    #[inline(always)]
120    pub fn add_floor(mut self, floor: RedactionFloor) -> Self {
121        self.floor = Some(match self.floor.take() {
122            Some(existing) => existing.combine(&floor),
123            None => floor,
124        });
125        self
126    }
127
128    /// Disables all floor protection for this rules snapshot.
129    ///
130    /// # Security
131    ///
132    /// This explicitly removes global and configured minimum protection. Use it
133    /// only when the caller intentionally accepts responsibility for doing so.
134    #[must_use]
135    #[inline(always)]
136    pub fn disable_floor(mut self) -> Self {
137        self.floor = None;
138        self
139    }
140
141    /// Explains application-rule matching only; it is not the final safety
142    /// decision.
143    #[must_use]
144    #[inline(always)]
145    pub fn classify_field<'a>(&'a self, field: &str) -> FieldClassification<'a> {
146        classify_inner(&self.application, field, self.application.matching, true)
147    }
148
149    /// Resolves final sensitivity using exact-only matching in both layers.
150    #[must_use]
151    #[inline(always)]
152    pub(crate) fn sensitivity_for_exact(&self, field: &str) -> Option<Sensitivity> {
153        match self.resolve_field_exact(field) {
154            ResolvedField::Sensitive { sensitivity } => Some(sensitivity),
155            ResolvedField::PassThrough => None,
156        }
157    }
158
159    /// Resolves exact-only sensitivity from application and floor rules.
160    pub(crate) fn resolve_field_exact(&self, field: &str) -> ResolvedField {
161        let application = sensitivity_inner(&self.application, field, FieldNameMatching::Exact, true);
162        let floor = self
163            .floor
164            .as_ref()
165            .and_then(|floor| sensitivity_inner(&floor.inner, field, FieldNameMatching::Exact, false));
166        match self.floor.as_ref().zip(floor) {
167            Some((_floor, floor_level)) => ResolvedField::Sensitive {
168                sensitivity: application.map_or(floor_level, |level| level.max(floor_level)),
169            },
170            None => match application {
171                Some(sensitivity) => ResolvedField::Sensitive { sensitivity },
172                None => ResolvedField::PassThrough,
173            },
174        }
175    }
176
177    /// Resolves final sensitivity for `field` exactly once.
178    #[inline(always)]
179    pub(crate) fn resolve_field(&self, field: &str) -> ResolvedField {
180        self.resolve_field_with_matching(field, self.application.matching)
181    }
182
183    /// Clones only the application-rule layer for builder reconstruction.
184    #[inline(always)]
185    pub(crate) fn clone_application(&self) -> RedactionPolicyInner {
186        (*self.application).clone()
187    }
188
189    /// Resolves final sensitivity using `matching` for application rules.
190    fn resolve_field_with_matching(&self, field: &str, matching: FieldNameMatching) -> ResolvedField {
191        let application = sensitivity_inner(&self.application, field, matching, true);
192        let floor = self
193            .floor
194            .as_ref()
195            .and_then(|floor| sensitivity_inner(&floor.inner, field, floor.inner.matching, false));
196        match floor {
197            Some(floor_level) => ResolvedField::Sensitive {
198                sensitivity: application.map_or(floor_level, |level| level.max(floor_level)),
199            },
200            None => match application {
201                Some(sensitivity) => ResolvedField::Sensitive { sensitivity },
202                None => ResolvedField::PassThrough,
203            },
204        }
205    }
206}
207
208/// Classifies one field against a single rule layer.
209#[must_use]
210fn classify_inner<'a>(
211    inner: &'a RedactionPolicyInner,
212    field: &str,
213    matching: FieldNameMatching,
214    allow: bool,
215) -> FieldClassification<'a> {
216    match visit_canonical_field_candidates(field, matching, |is_exact, candidate| {
217        let match_kind = if is_exact {
218            FieldMatchKind::Exact
219        } else {
220            FieldMatchKind::TokenSuffix
221        };
222        if allow
223            && is_exact
224            && let Some(field) = inner.allow_exact.get(candidate)
225        {
226            return ControlFlow::Break(FieldClassification::Allowed {
227                rule: AllowRule::new(field, FieldNameMatching::Exact),
228                match_kind,
229            });
230        }
231        if allow && let Some(field) = inner.allow_suffix.get(candidate) {
232            return ControlFlow::Break(FieldClassification::Allowed {
233                rule: AllowRule::new(field, FieldNameMatching::ExactOrTokenSuffix),
234                match_kind,
235            });
236        }
237        if let Some((field, sensitivity)) = inner.sensitive.get_key_value(candidate) {
238            return ControlFlow::Break(FieldClassification::Sensitive {
239                rule: SensitiveFieldRule::new(field, *sensitivity),
240                match_kind,
241            });
242        }
243        ControlFlow::Continue(())
244    }) {
245        ControlFlow::Break(classification) => classification,
246        ControlFlow::Continue(()) => FieldClassification::Unknown,
247    }
248}
249
250/// Resolves the strongest sensitivity applicable to one field name.
251#[must_use]
252fn sensitivity_inner(
253    inner: &RedactionPolicyInner,
254    field: &str,
255    matching: FieldNameMatching,
256    allow: bool,
257) -> Option<Sensitivity> {
258    match classify_inner(inner, field, matching, allow) {
259        FieldClassification::Allowed { .. } => None,
260        FieldClassification::Sensitive { .. } | FieldClassification::Unknown => {
261            strongest_sensitive_match(inner, field, matching).or_else(|| inner.unknown_field_policy.sensitivity())
262        }
263    }
264}
265
266/// Returns the strongest sensitivity among every matching field candidate.
267#[must_use]
268fn strongest_sensitive_match(
269    inner: &RedactionPolicyInner,
270    field: &str,
271    matching: FieldNameMatching,
272) -> Option<Sensitivity> {
273    let mut strongest: Option<Sensitivity> = None;
274    let _ = visit_canonical_field_candidates(field, matching, |_is_exact, candidate| {
275        if let Some(level) = inner.sensitive.get(candidate) {
276            strongest = Some(strongest.map_or(*level, |current| current.max(*level)));
277        }
278        ControlFlow::<()>::Continue(())
279    });
280    strongest
281}