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}