qubit_sanitize/core/mask_policy.rs
1/*******************************************************************************
2 *
3 * Copyright (c) 2026 Haixing Hu.
4 *
5 * SPDX-License-Identifier: Apache-2.0
6 *
7 * Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10use std::borrow::Cow;
11
12/// Strategy used to mask one sensitive field value.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum MaskPolicy {
15 /// Replaces non-empty values with a fixed replacement string.
16 Fixed {
17 /// Replacement used for non-empty values.
18 replacement: String,
19 },
20 /// Preserves a prefix and suffix for diagnosability.
21 PreserveEdges {
22 /// Number of leading Unicode scalar values to retain.
23 prefix_chars: usize,
24 /// Number of trailing Unicode scalar values to retain.
25 suffix_chars: usize,
26 /// Replacement inserted between retained edges.
27 replacement: String,
28 /// Values at or below this character length are fully masked.
29 full_mask_below_or_equal: usize,
30 },
31 /// Preserves only the final part of the value.
32 PreserveSuffix {
33 /// Number of trailing Unicode scalar values to retain.
34 suffix_chars: usize,
35 /// Replacement inserted before the retained suffix.
36 replacement: String,
37 /// Values at or below this character length are fully masked.
38 full_mask_below_or_equal: usize,
39 },
40 /// Removes non-empty values entirely.
41 Empty,
42}
43
44impl MaskPolicy {
45 /// Creates a fixed-replacement mask policy.
46 ///
47 /// # Parameters
48 ///
49 /// * `replacement` - Replacement used for non-empty values.
50 ///
51 /// # Returns
52 ///
53 /// A mask policy that replaces non-empty values with `replacement`.
54 pub fn fixed(replacement: &str) -> Self {
55 Self::Fixed {
56 replacement: replacement.to_string(),
57 }
58 }
59
60 /// Creates an edge-preserving mask policy.
61 ///
62 /// # Parameters
63 ///
64 /// * `prefix_chars` - Number of leading characters to retain.
65 /// * `suffix_chars` - Number of trailing characters to retain.
66 /// * `replacement` - Replacement inserted between retained edges.
67 /// * `full_mask_below_or_equal` - Character length threshold for full masks.
68 ///
69 /// # Returns
70 ///
71 /// A mask policy that keeps selected value edges for long values.
72 pub fn preserve_edges(
73 prefix_chars: usize,
74 suffix_chars: usize,
75 replacement: &str,
76 full_mask_below_or_equal: usize,
77 ) -> Self {
78 Self::PreserveEdges {
79 prefix_chars,
80 suffix_chars,
81 replacement: replacement.to_string(),
82 full_mask_below_or_equal,
83 }
84 }
85
86 /// Creates a suffix-preserving mask policy.
87 ///
88 /// # Parameters
89 ///
90 /// * `suffix_chars` - Number of trailing characters to retain.
91 /// * `replacement` - Replacement inserted before the suffix.
92 /// * `full_mask_below_or_equal` - Character length threshold for full masks.
93 ///
94 /// # Returns
95 ///
96 /// A mask policy that keeps only the selected trailing characters.
97 pub fn preserve_suffix(
98 suffix_chars: usize,
99 replacement: &str,
100 full_mask_below_or_equal: usize,
101 ) -> Self {
102 Self::PreserveSuffix {
103 suffix_chars,
104 replacement: replacement.to_string(),
105 full_mask_below_or_equal,
106 }
107 }
108
109 /// Creates a policy that removes non-empty values.
110 ///
111 /// # Returns
112 ///
113 /// A mask policy that returns an empty value for every non-empty input.
114 pub const fn empty() -> Self {
115 Self::Empty
116 }
117
118 /// Masks one value according to this policy.
119 ///
120 /// Empty values are preserved as empty because they do not leak sensitive
121 /// material and keeping them empty preserves field semantics.
122 ///
123 /// # Parameters
124 ///
125 /// * `value` - Field value to mask.
126 ///
127 /// # Returns
128 ///
129 /// Borrowed `value` when it is empty, otherwise an owned masked value.
130 pub fn mask<'a>(&self, value: &'a str) -> Cow<'a, str> {
131 if value.is_empty() {
132 return Cow::Borrowed(value);
133 }
134 match self {
135 Self::Fixed { replacement } => Cow::Owned(replacement.clone()),
136 Self::PreserveEdges {
137 prefix_chars,
138 suffix_chars,
139 replacement,
140 full_mask_below_or_equal,
141 } => mask_preserving_edges(
142 value,
143 *prefix_chars,
144 *suffix_chars,
145 replacement,
146 *full_mask_below_or_equal,
147 ),
148 Self::PreserveSuffix {
149 suffix_chars,
150 replacement,
151 full_mask_below_or_equal,
152 } => {
153 mask_preserving_suffix(value, *suffix_chars, replacement, *full_mask_below_or_equal)
154 }
155 Self::Empty => Cow::Owned(String::new()),
156 }
157 }
158}
159
160/// Masks a value while preserving a prefix and suffix.
161///
162/// # Parameters
163///
164/// * `value` - Field value to mask.
165/// * `prefix_chars` - Number of leading characters to retain.
166/// * `suffix_chars` - Number of trailing characters to retain.
167/// * `replacement` - Replacement inserted between retained edges.
168/// * `full_mask_below_or_equal` - Character length threshold for full masks.
169///
170/// # Returns
171///
172/// Owned masked value.
173fn mask_preserving_edges<'a>(
174 value: &str,
175 prefix_chars: usize,
176 suffix_chars: usize,
177 replacement: &str,
178 full_mask_below_or_equal: usize,
179) -> Cow<'a, str> {
180 let chars = value.chars().collect::<Vec<_>>();
181 if chars.len() <= full_mask_below_or_equal || chars.len() <= prefix_chars + suffix_chars {
182 return Cow::Owned(replacement.to_string());
183 }
184 let prefix = chars.iter().take(prefix_chars).collect::<String>();
185 let suffix = chars
186 .iter()
187 .skip(chars.len() - suffix_chars)
188 .collect::<String>();
189 Cow::Owned(format!("{prefix}{replacement}{suffix}"))
190}
191
192/// Masks a value while preserving only a suffix.
193///
194/// # Parameters
195///
196/// * `value` - Field value to mask.
197/// * `suffix_chars` - Number of trailing characters to retain.
198/// * `replacement` - Replacement inserted before the retained suffix.
199/// * `full_mask_below_or_equal` - Character length threshold for full masks.
200///
201/// # Returns
202///
203/// Owned masked value.
204fn mask_preserving_suffix<'a>(
205 value: &str,
206 suffix_chars: usize,
207 replacement: &str,
208 full_mask_below_or_equal: usize,
209) -> Cow<'a, str> {
210 let chars = value.chars().collect::<Vec<_>>();
211 if chars.len() <= full_mask_below_or_equal || chars.len() <= suffix_chars {
212 return Cow::Owned(replacement.to_string());
213 }
214 let suffix = chars
215 .iter()
216 .skip(chars.len() - suffix_chars)
217 .collect::<String>();
218 Cow::Owned(format!("{replacement}{suffix}"))
219}