Skip to main content

qubit_redact/
redactor.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//! Stateless redaction operations backed by an immutable policy.
9
10use std::borrow::Cow;
11
12use crate::{
13    FieldClassification,
14    FieldRedaction,
15    PassThroughReason,
16    RedactMapValueMut,
17    RedactedKeyedValue,
18    RedactedText,
19    RedactionPolicy,
20    RedactionSession,
21    Sensitivity,
22    policy::{
23        OutputCharge,
24        ResolvedField,
25    },
26};
27
28/// Applies one immutable policy to scalar values and string maps.
29#[must_use]
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Redactor {
32    /// Field classification and masking configuration.
33    policy: RedactionPolicy,
34}
35
36impl Redactor {
37    /// Creates a redactor using `policy`.
38    ///
39    /// # Parameters
40    ///
41    /// * `policy` - Immutable field classification and masking configuration.
42    ///
43    /// # Returns
44    ///
45    /// A redactor that owns the supplied policy snapshot.
46    #[inline(always)]
47    pub const fn new(policy: RedactionPolicy) -> Self {
48        Self { policy }
49    }
50
51    /// Creates a redactor with the strict policy for untrusted scalar data.
52    ///
53    /// Unknown fields are masked at [`Sensitivity::Secret`].
54    #[inline]
55    pub fn strict() -> Self {
56        Self::new(RedactionPolicy::strict())
57    }
58
59    /// Returns the immutable policy used by this redactor.
60    ///
61    /// # Returns
62    ///
63    /// A borrowed view of the redactor's policy snapshot.
64    #[must_use = "use the policy snapshot backing this redactor"]
65    #[inline(always)]
66    pub const fn policy(&self) -> &RedactionPolicy {
67        &self.policy
68    }
69
70    /// Redacts one value according to its field name.
71    ///
72    /// Unknown and explicitly allowed fields retain a borrow of `value`.
73    /// Sensitive fields return the value produced by the configured mask.
74    /// This method classifies only `field`; it never scans `value` for secret
75    /// syntax. Do not pass an arbitrary error message or complete diagnostic
76    /// under a generic field name and expect embedded credentials to be found.
77    /// Use structured fields, [`Self::redact_at`] for an opaque value whose
78    /// sensitivity is already known, or a fixed safe public summary with the
79    /// original error retained only as an error source.
80    ///
81    /// # Type Parameters
82    ///
83    /// * `'a` - Lifetime of the input and any borrowed redacted result.
84    ///
85    /// # Parameters
86    ///
87    /// * `field` - Raw field name to classify.
88    /// * `value` - Field value to redact when classified as sensitive.
89    ///
90    /// # Returns
91    ///
92    /// A typed result that distinguishes masked values from pass-through
93    /// values while borrowing safe input where possible.
94    #[must_use = "use the returned redacted value"]
95    #[inline]
96    pub fn redact_field<'a>(
97        &self,
98        field: &str,
99        value: &'a str,
100    ) -> FieldRedaction<'a> {
101        let session = RedactionSession::operation(&self.policy);
102        self.redact_field_with_session(&session, field, value)
103    }
104
105    /// Redacts one field while consuming the supplied operation session.
106    ///
107    /// This is the composition entry point for diagnostics that contain more
108    /// than one field-producing adapter. Input accounting happens before the
109    /// value is inspected, and generated masks are charged to the same
110    /// session.
111    #[must_use = "use the returned redacted value"]
112    pub fn redact_field_with_session<'a>(
113        &self,
114        session: &RedactionSession<'_>,
115        field: &str,
116        value: &'a str,
117    ) -> FieldRedaction<'a> {
118        if !session.consume_input(field.len().saturating_add(value.len())) {
119            return self.fallback_field(session);
120        }
121        let resolved = self.policy.resolve_field(field);
122        match resolved {
123            ResolvedField::Sensitive { sensitivity } => {
124                let max_bytes = session.remaining_output_bytes();
125                let masked = self.policy.masking().mask_bounded(
126                    sensitivity,
127                    value,
128                    max_bytes,
129                );
130                let mask_len = masked.len();
131                let fallback = self.opaque_mask();
132                match session
133                    .charge_output_or_fallback(mask_len, fallback.len())
134                {
135                    OutputCharge::Complete => FieldRedaction::Masked {
136                        value: RedactedText::new(masked),
137                        sensitivity,
138                    },
139                    OutputCharge::Fallback => FieldRedaction::Masked {
140                        value: RedactedText::new(Cow::Owned(
141                            fallback.to_owned(),
142                        )),
143                        sensitivity: Sensitivity::Secret,
144                    },
145                    OutputCharge::Exhausted => FieldRedaction::Masked {
146                        value: RedactedText::new(Cow::Owned(String::new())),
147                        sensitivity: Sensitivity::Secret,
148                    },
149                }
150            }
151            ResolvedField::PassThrough => {
152                let reason = match self.policy.classify_field(field) {
153                    FieldClassification::Allowed { .. } => {
154                        PassThroughReason::Allowed
155                    }
156                    FieldClassification::Sensitive { .. }
157                    | FieldClassification::Unknown => {
158                        PassThroughReason::Unknown
159                    }
160                };
161                match session.charge_output_or_fallback(
162                    value.len(),
163                    self.opaque_mask().len(),
164                ) {
165                    OutputCharge::Complete => {
166                        FieldRedaction::PassedThrough { value, reason }
167                    }
168                    OutputCharge::Fallback => FieldRedaction::Masked {
169                        value: RedactedText::new(Cow::Owned(
170                            self.opaque_mask().to_owned(),
171                        )),
172                        sensitivity: Sensitivity::Secret,
173                    },
174                    OutputCharge::Exhausted => FieldRedaction::Masked {
175                        value: RedactedText::new(Cow::Owned(String::new())),
176                        sensitivity: Sensitivity::Secret,
177                    },
178                }
179            }
180        }
181    }
182
183    /// Redacts one value at an explicit sensitivity level.
184    ///
185    /// This ignores field classification and allow rules. Use it at a boundary
186    /// where the value is known to be sensitive regardless of its field name.
187    ///
188    /// # Type Parameters
189    ///
190    /// * `'a` - Lifetime of the input and any borrowed redacted result.
191    ///
192    /// # Parameters
193    ///
194    /// * `level` - Sensitivity required by the calling boundary.
195    /// * `value` - Value to mask.
196    ///
197    /// # Returns
198    ///
199    /// Typed redacted text produced by the configured mask for `level`.
200    #[must_use = "use the returned redacted value"]
201    #[inline]
202    pub fn redact_at<'a>(
203        &self,
204        level: Sensitivity,
205        value: &'a str,
206    ) -> RedactedText<'a> {
207        let session = RedactionSession::operation(&self.policy);
208        self.redact_at_with_session(&session, level, value)
209    }
210
211    /// Redacts an explicitly sensitive value through an existing session.
212    #[must_use = "use the returned redacted value"]
213    pub fn redact_at_with_session<'a>(
214        &self,
215        session: &RedactionSession<'_>,
216        level: Sensitivity,
217        value: &'a str,
218    ) -> RedactedText<'a> {
219        if !session.consume_input(value.len()) {
220            return self.fallback_text(session);
221        }
222        let masked = self.policy.masking().mask_bounded(
223            level,
224            value,
225            session.remaining_output_bytes(),
226        );
227        let length = masked.len();
228        let fallback = self.opaque_mask();
229        match session.charge_output_or_fallback(length, fallback.len()) {
230            OutputCharge::Complete => RedactedText::new(masked),
231            OutputCharge::Fallback => {
232                RedactedText::new(Cow::Owned(fallback.to_owned()))
233            }
234            OutputCharge::Exhausted => {
235                RedactedText::new(Cow::Owned(String::new()))
236            }
237        }
238    }
239
240    /// Returns the policy's opaque Secret mask.
241    #[inline(always)]
242    fn opaque_mask(&self) -> &str {
243        self.policy.masking().mask_opaque(Sensitivity::Secret)
244    }
245
246    /// Charges one fail-closed scalar fallback through the shared session.
247    fn fallback_text<'a>(
248        &self,
249        session: &RedactionSession<'_>,
250    ) -> RedactedText<'a> {
251        let fallback = self.opaque_mask();
252        match session.charge_output_or_fallback(fallback.len(), fallback.len())
253        {
254            OutputCharge::Complete => {
255                RedactedText::new(Cow::Owned(fallback.to_owned()))
256            }
257            OutputCharge::Fallback | OutputCharge::Exhausted => {
258                RedactedText::new(Cow::Owned(String::new()))
259            }
260        }
261    }
262
263    /// Wraps a charged fail-closed scalar fallback as a field result.
264    fn fallback_field<'a>(
265        &self,
266        session: &RedactionSession<'_>,
267    ) -> FieldRedaction<'a> {
268        FieldRedaction::Masked {
269            value: self.fallback_text(session),
270            sensitivity: Sensitivity::Secret,
271        }
272    }
273
274    /// Creates a lazy redacted view selected by an external key.
275    ///
276    /// The returned view borrows this redactor's policy snapshot. When its key
277    /// is sensitive, it masks the complete value through
278    /// [`RedactValue`](crate::RedactValue). Otherwise it delegates to the
279    /// value's recursive redaction contracts.
280    ///
281    /// # Type Parameters
282    ///
283    /// * `'value` - Lifetime of the borrowed key and value.
284    /// * `T` - Value type rendered or serialized through redaction.
285    ///
286    /// # Parameters
287    ///
288    /// * `key` - Field name used only for policy classification.
289    /// * `value` - Value to render or serialize through the selected policy.
290    ///
291    /// # Returns
292    ///
293    /// A lazy keyed redaction view borrowing `key` and `value`.
294    #[must_use = "format or serialize the returned keyed redaction view"]
295    #[inline(always)]
296    pub fn redact_keyed<'value, T: ?Sized>(
297        &self,
298        key: &'value str,
299        value: &'value T,
300    ) -> RedactedKeyedValue<'value, '_, T> {
301        RedactedKeyedValue::new(key, value, &self.policy)
302    }
303
304    /// Creates a redacted copy of a text-keyed, mutable text-valued map.
305    ///
306    /// The source map is never modified. Its concrete collection type is
307    /// preserved by cloning the collection before applying in-place redaction.
308    ///
309    /// # Type Parameters
310    ///
311    /// * `M` - Cloneable map-like collection returned after redaction.
312    /// * `K` - Runtime key type used for field classification.
313    /// * `V` - Mutable map-value type redacted in the cloned collection.
314    ///
315    /// # Parameters
316    ///
317    /// * `map` - Map whose values are classified by their corresponding keys.
318    ///
319    /// # Returns
320    ///
321    /// A map of the same type containing redacted values.
322    #[must_use = "use the returned redacted map"]
323    pub fn redact_map<M, K: ?Sized, V: ?Sized>(&self, map: &M) -> M
324    where
325        M: Clone + RedactMapValueMut<K, V>,
326    {
327        let mut redacted = map.clone();
328        RedactMapValueMut::redact_map_in_place(&mut redacted, &self.policy);
329        redacted
330    }
331
332    /// Redacts sensitive values of a text-keyed map in place.
333    ///
334    /// # Type Parameters
335    ///
336    /// * `M` - Mutable map-like collection type.
337    /// * `K` - Runtime key type used for field classification.
338    /// * `V` - Mutable map-value type redacted in place.
339    ///
340    /// # Parameters
341    ///
342    /// * `map` - Mutable map whose values are classified by their keys.
343    #[inline(always)]
344    pub fn redact_map_in_place<M, K: ?Sized, V: ?Sized>(&self, map: &mut M)
345    where
346        M: RedactMapValueMut<K, V> + ?Sized,
347    {
348        RedactMapValueMut::redact_map_in_place(map, &self.policy);
349    }
350}
351
352impl Default for Redactor {
353    /// Creates a redactor from the current global redaction configuration.
354    ///
355    /// # Returns
356    ///
357    /// A redactor that is unaffected by later policy configuration attempts.
358    #[inline(always)]
359    fn default() -> Self {
360        Self::new(RedactionPolicy::default())
361    }
362}