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