qubit-redact 0.3.0

Rule-driven redaction for fields, diagnostics, HTTP data, and Rust domain objects
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
// =============================================================================
//    Copyright (c) 2025 - 2026 Haixing Hu.
//
//    SPDX-License-Identifier: Apache-2.0
//
//    Licensed under the Apache License, Version 2.0.
// =============================================================================
//! Mutable builder for immutable redaction policies.

use std::collections::{
    BTreeMap,
    BTreeSet,
};

use super::{
    DiagnosticBudget,
    FieldNameMatching,
    MaskPolicy,
    MaskingPolicy,
    PolicyError,
    RedactionPolicy,
    SensitiveFieldPreset,
    Sensitivity,
    UnknownFieldPolicy,
    internal::canonicalize_field_name,
};

/// Mutable construction state for an immutable [`RedactionPolicy`].
#[must_use]
#[derive(Debug, Clone)]
pub struct RedactionPolicyBuilder {
    /// Canonical sensitive fields and their levels.
    sensitive: BTreeMap<String, Sensitivity>,
    /// Canonical exact-only allow rules.
    allow_exact: BTreeSet<String>,
    /// Canonical suffix allow rules.
    allow_suffix: BTreeSet<String>,
    /// Candidate-generation breadth for sensitive rules.
    matching: FieldNameMatching,
    /// Fallback behavior for fields with no matching rule.
    unknown_field_policy: UnknownFieldPolicy,
    /// Value masks selected by sensitivity level.
    masking: MaskingPolicy,
    /// Limits applied to diagnostics rendered with the built policy.
    diagnostic_budget: DiagnosticBudget,
    /// First validation error observed while canonicalizing rules.
    error: Option<PolicyError>,
}

impl RedactionPolicyBuilder {
    /// Creates a builder without sensitive or allow rules.
    ///
    /// # Returns
    ///
    /// Empty construction state with default matching, masks, and diagnostic
    /// limits.
    #[inline]
    pub fn new() -> Self {
        Self::empty()
    }

    /// Creates a builder with no field rules and default masks.
    ///
    /// # Returns
    ///
    /// Empty construction state using token-suffix matching.
    #[inline]
    pub(crate) fn empty() -> Self {
        Self {
            sensitive: BTreeMap::new(),
            allow_exact: BTreeSet::new(),
            allow_suffix: BTreeSet::new(),
            matching: FieldNameMatching::ExactOrTokenSuffix,
            unknown_field_policy: UnknownFieldPolicy::PassThrough,
            masking: MaskingPolicy::default(),
            diagnostic_budget: DiagnosticBudget::default(),
            error: None,
        }
    }

    /// Replaces this builder with the current default policy snapshot.
    ///
    /// # Returns
    ///
    /// A mutable copy of `RedactionPolicy::default`.
    ///
    /// # Warning
    ///
    /// This replaces every builder component, including prior rules, matching,
    /// masks, diagnostic budget, and recorded validation error. Call this
    /// method before adding application-specific configuration.
    #[inline]
    pub fn load_default(self) -> Self {
        Self::from_policy(&RedactionPolicy::default())
    }

    /// Copies complete construction state from an immutable policy.
    ///
    /// # Parameters
    ///
    /// * `policy` - Immutable base policy to copy.
    ///
    /// # Returns
    ///
    /// Mutable construction state initialized from `policy`.
    pub(super) fn from_policy(policy: &RedactionPolicy) -> Self {
        Self {
            sensitive: policy.clone_sensitive(),
            allow_exact: policy.clone_allow_exact(),
            allow_suffix: policy.clone_allow_suffix(),
            matching: policy.matching(),
            unknown_field_policy: policy.unknown_field_policy(),
            masking: policy.masking().clone(),
            diagnostic_budget: policy.diagnostic_budget(),
            error: None,
        }
    }

    /// Validates one field name using the builder's canonicalization rules.
    ///
    /// # Parameters
    ///
    /// * `field` - Raw field name to canonicalize and validate.
    ///
    /// # Returns
    ///
    /// `Ok(())` when the name remains non-empty after canonicalization.
    ///
    /// # Errors
    ///
    /// Returns [`PolicyError::EmptyFieldName`] when canonicalization removes
    /// every character.
    pub fn validate_field_name(field: &str) -> Result<(), PolicyError> {
        Self::checked_canonical_field(field).map(|_| ())
    }

