scrubber_rb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.cargo/config.toml +21 -0
- data/CHANGELOG.md +59 -0
- data/Cargo.lock +626 -0
- data/Cargo.toml +10 -0
- data/LICENSE +21 -0
- data/README.md +298 -0
- data/SECURITY.md +54 -0
- data/ext/scrubber_rb/Cargo.toml +41 -0
- data/ext/scrubber_rb/build.rs +6 -0
- data/ext/scrubber_rb/extconf.rb +6 -0
- data/ext/scrubber_rb/src/detectors/checksum.rs +222 -0
- data/ext/scrubber_rb/src/detectors/mod.rs +636 -0
- data/ext/scrubber_rb/src/detectors/upi.rs +138 -0
- data/ext/scrubber_rb/src/detectors/validate.rs +339 -0
- data/ext/scrubber_rb/src/engine.rs +716 -0
- data/ext/scrubber_rb/src/lib.rs +247 -0
- data/ext/scrubber_rb/src/nogvl.rs +84 -0
- data/ext/scrubber_rb/src/offsets.rs +118 -0
- data/ext/scrubber_rb/src/pattern.rs +319 -0
- data/ext/scrubber_rb/src/replace.rs +238 -0
- data/lib/scrubber/instance.rb +223 -0
- data/lib/scrubber/llm_guard.rb +79 -0
- data/lib/scrubber/log_formatter.rb +40 -0
- data/lib/scrubber/match.rb +27 -0
- data/lib/scrubber/middleware.rb +124 -0
- data/lib/scrubber/version.rb +5 -0
- data/lib/scrubber_rb.rb +109 -0
- data/sig/scrubber_rb.rbs +150 -0
- metadata +96 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
//! Translating user-supplied Ruby `Regexp`s into Rust `regex` syntax.
|
|
2
|
+
//!
|
|
3
|
+
//! The two dialects overlap a lot but not completely. Where Ruby has something
|
|
4
|
+
//! Rust's linear-time engine deliberately cannot do — backreferences, lookaround
|
|
5
|
+
//! — we refuse at construction time with the name of the offending construct.
|
|
6
|
+
//! Silently dropping a custom detector would be the worst possible failure mode
|
|
7
|
+
//! for a redaction library: you would ship, see no errors, and leak.
|
|
8
|
+
|
|
9
|
+
/// Why a Ruby pattern could not be used.
|
|
10
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
11
|
+
pub struct Unsupported {
|
|
12
|
+
/// The literal construct we found, e.g. `"(?<=" (lookbehind)`.
|
|
13
|
+
pub construct: String,
|
|
14
|
+
/// Why the Rust engine cannot express it.
|
|
15
|
+
pub reason: &'static str,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// Ruby `Regexp` option bits, as returned by `Regexp#options`.
|
|
19
|
+
pub const IGNORECASE: i32 = 1;
|
|
20
|
+
pub const EXTENDED: i32 = 2;
|
|
21
|
+
/// Ruby's `/m` means "dot matches newline", which Rust spells `(?s)`.
|
|
22
|
+
pub const MULTILINE: i32 = 4;
|
|
23
|
+
|
|
24
|
+
/// Translate a Ruby pattern source plus option bits into Rust `regex` syntax.
|
|
25
|
+
pub fn translate(source: &str, options: i32) -> Result<String, Unsupported> {
|
|
26
|
+
reject_unsupported(source)?;
|
|
27
|
+
|
|
28
|
+
let mut flags = String::new();
|
|
29
|
+
if options & IGNORECASE != 0 {
|
|
30
|
+
flags.push('i');
|
|
31
|
+
}
|
|
32
|
+
if options & EXTENDED != 0 {
|
|
33
|
+
flags.push('x');
|
|
34
|
+
}
|
|
35
|
+
if options & MULTILINE != 0 {
|
|
36
|
+
flags.push('s');
|
|
37
|
+
}
|
|
38
|
+
// Ruby's `^` and `$` are always line anchors; Rust's are text anchors
|
|
39
|
+
// unless `m` is set. Setting it unconditionally preserves Ruby semantics.
|
|
40
|
+
flags.push('m');
|
|
41
|
+
|
|
42
|
+
let body = rewrite(source);
|
|
43
|
+
Ok(format!("(?{flags}){body}"))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Constructs Rust's `regex` crate cannot express, in scan order.
|
|
47
|
+
fn reject_unsupported(source: &str) -> Result<(), Unsupported> {
|
|
48
|
+
let bytes = source.as_bytes();
|
|
49
|
+
let mut i = 0;
|
|
50
|
+
let mut in_class = false;
|
|
51
|
+
|
|
52
|
+
while i < bytes.len() {
|
|
53
|
+
let b = bytes[i];
|
|
54
|
+
|
|
55
|
+
if b == b'\\' {
|
|
56
|
+
if let Some(next) = bytes.get(i + 1) {
|
|
57
|
+
if let Some(u) = escape_problem(*next) {
|
|
58
|
+
return Err(u);
|
|
59
|
+
}
|
|
60
|
+
// `\H` expands to a negated class, which cannot be nested
|
|
61
|
+
// inside another character class without changing meaning.
|
|
62
|
+
if *next == b'H' && in_class {
|
|
63
|
+
return Err(Unsupported {
|
|
64
|
+
construct: r"[\H]".to_string(),
|
|
65
|
+
reason: "\\H inside a character class has no Rust equivalent; \
|
|
66
|
+
write the negation explicitly",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (*next == b'k' || *next == b'g')
|
|
70
|
+
&& matches!(bytes.get(i + 2), Some(b'<') | Some(b'\''))
|
|
71
|
+
{
|
|
72
|
+
return Err(Unsupported {
|
|
73
|
+
construct: format!("\\{}<...>", *next as char),
|
|
74
|
+
reason: if *next == b'k' {
|
|
75
|
+
"named backreferences require backtracking"
|
|
76
|
+
} else {
|
|
77
|
+
"subexpression calls require backtracking"
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
i += 2;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if in_class {
|
|
87
|
+
if b == b']' {
|
|
88
|
+
in_class = false;
|
|
89
|
+
}
|
|
90
|
+
i += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
match b {
|
|
95
|
+
b'[' => in_class = true,
|
|
96
|
+
b'(' => {
|
|
97
|
+
let rest = &source[i..];
|
|
98
|
+
if let Some(u) = group_problem(rest) {
|
|
99
|
+
return Err(u);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
b'*' | b'+' | b'?' | b'}' => {
|
|
103
|
+
if bytes.get(i + 1) == Some(&b'+') && b != b'+' {
|
|
104
|
+
return Err(Unsupported {
|
|
105
|
+
construct: format!("{}+", b as char),
|
|
106
|
+
reason: "possessive quantifiers require backtracking",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// `++` is possessive; `+?` is lazy and fine.
|
|
110
|
+
if b == b'+' && bytes.get(i + 1) == Some(&b'+') {
|
|
111
|
+
return Err(Unsupported {
|
|
112
|
+
construct: "++".to_string(),
|
|
113
|
+
reason: "possessive quantifiers require backtracking",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
_ => {}
|
|
118
|
+
}
|
|
119
|
+
i += 1;
|
|
120
|
+
}
|
|
121
|
+
Ok(())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fn escape_problem(next: u8) -> Option<Unsupported> {
|
|
125
|
+
match next {
|
|
126
|
+
b'1'..=b'9' => Some(Unsupported {
|
|
127
|
+
construct: format!("\\{}", next as char),
|
|
128
|
+
reason: "backreferences require backtracking",
|
|
129
|
+
}),
|
|
130
|
+
b'G' => Some(Unsupported {
|
|
131
|
+
construct: "\\G".to_string(),
|
|
132
|
+
reason: "the \\G anchor has no equivalent in a one-pass engine",
|
|
133
|
+
}),
|
|
134
|
+
b'K' => Some(Unsupported {
|
|
135
|
+
construct: "\\K".to_string(),
|
|
136
|
+
reason: "\\K is a backtracking-only match reset",
|
|
137
|
+
}),
|
|
138
|
+
_ => None,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fn group_problem(rest: &str) -> Option<Unsupported> {
|
|
143
|
+
const CASES: &[(&str, &str, &str)] = &[
|
|
144
|
+
("(?<=", "(?<=", "lookbehind requires backtracking"),
|
|
145
|
+
("(?<!", "(?<!", "negative lookbehind requires backtracking"),
|
|
146
|
+
("(?=", "(?=", "lookahead requires backtracking"),
|
|
147
|
+
("(?!", "(?!", "negative lookahead requires backtracking"),
|
|
148
|
+
("(?>", "(?>", "atomic groups require backtracking"),
|
|
149
|
+
("(?(", "(?(", "conditional groups require backtracking"),
|
|
150
|
+
("(?~", "(?~", "absence operators are Onigmo-only"),
|
|
151
|
+
];
|
|
152
|
+
CASES.iter().find_map(|(prefix, construct, reason)| {
|
|
153
|
+
rest.starts_with(prefix).then(|| Unsupported {
|
|
154
|
+
construct: (*construct).to_string(),
|
|
155
|
+
reason,
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// Rewrite the Ruby-only escapes that do have a Rust equivalent.
|
|
161
|
+
fn rewrite(source: &str) -> String {
|
|
162
|
+
let bytes = source.as_bytes();
|
|
163
|
+
let mut out = String::with_capacity(source.len() + 16);
|
|
164
|
+
let mut i = 0;
|
|
165
|
+
let mut in_class = false;
|
|
166
|
+
|
|
167
|
+
while i < bytes.len() {
|
|
168
|
+
let b = bytes[i];
|
|
169
|
+
if b == b'\\' {
|
|
170
|
+
match bytes.get(i + 1) {
|
|
171
|
+
// `\h` / `\H` are Onigmo hex-digit shorthands.
|
|
172
|
+
Some(b'h') => {
|
|
173
|
+
out.push_str(if in_class { "0-9a-fA-F" } else { "[0-9a-fA-F]" });
|
|
174
|
+
i += 2;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
Some(b'H') => {
|
|
178
|
+
out.push_str(if in_class {
|
|
179
|
+
"^0-9a-fA-F"
|
|
180
|
+
} else {
|
|
181
|
+
"[^0-9a-fA-F]"
|
|
182
|
+
});
|
|
183
|
+
i += 2;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// `\Z` is "end of string, ignoring one trailing newline".
|
|
187
|
+
Some(b'Z') if !in_class => {
|
|
188
|
+
out.push_str(r"(?:\n?\z)");
|
|
189
|
+
i += 2;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
Some(next) => {
|
|
193
|
+
out.push('\\');
|
|
194
|
+
out.push(*next as char);
|
|
195
|
+
i += 2;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
None => {
|
|
199
|
+
out.push('\\');
|
|
200
|
+
i += 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if !in_class && b == b'[' {
|
|
207
|
+
in_class = true;
|
|
208
|
+
} else if in_class && b == b']' {
|
|
209
|
+
in_class = false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Copy the whole UTF-8 character, not just this byte.
|
|
213
|
+
let len = utf8_len(b);
|
|
214
|
+
out.push_str(&source[i..(i + len).min(source.len())]);
|
|
215
|
+
i += len;
|
|
216
|
+
}
|
|
217
|
+
out
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn utf8_len(b: u8) -> usize {
|
|
221
|
+
match b {
|
|
222
|
+
0x00..=0x7f => 1,
|
|
223
|
+
0xc0..=0xdf => 2,
|
|
224
|
+
0xe0..=0xef => 3,
|
|
225
|
+
0xf0..=0xf7 => 4,
|
|
226
|
+
_ => 1,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#[cfg(test)]
|
|
231
|
+
mod tests {
|
|
232
|
+
use super::*;
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn plain_patterns_pass_through_with_multiline_flag() {
|
|
236
|
+
let out = translate(r"\bEMP-\d{6}\b", 0).unwrap();
|
|
237
|
+
assert_eq!(out, r"(?m)\bEMP-\d{6}\b");
|
|
238
|
+
assert!(regex::Regex::new(&out).is_ok());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[test]
|
|
242
|
+
fn ruby_option_bits_become_inline_flags() {
|
|
243
|
+
assert!(translate("abc", IGNORECASE).unwrap().starts_with("(?im)"));
|
|
244
|
+
assert!(translate("abc", MULTILINE).unwrap().starts_with("(?sm)"));
|
|
245
|
+
assert!(translate("abc", EXTENDED).unwrap().starts_with("(?xm)"));
|
|
246
|
+
assert!(translate("abc", IGNORECASE | MULTILINE | EXTENDED)
|
|
247
|
+
.unwrap()
|
|
248
|
+
.starts_with("(?ixsm)"));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#[test]
|
|
252
|
+
fn hex_shorthand_is_expanded_inside_and_outside_classes() {
|
|
253
|
+
assert_eq!(translate(r"\h{6}", 0).unwrap(), r"(?m)[0-9a-fA-F]{6}");
|
|
254
|
+
assert_eq!(translate(r"[\h_]{6}", 0).unwrap(), r"(?m)[0-9a-fA-F_]{6}");
|
|
255
|
+
assert!(regex::Regex::new(&translate(r"[\h_]{6}", 0).unwrap()).is_ok());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#[test]
|
|
259
|
+
fn trailing_newline_anchor_is_translated() {
|
|
260
|
+
let out = translate(r"end\Z", 0).unwrap();
|
|
261
|
+
assert_eq!(out, r"(?m)end(?:\n?\z)");
|
|
262
|
+
assert!(regex::Regex::new(&out).is_ok());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#[test]
|
|
266
|
+
fn backreferences_are_rejected_by_name() {
|
|
267
|
+
let err = translate(r"(a)\1", 0).unwrap_err();
|
|
268
|
+
assert_eq!(err.construct, r"\1");
|
|
269
|
+
assert!(err.reason.contains("backreference"));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#[test]
|
|
273
|
+
fn lookaround_is_rejected_by_name() {
|
|
274
|
+
for (src, construct) in [
|
|
275
|
+
(r"(?<=x)y", "(?<="),
|
|
276
|
+
(r"(?<!x)y", "(?<!"),
|
|
277
|
+
(r"x(?=y)", "(?="),
|
|
278
|
+
(r"x(?!y)", "(?!"),
|
|
279
|
+
] {
|
|
280
|
+
let err = translate(src, 0).unwrap_err();
|
|
281
|
+
assert_eq!(err.construct, construct, "for {src}");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#[test]
|
|
286
|
+
fn atomic_groups_and_possessive_quantifiers_are_rejected() {
|
|
287
|
+
assert_eq!(translate(r"(?>a+)b", 0).unwrap_err().construct, "(?>");
|
|
288
|
+
assert_eq!(translate(r"a*+b", 0).unwrap_err().construct, "*+");
|
|
289
|
+
assert_eq!(translate(r"a++b", 0).unwrap_err().construct, "++");
|
|
290
|
+
assert_eq!(translate(r"a?+b", 0).unwrap_err().construct, "?+");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#[test]
|
|
294
|
+
fn lazy_quantifiers_are_fine() {
|
|
295
|
+
assert!(translate(r"a+?b", 0).is_ok());
|
|
296
|
+
assert!(translate(r"a*?b", 0).is_ok());
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[test]
|
|
300
|
+
fn named_groups_are_not_mistaken_for_lookbehind() {
|
|
301
|
+
let out = translate(r"(?<id>\d+)", 0).unwrap();
|
|
302
|
+
assert!(regex::Regex::new(&out).is_ok());
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#[test]
|
|
306
|
+
fn brackets_inside_classes_do_not_confuse_the_scanner() {
|
|
307
|
+
// `(` inside a character class is a literal, not a group.
|
|
308
|
+
assert!(translate(r"[(?=]+", 0).is_ok());
|
|
309
|
+
// An escaped bracket does not open a class.
|
|
310
|
+
assert!(translate(r"\[(?=x)", 0).is_err());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn multibyte_sources_survive_the_rewrite() {
|
|
315
|
+
let out = translate("नमस्ते|🎉", 0).unwrap();
|
|
316
|
+
assert!(out.ends_with("नमस्ते|🎉"));
|
|
317
|
+
assert!(regex::Regex::new(&out).is_ok());
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
//! Replacement strategies: what actually goes into the output where a match was.
|
|
2
|
+
|
|
3
|
+
use sha2::{Digest, Sha256};
|
|
4
|
+
|
|
5
|
+
/// How `:mask` should partially reveal a value. Secrets get `Full` — showing
|
|
6
|
+
/// the last four characters of an API key is a leak, not a convenience.
|
|
7
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
8
|
+
pub enum MaskKind {
|
|
9
|
+
/// `nik@example.com` -> `n***@e***.com`
|
|
10
|
+
Email,
|
|
11
|
+
/// Keep the last four alphanumerics, star the rest, preserve separators.
|
|
12
|
+
Tail4,
|
|
13
|
+
/// Star every non-space character.
|
|
14
|
+
Full,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
18
|
+
pub enum Strategy {
|
|
19
|
+
Label,
|
|
20
|
+
Mask,
|
|
21
|
+
Hash { salt: String },
|
|
22
|
+
Remove,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
impl Strategy {
|
|
26
|
+
pub fn from_name(name: &str, salt: Option<String>) -> Option<Self> {
|
|
27
|
+
match name {
|
|
28
|
+
"label" => Some(Strategy::Label),
|
|
29
|
+
"mask" => Some(Strategy::Mask),
|
|
30
|
+
"hash" => Some(Strategy::Hash {
|
|
31
|
+
salt: salt.unwrap_or_default(),
|
|
32
|
+
}),
|
|
33
|
+
"remove" => Some(Strategy::Remove),
|
|
34
|
+
_ => None,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Render the replacement text for one match.
|
|
40
|
+
pub fn render(strategy: &Strategy, kind: &str, matched: &str, mask: MaskKind, out: &mut String) {
|
|
41
|
+
match strategy {
|
|
42
|
+
Strategy::Remove => {}
|
|
43
|
+
Strategy::Label => {
|
|
44
|
+
out.push('[');
|
|
45
|
+
push_label(kind, out);
|
|
46
|
+
out.push(']');
|
|
47
|
+
}
|
|
48
|
+
Strategy::Hash { salt } => {
|
|
49
|
+
out.push('[');
|
|
50
|
+
push_label(kind, out);
|
|
51
|
+
out.push(':');
|
|
52
|
+
out.push_str(&hash_token(salt, matched));
|
|
53
|
+
out.push(']');
|
|
54
|
+
}
|
|
55
|
+
Strategy::Mask => match mask {
|
|
56
|
+
MaskKind::Email => mask_email(matched, out),
|
|
57
|
+
MaskKind::Tail4 => mask_tail(matched, 4, out),
|
|
58
|
+
MaskKind::Full => mask_tail(matched, 0, out),
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fn push_label(kind: &str, out: &mut String) {
|
|
64
|
+
for ch in kind.chars() {
|
|
65
|
+
out.extend(ch.to_uppercase());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// sha256(salt || value), first 8 hex characters. Deterministic, so the same
|
|
70
|
+
/// value produces the same token across processes and days — logs stay
|
|
71
|
+
/// correlatable without holding the value.
|
|
72
|
+
pub fn hash_token(salt: &str, value: &str) -> String {
|
|
73
|
+
let mut hasher = Sha256::new();
|
|
74
|
+
hasher.update(salt.as_bytes());
|
|
75
|
+
hasher.update(value.as_bytes());
|
|
76
|
+
let digest = hasher.finalize();
|
|
77
|
+
let mut s = String::with_capacity(8);
|
|
78
|
+
for byte in &digest[..4] {
|
|
79
|
+
s.push(char::from_digit(u32::from(byte >> 4), 16).unwrap());
|
|
80
|
+
s.push(char::from_digit(u32::from(byte & 0x0f), 16).unwrap());
|
|
81
|
+
}
|
|
82
|
+
s
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// `nikhil@example.co.uk` -> `n*****@e******.co.uk`
|
|
86
|
+
fn mask_email(value: &str, out: &mut String) {
|
|
87
|
+
let Some((local, domain)) = value.rsplit_once('@') else {
|
|
88
|
+
mask_tail(value, 0, out);
|
|
89
|
+
return;
|
|
90
|
+
};
|
|
91
|
+
push_first_then_stars(local, out);
|
|
92
|
+
out.push('@');
|
|
93
|
+
match domain.split_once('.') {
|
|
94
|
+
Some((host, rest)) => {
|
|
95
|
+
push_first_then_stars(host, out);
|
|
96
|
+
out.push('.');
|
|
97
|
+
out.push_str(rest);
|
|
98
|
+
}
|
|
99
|
+
None => push_first_then_stars(domain, out),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn push_first_then_stars(s: &str, out: &mut String) {
|
|
104
|
+
let mut chars = s.chars();
|
|
105
|
+
match chars.next() {
|
|
106
|
+
Some(first) => {
|
|
107
|
+
out.push(first);
|
|
108
|
+
for _ in chars {
|
|
109
|
+
out.push('*');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
None => out.push('*'),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Star every alphanumeric except the last `keep`, preserving separators so
|
|
117
|
+
/// `4111 1111 1111 1111` stays visually a card: `**** **** **** 1111`.
|
|
118
|
+
fn mask_tail(value: &str, keep: usize, out: &mut String) {
|
|
119
|
+
let total = value.chars().filter(|c| c.is_alphanumeric()).count();
|
|
120
|
+
let reveal_from = total.saturating_sub(keep);
|
|
121
|
+
let mut seen = 0;
|
|
122
|
+
for ch in value.chars() {
|
|
123
|
+
if ch.is_alphanumeric() {
|
|
124
|
+
if seen >= reveal_from {
|
|
125
|
+
out.push(ch);
|
|
126
|
+
} else {
|
|
127
|
+
out.push('*');
|
|
128
|
+
}
|
|
129
|
+
seen += 1;
|
|
130
|
+
} else {
|
|
131
|
+
out.push(ch);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[cfg(test)]
|
|
137
|
+
mod tests {
|
|
138
|
+
use super::*;
|
|
139
|
+
|
|
140
|
+
fn rendered(strategy: &Strategy, kind: &str, value: &str, mask: MaskKind) -> String {
|
|
141
|
+
let mut s = String::new();
|
|
142
|
+
render(strategy, kind, value, mask, &mut s);
|
|
143
|
+
s
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[test]
|
|
147
|
+
fn label_uppercases_the_detector_key() {
|
|
148
|
+
assert_eq!(
|
|
149
|
+
rendered(&Strategy::Label, "credit_card", "x", MaskKind::Tail4),
|
|
150
|
+
"[CREDIT_CARD]"
|
|
151
|
+
);
|
|
152
|
+
assert_eq!(
|
|
153
|
+
rendered(&Strategy::Label, "email", "x", MaskKind::Email),
|
|
154
|
+
"[EMAIL]"
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#[test]
|
|
159
|
+
fn remove_emits_nothing() {
|
|
160
|
+
assert_eq!(
|
|
161
|
+
rendered(&Strategy::Remove, "email", "a@b.com", MaskKind::Email),
|
|
162
|
+
""
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#[test]
|
|
167
|
+
fn hash_is_deterministic_and_salt_sensitive() {
|
|
168
|
+
let a = Strategy::Hash {
|
|
169
|
+
salt: String::new(),
|
|
170
|
+
};
|
|
171
|
+
let b = Strategy::Hash {
|
|
172
|
+
salt: "pepper".into(),
|
|
173
|
+
};
|
|
174
|
+
let first = rendered(&a, "email", "nik@example.com", MaskKind::Email);
|
|
175
|
+
let second = rendered(&a, "email", "nik@example.com", MaskKind::Email);
|
|
176
|
+
assert_eq!(first, second);
|
|
177
|
+
assert_ne!(
|
|
178
|
+
first,
|
|
179
|
+
rendered(&b, "email", "nik@example.com", MaskKind::Email)
|
|
180
|
+
);
|
|
181
|
+
assert!(first.starts_with("[EMAIL:") && first.ends_with(']'));
|
|
182
|
+
assert_eq!(first.len(), "[EMAIL:".len() + 8 + 1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#[test]
|
|
186
|
+
fn mask_email_keeps_shape() {
|
|
187
|
+
assert_eq!(
|
|
188
|
+
rendered(&Strategy::Mask, "email", "nik@example.com", MaskKind::Email),
|
|
189
|
+
"n**@e******.com"
|
|
190
|
+
);
|
|
191
|
+
assert_eq!(
|
|
192
|
+
rendered(&Strategy::Mask, "email", "a@b.co.uk", MaskKind::Email),
|
|
193
|
+
"a@b.co.uk"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn mask_card_preserves_grouping() {
|
|
199
|
+
assert_eq!(
|
|
200
|
+
rendered(
|
|
201
|
+
&Strategy::Mask,
|
|
202
|
+
"credit_card",
|
|
203
|
+
"4111 1111 1111 1111",
|
|
204
|
+
MaskKind::Tail4
|
|
205
|
+
),
|
|
206
|
+
"**** **** **** 1111"
|
|
207
|
+
);
|
|
208
|
+
assert_eq!(
|
|
209
|
+
rendered(
|
|
210
|
+
&Strategy::Mask,
|
|
211
|
+
"credit_card",
|
|
212
|
+
"4111111111111111",
|
|
213
|
+
MaskKind::Tail4
|
|
214
|
+
),
|
|
215
|
+
"************1111"
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#[test]
|
|
220
|
+
fn mask_full_hides_everything() {
|
|
221
|
+
assert_eq!(
|
|
222
|
+
rendered(&Strategy::Mask, "api_key", "ghp_abc123", MaskKind::Full),
|
|
223
|
+
"***_******"
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[test]
|
|
228
|
+
fn strategy_names_round_trip() {
|
|
229
|
+
assert!(Strategy::from_name("label", None).is_some());
|
|
230
|
+
assert!(Strategy::from_name("mask", None).is_some());
|
|
231
|
+
assert!(Strategy::from_name("remove", None).is_some());
|
|
232
|
+
assert_eq!(
|
|
233
|
+
Strategy::from_name("hash", Some("s".into())),
|
|
234
|
+
Some(Strategy::Hash { salt: "s".into() })
|
|
235
|
+
);
|
|
236
|
+
assert!(Strategy::from_name("nope", None).is_none());
|
|
237
|
+
}
|
|
238
|
+
}
|