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}