    /// Sets the candidate-generation breadth for sensitive rules.
    ///
    /// # Parameters
    ///
    /// * `matching` - Matching mode used by the built policy.
    ///
    /// # Returns
    ///
    /// The updated builder.
    #[inline(always)]
    pub const fn matching(mut self, matching: FieldNameMatching) -> Self {
        self.matching = matching;
        self
    }

    /// Sets fallback behavior for fields with no matching rule.
    ///
    /// Explicit rules retain existing candidate-order semantics; this setting
    /// applies only after classification is unknown.
    ///
    /// # Parameters
    ///
    /// * policy - Fallback selected by the built policy.
    ///
    /// # Returns
    ///
    /// The updated builder.
    #[inline(always)]
    pub const fn unknown_field_policy(
        mut self,
        policy: UnknownFieldPolicy,
    ) -> Self {
        self.unknown_field_policy = policy;
        self
    }

    /// Adds every rule from one predefined field group.
    ///
    /// Existing rules retain the stronger sensitivity level.
    ///
    /// # Parameters
    ///
    /// * `preset` - Predefined field group to merge.
    ///
    /// # Returns
    ///
    /// The updated builder.
    pub fn include_preset(mut self, preset: SensitiveFieldPreset) -> Self {
        for &(field, level) in preset.fields() {
            self = self.raise(field, level);
        }
        self
    }

    /// Raises one field to at least the requested sensitivity.
    ///
    /// # Parameters
    ///
    /// * `field` - Field name to canonicalize and classify.
    /// * `requested` - Minimum sensitivity for the field.
    ///
    /// # Returns
    ///
    /// The updated builder, retaining any stronger existing level.
    pub fn raise(mut self, field: &str, requested: Sensitivity) -> Self {
        let Some(field) = self.canonical_field(field) else {
            return self;
        };
        self.sensitive
            .entry(field)
            .and_modify(|existing| *existing = (*existing).max(requested))
            .or_insert(requested);
        self
    }

    /// Replaces the exact rule for one field with the requested sensitivity.
    ///
    /// # Parameters
    ///
    /// * `field` - Field name to canonicalize and classify.
    /// * `level` - Replacement sensitivity level.
    ///
    /// # Returns
    ///
    /// The updated builder.
    pub fn override_level(mut self, field: &str, level: Sensitivity) -> Self {
        let Some(field) = self.canonical_field(field) else {
            return self;
        };
        self.sensitive.insert(field, level);
        self
    }

    /// Adds an allow rule that applies only to a complete field name.
    ///
    /// The allow rule may coexist with a sensitive rule and wins when both
    /// match the complete canonical candidate.
    ///
    /// # Parameters
    ///
    /// * `field` - Field name to canonicalize and allow exactly.
    ///
    /// # Returns
    ///
    /// The updated builder.
    pub fn allow_exact(mut self, field: &str) -> Self {
        let Some(field) = self.canonical_field(field) else {
            return self;
        };
        self.allow_exact.insert(field);
        self
    }

    /// Adds a broad allow rule that applies at token-suffix boundaries.
    ///
    /// # Parameters
    ///
    /// * `field` - Field name to canonicalize and allow as a suffix.
    ///
    /// # Returns
    ///
    /// The updated builder.
    pub fn allow_suffix(mut self, field: &str) -> Self {
        let Some(field) = self.canonical_field(field) else {
            return self;
        };
        self.allow_suffix.insert(field);
        self
    }

    /// Removes one exact allow rule.
    ///
    /// # Parameters
    ///
    /// * `field` - Field name to canonicalize and remove from exact rules.
    ///
    /// # Returns
    ///
    /// The updated builder. Removing an absent rule has no effect.
    pub fn remove_allow_exact(mut self, field: &str) -> Self {
        let Some(field) = self.canonical_field(field) else {
            return self;
        };
        self.allow_exact.remove(&field);
        self
    }

    /// Removes one token-suffix allow rule.
    ///
    /// # Parameters
    ///
    /// * `field` - Field name to canonicalize and remove from suffix rules.
    ///
    /// # Returns
    ///
    /// The updated builder. Removing an absent rule has no effect.
    pub fn remove_allow_suffix(mut self, field: &str) -> Self {
        let Some(field) = self.canonical_field(field) else {
            return self;
        };
        self.allow_suffix.remove(&field);
        self
    }

