Skip to main content

qubit_redact/policy/masking/
mask_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//! Algorithms for masking one sensitive value.
9
10use std::borrow::Cow;
11use std::fmt;
12use std::fmt::Write;
13
14use crate::policy::internal::BoundedMaskWriter;
15
16/// Strategy used to mask one sensitive field value.
17///
18/// # Examples
19///
20/// ```
21/// use qubit_redact::MaskPolicy;
22///
23/// let mask = MaskPolicy::preserve_suffix(2, "***", 2);
24/// assert_eq!(mask.mask("123456"), "***56");
25/// assert_eq!(mask.mask("12"), "***");
26/// ```
27#[non_exhaustive]
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum MaskPolicy {
30    /// Replaces non-empty values with a fixed replacement string.
31    #[non_exhaustive]
32    Fixed {
33        /// Replacement used for non-empty values.
34        replacement: String,
35    },
36    /// Preserves a prefix and suffix for diagnosability.
37    #[non_exhaustive]
38    PreserveEdges {
39        /// Number of leading Unicode scalar values to retain.
40        prefix_chars: usize,
41        /// Number of trailing Unicode scalar values to retain.
42        suffix_chars: usize,
43        /// Replacement inserted between retained edges.
44        replacement: String,
45        /// Values at or below this character length are fully masked.
46        full_mask_below_or_equal: usize,
47    },
48    /// Preserves only the final part of the value.
49    #[non_exhaustive]
50    PreserveSuffix {
51        /// Number of trailing Unicode scalar values to retain.
52        suffix_chars: usize,
53        /// Replacement inserted before the retained suffix.
54        replacement: String,
55        /// Values at or below this character length are fully masked.
56        full_mask_below_or_equal: usize,
57    },
58    /// Removes non-empty values entirely.
59    Empty,
60}
61
62impl MaskPolicy {
63    /// Creates a fixed-replacement policy.
64    ///
65    /// # Parameters
66    ///
67    /// * `replacement` - Text returned for every non-empty value.
68    ///
69    /// # Returns
70    ///
71    /// A fixed-replacement mask policy.
72    #[inline]
73    #[must_use]
74    pub fn fixed(replacement: &str) -> Self {
75        Self::Fixed {
76            replacement: replacement.to_string(),
77        }
78    }
79
80    /// Creates a policy that retains `prefix_chars` and `suffix_chars` scalars.
81    ///
82    /// Values no longer than `full_mask_below_or_equal`, or too short to keep
83    /// both requested edges without overlap, are replaced completely.
84    ///
85    /// # Parameters
86    ///
87    /// * `prefix_chars` - Number of leading Unicode scalars to retain.
88    /// * `suffix_chars` - Number of trailing Unicode scalars to retain.
89    /// * `replacement` - Text inserted between retained edges.
90    /// * `full_mask_below_or_equal` - Scalar-count threshold for full masking.
91    ///
92    /// # Returns
93    ///
94    /// An edge-preserving mask policy.
95    #[inline]
96    #[must_use]
97    pub fn preserve_edges(
98        prefix_chars: usize,
99        suffix_chars: usize,
100        replacement: &str,
101        full_mask_below_or_equal: usize,
102    ) -> Self {
103        Self::PreserveEdges {
104            prefix_chars,
105            suffix_chars,
106            replacement: replacement.to_string(),
107            full_mask_below_or_equal,
108        }
109    }
110
111    /// Creates a policy that retains `suffix_chars` trailing Unicode scalars.
112    ///
113    /// Values no longer than `full_mask_below_or_equal`, or no longer than the
114    /// requested suffix, are replaced completely.
115    ///
116    /// # Parameters
117    ///
118    /// * `suffix_chars` - Number of trailing Unicode scalars to retain.
119    /// * `replacement` - Text inserted before the retained suffix.
120    /// * `full_mask_below_or_equal` - Scalar-count threshold for full masking.
121    ///
122    /// # Returns
123    ///
124    /// A suffix-preserving mask policy.
125    #[inline]
126    #[must_use]
127    pub fn preserve_suffix(suffix_chars: usize, replacement: &str, full_mask_below_or_equal: usize) -> Self {
128        Self::PreserveSuffix {
129            suffix_chars,
130            replacement: replacement.to_string(),
131            full_mask_below_or_equal,
132        }
133    }
134
135    /// Creates a policy that removes every non-empty value.
136    ///
137    /// # Returns
138    ///
139    /// A mask policy that produces an empty result.
140    #[must_use]
141    #[inline(always)]
142    pub const fn empty() -> Self {
143        Self::Empty
144    }
145
146    /// Returns the complete replacement for a value whose contents are opaque.
147    ///
148    /// Edge-preserving policies cannot safely retain any part of an opaque
149    /// value, so this method returns only their configured replacement.
150    ///
151    /// # Returns
152    ///
153    /// The complete configured replacement, or an empty string for
154    /// [`Self::Empty`].
155    #[must_use]
156    #[inline(always)]
157    pub fn opaque_mask(&self) -> &str {
158        match self {
159            Self::Fixed { replacement }
160            | Self::PreserveEdges { replacement, .. }
161            | Self::PreserveSuffix { replacement, .. } => replacement,
162            Self::Empty => "",
163        }
164    }
165
166    /// Masks `value` according to this policy.
167    ///
168    /// Empty values remain borrowed and empty. Non-empty values return an
169    /// owned mask, with edge counts measured in Unicode scalar values.
170    ///
171    /// # Type Parameters
172    ///
173    /// * `'a` - Lifetime of the input and any borrowed result.
174    ///
175    /// # Parameters
176    ///
177    /// * `value` - Value to mask.
178    ///
179    /// # Returns
180    ///
181    /// The borrowed empty input or an owned masked value.
182    #[must_use]
183    pub fn mask<'a>(&self, value: &'a str) -> Cow<'a, str> {
184        if value.is_empty() {
185            return Cow::Borrowed(value);
186        }
187        match self {
188            Self::Fixed { replacement } => Cow::Owned(replacement.clone()),
189            Self::PreserveEdges {
190                prefix_chars,
191                suffix_chars,
192                replacement,
193                full_mask_below_or_equal,
194            } => Cow::Owned(mask_preserving_edges(
195                value,
196                *prefix_chars,
197                *suffix_chars,
198                replacement,
199                *full_mask_below_or_equal,
200            )),
201            Self::PreserveSuffix {
202                suffix_chars,
203                replacement,
204                full_mask_below_or_equal,
205            } => Cow::Owned(mask_preserving_suffix(
206                value,
207                *suffix_chars,
208                replacement,
209                *full_mask_below_or_equal,
210            )),
211            Self::Empty => Cow::Owned(String::new()),
212        }
213    }
214
215    /// Masks a value without allocating beyond a caller-supplied byte limit.
216    ///
217    /// # Type Parameters
218    ///
219    /// * `'a` - Lifetime of the input and any borrowed result.
220    ///
221    /// # Parameters
222    ///
223    /// * `value` - Value to mask.
224    /// * `max_bytes` - Maximum bytes retained from the masked representation.
225    ///
226    /// # Returns
227    ///
228    /// Empty input remains borrowed; other results own at most `max_bytes`.
229    #[cfg(feature = "http")]
230    #[must_use]
231    #[inline(always)]
232    pub(crate) fn mask_bounded<'a>(&self, value: &'a str, max_bytes: usize) -> Cow<'a, str> {
233        self.mask_bounded_with_truncation(value, max_bytes).0
234    }
235
236    /// Masks a value and reports whether the byte ceiling cut the mask.
237    #[must_use]
238    pub(crate) fn mask_bounded_with_truncation<'a>(&self, value: &'a str, max_bytes: usize) -> (Cow<'a, str>, bool) {
239        if value.is_empty() {
240            return (Cow::Borrowed(value), false);
241        }
242        let mut writer = BoundedMaskWriter::new(max_bytes);
243        let _ = self.write_masked(value, &mut writer);
244        let (output, truncated) = writer.finish();
245        (Cow::Owned(output), truncated)
246    }
247
248    /// Returns an opaque replacement without exceeding a byte limit.
249    ///
250    /// # Parameters
251    ///
252    /// * `max_bytes` - Maximum bytes retained from the replacement.
253    ///
254    /// # Returns
255    ///
256    /// An owned UTF-8 prefix of the configured opaque replacement.
257    #[must_use]
258    pub(crate) fn opaque_mask_bounded(&self, max_bytes: usize) -> String {
259        let mut writer = BoundedMaskWriter::new(max_bytes);
260        let _ = writer.write_str(self.opaque_mask());
261        writer.finish().0
262    }
263
264    /// Writes a masked value directly without cloning fixed replacements.
265    ///
266    /// # Type Parameters
267    ///
268    /// * `W` - Formatting destination receiving the masked value.
269    ///
270    /// # Parameters
271    ///
272    /// * `value` - Value to mask; unlike [`Self::mask`], empty input is also
273    ///   replaced according to this policy.
274    /// * `writer` - Formatting destination that may stop accepting output.
275    ///
276    /// # Returns
277    ///
278    /// `Ok(())` after writing the complete configured mask.
279    ///
280    /// # Errors
281    ///
282    /// Returns the destination formatting error unchanged.
283    pub(crate) fn write_masked<W: fmt::Write>(&self, value: &str, writer: &mut W) -> fmt::Result {
284        match self {
285            Self::Fixed { replacement } => writer.write_str(replacement),
286            Self::PreserveEdges {
287                prefix_chars,
288                suffix_chars,
289                replacement,
290                full_mask_below_or_equal,
291            } => {
292                let Some((prefix_end, suffix_start)) =
293                    preserved_edge_bounds(value, *prefix_chars, *suffix_chars, *full_mask_below_or_equal)
294                else {
295                    return writer.write_str(replacement);
296                };
297                writer.write_str(&value[..prefix_end])?;
298                writer.write_str(replacement)?;
299                writer.write_str(&value[suffix_start..])
300            }
301            Self::PreserveSuffix {
302                suffix_chars,
303                replacement,
304                full_mask_below_or_equal,
305            } => {
306                let Some(suffix_start) = preserved_suffix_start(value, *suffix_chars, *full_mask_below_or_equal) else {
307                    return writer.write_str(replacement);
308                };
309                writer.write_str(replacement)?;
310                writer.write_str(&value[suffix_start..])
311            }
312            Self::Empty => Ok(()),
313        }
314    }
315}
316
317/// Masks `value` while preserving requested Unicode scalar edges.
318///
319/// # Parameters
320///
321/// * `value` - Non-empty value to mask.
322/// * `prefix_chars` - Number of leading scalars to retain.
323/// * `suffix_chars` - Number of trailing scalars to retain.
324/// * `replacement` - Text inserted between retained edges.
325/// * `full_mask_below_or_equal` - Scalar-count threshold for full masking.
326///
327/// # Returns
328///
329/// An owned masked value.
330#[must_use]
331fn mask_preserving_edges(
332    value: &str,
333    prefix_chars: usize,
334    suffix_chars: usize,
335    replacement: &str,
336    full_mask_below_or_equal: usize,
337) -> String {
338    let Some((prefix_end, suffix_start)) =
339        preserved_edge_bounds(value, prefix_chars, suffix_chars, full_mask_below_or_equal)
340    else {
341        return replacement.to_string();
342    };
343    let mut masked = String::with_capacity(prefix_end + replacement.len() + value.len() - suffix_start);
344    masked.push_str(&value[..prefix_end]);
345    masked.push_str(replacement);
346    masked.push_str(&value[suffix_start..]);
347    masked
348}
349
350/// Masks `value` while preserving requested trailing Unicode scalar values.
351///
352/// # Parameters
353///
354/// * `value` - Non-empty value to mask.
355/// * `suffix_chars` - Number of trailing scalars to retain.
356/// * `replacement` - Text inserted before the retained suffix.
357/// * `full_mask_below_or_equal` - Scalar-count threshold for full masking.
358///
359/// # Returns
360///
361/// An owned masked value.
362#[must_use]
363fn mask_preserving_suffix(
364    value: &str,
365    suffix_chars: usize,
366    replacement: &str,
367    full_mask_below_or_equal: usize,
368) -> String {
369    let Some(suffix_start) = preserved_suffix_start(value, suffix_chars, full_mask_below_or_equal) else {
370        return replacement.to_string();
371    };
372    let mut masked = String::with_capacity(replacement.len() + value.len() - suffix_start);
373    masked.push_str(replacement);
374    masked.push_str(&value[suffix_start..]);
375    masked
376}
377
378/// Finds byte boundaries for preserving a prefix and suffix without counting
379/// every scalar in a long value.
380///
381/// # Parameters
382///
383/// * `value` - UTF-8 text whose preserved edges are measured.
384/// * `prefix_chars` - Number of leading scalar values to preserve.
385/// * `suffix_chars` - Number of trailing scalar values to preserve.
386/// * `full_mask_below_or_equal` - Length threshold requiring a complete mask.
387///
388/// # Returns
389///
390/// `Some((prefix_end, suffix_start))` when the value exceeds both full-mask
391/// limits, or `None` when it must be masked completely.
392fn preserved_edge_bounds(
393    value: &str,
394    prefix_chars: usize,
395    suffix_chars: usize,
396    full_mask_below_or_equal: usize,
397) -> Option<(usize, usize)> {
398    let edge_chars = prefix_chars.checked_add(suffix_chars)?;
399    let required_chars = full_mask_below_or_equal.max(edge_chars);
400    value.chars().nth(required_chars)?;
401    let prefix_end = value.char_indices().nth(prefix_chars)?.0;
402    let suffix_start = suffix_start(value, suffix_chars)?;
403    Some((prefix_end, suffix_start))
404}
405
406/// Finds the byte boundary for preserving a suffix without counting every
407/// scalar in a long value.
408///
409/// # Parameters
410///
411/// * `value` - UTF-8 text whose preserved suffix is measured.
412/// * `suffix_chars` - Number of trailing scalar values to preserve.
413/// * `full_mask_below_or_equal` - Length threshold requiring a complete mask.
414///
415/// # Returns
416///
417/// `Some(suffix_start)` when the value exceeds both full-mask limits, or
418/// `None` when it must be masked completely.
419fn preserved_suffix_start(value: &str, suffix_chars: usize, full_mask_below_or_equal: usize) -> Option<usize> {
420    let required_chars = full_mask_below_or_equal.max(suffix_chars);
421    value.chars().nth(required_chars)?;
422    suffix_start(value, suffix_chars)
423}
424
425/// Finds the byte boundary before the final requested number of scalars.
426///
427/// # Parameters
428///
429/// * `value` - UTF-8 text whose suffix boundary is located.
430/// * `suffix_chars` - Number of trailing scalar values in the suffix.
431///
432/// # Returns
433///
434/// `Some(index)` at a UTF-8 character boundary, or `None` when the value is
435/// shorter than the requested suffix.
436fn suffix_start(value: &str, suffix_chars: usize) -> Option<usize> {
437    if suffix_chars == 0 {
438        return Some(value.len());
439    }
440    value.char_indices().rev().nth(suffix_chars - 1).map(|(index, _)| index)
441}