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}