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