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}