Skip to main content

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