1use sha2::{Digest, Sha256};
35
36#[derive(Debug, Clone)]
38pub struct SecretEntry {
39 pub content: String,
41 pub mode: SecretMode,
43 pub replacement: Option<String>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum SecretMode {
51 #[default]
54 Obfuscate,
55 Replace,
57}
58
59const PLACEHOLDER_LEN: usize = 6; const HASH_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
62
63const MIN_SECRET_LEN: usize = 8;
66
67const REPLACEMENT_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
69
70fn build_placeholder(n: usize) -> String {
72 let hash = Sha256::digest(format!("oxicode-secret-placeholder-{n}").as_bytes());
73 let mut tag = String::with_capacity(PLACEHOLDER_LEN);
74 tag.push('#');
75 for i in 0..4 {
76 let byte = hash[i] as usize;
77 tag.push(HASH_CHARS[byte % HASH_CHARS.len()] as char);
78 }
79 tag.push('#');
80 tag
81}
82
83fn deterministic_replacement(secret: &str) -> String {
87 let hash = Sha256::digest(secret.as_bytes());
88 let chars: Vec<char> = secret.chars().collect();
89 let mut out = String::with_capacity(secret.len());
90 for (i, _) in chars.iter().enumerate() {
91 let h = hash[i % hash.len()]
94 .wrapping_mul((i as u8).wrapping_add(1))
95 .wrapping_add(0x9e);
96 out.push(REPLACEMENT_CHARS[h as usize % REPLACEMENT_CHARS.len()] as char);
97 }
98 out
99}
100
101pub struct SecretObfuscator {
107 plain_to_index: std::collections::HashMap<String, usize>,
109 obfuscate_mappings: std::collections::HashMap<usize, (String, String)>,
111 deobfuscate_map: std::collections::HashMap<String, String>,
113 replace_mappings: std::collections::HashMap<String, String>,
115 has_any: bool,
117}
118
119impl std::fmt::Debug for SecretObfuscator {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.debug_struct("SecretObfuscator")
122 .field("secret_count", &self.obfuscate_mappings.len())
123 .field("replace_count", &self.replace_mappings.len())
124 .field("has_any", &self.has_any)
125 .finish_non_exhaustive()
126 }
127}
128
129impl Default for SecretObfuscator {
130 fn default() -> Self {
131 Self::new(&[])
132 }
133}
134
135impl SecretObfuscator {
136 pub fn new(entries: &[SecretEntry]) -> Self {
142 let mut ob = Self {
143 plain_to_index: std::collections::HashMap::new(),
144 obfuscate_mappings: std::collections::HashMap::new(),
145 deobfuscate_map: std::collections::HashMap::new(),
146 replace_mappings: std::collections::HashMap::new(),
147 has_any: false,
148 };
149
150 for (index, entry) in entries.iter().enumerate() {
151 match entry.mode {
152 SecretMode::Obfuscate => {
153 if entry.content.len() < MIN_SECRET_LEN {
154 continue;
155 }
156 let placeholder = build_placeholder(index);
157 ob.plain_to_index.insert(entry.content.clone(), index);
158 ob.obfuscate_mappings
159 .insert(index, (entry.content.clone(), placeholder.clone()));
160 ob.deobfuscate_map
161 .insert(placeholder, entry.content.clone());
162 ob.has_any = true;
163 }
164 SecretMode::Replace => {
165 let replacement = entry
166 .replacement
167 .clone()
168 .unwrap_or_else(|| deterministic_replacement(&entry.content));
169 ob.replace_mappings
170 .insert(entry.content.clone(), replacement);
171 ob.has_any = true;
172 }
173 }
174 }
175
176 ob
177 }
178
179 pub fn has_secrets(&self) -> bool {
181 self.has_any
182 }
183
184 pub fn obfuscate(&self, text: &str) -> String {
193 if !self.has_any {
194 return text.to_string();
195 }
196 let mut result = text.to_string();
197
198 let mut replace_sorted: Vec<(&String, &String)> = self.replace_mappings.iter().collect();
201 replace_sorted.sort_by_key(|(s, _)| std::cmp::Reverse(s.len()));
202 for (secret, replacement) in replace_sorted {
203 if !result.contains(secret.as_str()) {
204 continue;
205 }
206 result = result.replace(secret.as_str(), replacement.as_str());
207 }
208
209 let mut obfuscate_sorted: Vec<(&String, &usize)> = self.plain_to_index.iter().collect();
211 obfuscate_sorted.sort_by_key(|(s, _)| std::cmp::Reverse(s.len()));
212 for (secret, index) in obfuscate_sorted {
213 if !result.contains(secret.as_str()) {
214 continue;
215 }
216 if let Some((_, placeholder)) = self.obfuscate_mappings.get(index) {
217 result = result.replace(secret.as_str(), placeholder.as_str());
218 }
219 }
220
221 result
222 }
223
224 pub fn deobfuscate(&self, text: &str) -> String {
230 if !self.has_any || !text.contains('#') {
231 return text.to_string();
232 }
233 let mut result = text.to_string();
234 for (placeholder, secret) in &self.deobfuscate_map {
235 if result.contains(placeholder.as_str()) {
236 result = result.replace(placeholder.as_str(), secret.as_str());
237 }
238 }
239 result
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn entry(content: &str, mode: SecretMode) -> SecretEntry {
248 SecretEntry {
249 content: content.to_string(),
250 mode,
251 replacement: None,
252 }
253 }
254
255 #[test]
256 fn empty_entries_passthrough() {
257 let ob = SecretObfuscator::new(&[]);
258 assert!(!ob.has_secrets());
259 assert_eq!(ob.obfuscate("hello world"), "hello world");
260 assert_eq!(ob.deobfuscate("hello world"), "hello world");
261 }
262
263 #[test]
264 fn obfuscate_replaces_long_secret() {
265 let ob =
266 SecretObfuscator::new(&[entry("sk-ant-api03-abcdef123456", SecretMode::Obfuscate)]);
267 let text = "Bearer sk-ant-api03-abcdef123456";
268 let obfuscated = ob.obfuscate(text);
269 assert!(obfuscated.contains("Bearer"));
270 assert!(!obfuscated.contains("sk-ant-api03"));
271 assert!(obfuscated.contains("#"));
272 }
273
274 #[test]
275 fn deobfuscate_restores_original() {
276 let ob =
277 SecretObfuscator::new(&[entry("sk-ant-api03-abcdef123456", SecretMode::Obfuscate)]);
278 let original = "Bearer sk-ant-api03-abcdef123456";
279 let obfuscated = ob.obfuscate(original);
280 let restored = ob.deobfuscate(&obfuscated);
281 assert_eq!(restored, original);
282 }
283
284 #[test]
285 fn short_secret_skipped() {
286 let ob = SecretObfuscator::new(&[entry("short", SecretMode::Obfuscate)]);
288 assert!(!ob.has_secrets());
289 assert_eq!(ob.obfuscate("has short word"), "has short word");
290 }
291
292 #[test]
293 fn replace_mode_not_reversible() {
294 let ob = SecretObfuscator::new(&[entry("sk-ant-api03-abcdef123456", SecretMode::Replace)]);
295 let original = "Bearer sk-ant-api03-abcdef123456";
296 let obfuscated = ob.obfuscate(original);
297 assert!(!obfuscated.contains("sk-ant-api03"));
298 let restored = ob.deobfuscate(&obfuscated);
300 assert_eq!(restored, obfuscated);
301 }
302
303 #[test]
304 fn replace_mode_with_custom_replacement() {
305 let ob = SecretObfuscator::new(&[SecretEntry {
306 content: "sk-ant-api03-abcdef123456".into(),
307 mode: SecretMode::Replace,
308 replacement: Some("[REDACTED]".into()),
309 }]);
310 let obfuscated = ob.obfuscate("key: sk-ant-api03-abcdef123456");
311 assert_eq!(obfuscated, "key: [REDACTED]");
312 }
313
314 #[test]
315 fn multiple_secrets_all_replaced() {
316 let ob = SecretObfuscator::new(&[
317 entry("sk-ant-api03-key1abcdef", SecretMode::Obfuscate),
318 entry("sk-openai-key2ghijkl", SecretMode::Obfuscate),
319 ]);
320 let text = "keys: sk-ant-api03-key1abcdef and sk-openai-key2ghijkl";
321 let obfuscated = ob.obfuscate(text);
322 assert!(!obfuscated.contains("sk-ant-api03"));
323 assert!(!obfuscated.contains("sk-openai"));
324 let restored = ob.deobfuscate(&obfuscated);
325 assert_eq!(restored, text);
326 }
327
328 #[test]
329 fn longest_first_ordering() {
330 let ob = SecretObfuscator::new(&[
333 entry("sk-ant-api03-key123456789", SecretMode::Obfuscate),
334 entry("sk-ant-api03-key123456789-extra", SecretMode::Obfuscate),
335 ]);
336 let text = "found sk-ant-api03-key123456789-extra here";
337 let obfuscated = ob.obfuscate(text);
338 assert!(!obfuscated.contains("sk-ant-api03"));
340 }
341
342 #[test]
343 fn deterministic_replacement_is_stable() {
344 let r1 = deterministic_replacement("sk-ant-api03-key");
345 let r2 = deterministic_replacement("sk-ant-api03-key");
346 assert_eq!(r1, r2);
347 assert_eq!(r1.len(), "sk-ant-api03-key".len());
348 }
349
350 #[test]
351 fn build_placeholder_format() {
352 let p0 = build_placeholder(0);
353 let p1 = build_placeholder(1);
354 assert!(p0.starts_with('#'));
355 assert!(p0.ends_with('#'));
356 assert_eq!(p0.len(), PLACEHOLDER_LEN);
357 assert_ne!(p0, p1, "different indices produce different placeholders");
358 }
359
360 #[test]
361 fn deobfuscate_without_placeholders_is_passthrough() {
362 let ob =
363 SecretObfuscator::new(&[entry("sk-ant-api03-abcdef123456", SecretMode::Obfuscate)]);
364 assert_eq!(ob.deobfuscate("plain text"), "plain text");
366 }
367
368 #[test]
369 fn debug_does_not_leak_secrets() {
370 let ob = SecretObfuscator::new(&[entry(
371 "sk-super-secret-key1234567890",
372 SecretMode::Obfuscate,
373 )]);
374 let dbg = format!("{ob:?}");
375 assert!(!dbg.contains("sk-super-secret"));
376 }
377}