    /// Removes every exact and token-suffix allow rule.
    ///
    /// # Returns
    ///
    /// The updated builder without allow-rule exceptions.
    pub fn clear_allow_rules(mut self) -> Self {
        self.allow_exact.clear();
        self.allow_suffix.clear();
        self
    }

    /// Replaces the mask assigned to one sensitivity level.
    ///
    /// # Parameters
    ///
    /// * `level` - Sensitivity level whose mask is replaced.
    /// * `policy` - Replacement mask policy.
    ///
    /// # Returns
    ///
    /// The updated builder.
    #[inline]
    pub fn mask(mut self, level: Sensitivity, policy: MaskPolicy) -> Self {
        self.masking = self.masking.with_policy(level, policy);
        self
    }

    /// Replaces the hard limits for diagnostics rendered with this policy.
    ///
    /// # Parameters
    ///
    /// * `budget` - Replacement diagnostic input and output limits.
    ///
    /// # Returns
    ///
    /// The updated builder.
    #[inline(always)]
    pub const fn diagnostic_budget(mut self, budget: DiagnosticBudget) -> Self {
        self.diagnostic_budget = budget;
        self
    }

    /// Validates and builds an immutable redaction policy.
    ///
    /// # Returns
    ///
    /// `Ok(policy)` when all rules and masks are valid.
    ///
    /// # Errors
    ///
    /// Returns [`PolicyError::EmptyFieldName`] for a field name that
    /// canonicalizes to empty, or [`PolicyError::EmptyFixedReplacement`] when
    /// a fixed mask has an empty replacement.
    pub fn build(self) -> Result<RedactionPolicy, PolicyError> {
        if let Some(error) = self.error.clone() {
            return Err(error);
        }
        for level in [
            Sensitivity::Low,
            Sensitivity::Medium,
            Sensitivity::High,
            Sensitivity::Secret,
        ] {
            if matches!(
                self.masking.for_level(level),
                MaskPolicy::Fixed { replacement } if replacement.is_empty()
            ) {
                return Err(PolicyError::EmptyFixedReplacement { level });
            }
        }
        Ok(self.into_policy())
    }

    /// Converts builder state into an immutable policy without validation.
    ///
    /// This is used only after validation or for compile-time built-in rules.
    ///
    /// # Returns
    ///
    /// An immutable policy sharing the complete constructed state.
    pub(super) fn into_policy(self) -> RedactionPolicy {
        RedactionPolicy::from_parts(
            self.sensitive,
            self.allow_exact,
            self.allow_suffix,
            self.matching,
            self.unknown_field_policy,
            self.masking,
            self.diagnostic_budget,
        )
    }

    /// Canonicalizes a rule field and records an empty-name error.
    ///
    /// # Parameters
    ///
    /// * `field` - Raw field name to canonicalize.
    ///
    /// # Returns
    ///
    /// `Some(canonical)` for a non-empty name, otherwise `None` after storing
    /// the first [`PolicyError::EmptyFieldName`].
    fn canonical_field(&mut self, field: &str) -> Option<String> {
        match Self::checked_canonical_field(field) {
            Ok(canonical) => Some(canonical),
            Err(error) => {
                self.error.get_or_insert(error);
                None
            }
        }
    }

    /// Canonicalizes one field name and rejects an empty result.
    ///
    /// # Parameters
    ///
    /// * `field` - Raw field name to canonicalize.
    ///
    /// # Returns
    ///
    /// The canonical field name when it is non-empty.
    ///
    /// # Errors
    ///
    /// Returns [`PolicyError::EmptyFieldName`] when canonicalization removes
    /// every character.
    fn checked_canonical_field(field: &str) -> Result<String, PolicyError> {
        let canonical = canonicalize_field_name(field);
        if canonical.is_empty() {
            Err(PolicyError::EmptyFieldName)
        } else {
            Ok(canonical.into_owned())
        }
    }
}

impl Default for RedactionPolicyBuilder {
    /// Creates the same empty construction state as [`Self::new`].
    ///
    /// # Returns
    ///
    /// A builder with no sensitive or allow rules.
    #[inline]
    fn default() -> Self {
        Self::new()
    }
}