Skip to main content

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}