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}