Skip to main content

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