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(suffix_chars: usize, replacement: &str, full_mask_below_or_equal: usize) -> Self {
98 Self::PreserveSuffix {
99 suffix_chars,
100 replacement: replacement.to_string(),
101 full_mask_below_or_equal,
102 }
103 }
104
105 /// Creates a policy that removes non-empty values.
106 ///
107 /// # Returns
108 ///
109 /// A mask policy that returns an empty value for every non-empty input.
110 pub const fn empty() -> Self {
111 Self::Empty
112 }
113
114 /// Masks one value according to this policy.
115 ///
116 /// Empty values are preserved as empty because they do not leak sensitive
117 /// material and keeping them empty preserves field semantics.
118 ///
119 /// # Parameters
120 ///
121 /// * `value` - Field value to mask.
122 ///
123 /// # Returns
124 ///
125 /// Borrowed `value` when it is empty, otherwise an owned masked value.
126 pub fn mask<'a>(&self, value: &'a str) -> Cow<'a, str> {
127 if value.is_empty() {
128 return Cow::Borrowed(value);
129 }
130 match self {
131 Self::Fixed { replacement } => Cow::Owned(replacement.clone()),
132 Self::PreserveEdges {
133 prefix_chars,
134 suffix_chars,
135 replacement,
136 full_mask_below_or_equal,
137 } => mask_preserving_edges(
138 value,
139 *prefix_chars,
140 *suffix_chars,
141 replacement,
142 *full_mask_below_or_equal,
143 ),
144 Self::PreserveSuffix {
145 suffix_chars,
146 replacement,
147 full_mask_below_or_equal,
148 } => mask_preserving_suffix(value, *suffix_chars, replacement, *full_mask_below_or_equal),
149 Self::Empty => Cow::Owned(String::new()),
150 }
151 }
152}
153
154/// Masks a value while preserving a prefix and suffix.
155///
156/// # Parameters
157///
158/// * `value` - Field value to mask.
159/// * `prefix_chars` - Number of leading characters to retain.
160/// * `suffix_chars` - Number of trailing characters to retain.
161/// * `replacement` - Replacement inserted between retained edges.
162/// * `full_mask_below_or_equal` - Character length threshold for full masks.
163///
164/// # Returns
165///
166/// Owned masked value.
167fn mask_preserving_edges<'a>(
168 value: &str,
169 prefix_chars: usize,
170 suffix_chars: usize,
171 replacement: &str,
172 full_mask_below_or_equal: usize,
173) -> Cow<'a, str> {
174 let chars = value.chars().collect::<Vec<_>>();
175 if chars.len() <= full_mask_below_or_equal || chars.len() <= prefix_chars + suffix_chars {
176 return Cow::Owned(replacement.to_string());
177 }
178 let prefix = chars.iter().take(prefix_chars).collect::<String>();
179 let suffix = chars.iter().skip(chars.len() - suffix_chars).collect::<String>();
180 Cow::Owned(format!("{prefix}{replacement}{suffix}"))
181}
182
183/// Masks a value while preserving only a suffix.
184///
185/// # Parameters
186///
187/// * `value` - Field value to mask.
188/// * `suffix_chars` - Number of trailing characters to retain.
189/// * `replacement` - Replacement inserted before the retained suffix.
190/// * `full_mask_below_or_equal` - Character length threshold for full masks.
191///
192/// # Returns
193///
194/// Owned masked value.
195fn mask_preserving_suffix<'a>(
196 value: &str,
197 suffix_chars: usize,
198 replacement: &str,
199 full_mask_below_or_equal: usize,
200) -> Cow<'a, str> {
201 let chars = value.chars().collect::<Vec<_>>();
202 if chars.len() <= full_mask_below_or_equal || chars.len() <= suffix_chars {
203 return Cow::Owned(replacement.to_string());
204 }
205 let suffix = chars.iter().skip(chars.len() - suffix_chars).collect::<String>();
206 Cow::Owned(format!("{replacement}{suffix}"))
207}