cuenv_ci/executor/
redact.rs1use std::collections::HashSet;
7
8pub const MIN_SECRET_LENGTH: usize = 4;
10
11pub const REDACTED_PLACEHOLDER: &str = "[REDACTED]";
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ShortSecretWarning {
17 pub key: String,
19 pub length: usize,
21}
22
23#[derive(Debug)]
49pub struct LogRedactor {
50 secrets: Vec<String>,
52 buffer: String,
54 max_secret_len: usize,
56}
57
58impl LogRedactor {
59 #[must_use]
67 pub fn new(secrets: Vec<String>) -> (Self, Vec<ShortSecretWarning>) {
68 let mut warnings = Vec::new();
69 let mut valid_secrets: Vec<String> = Vec::new();
70
71 for (idx, secret) in secrets.into_iter().enumerate() {
72 if secret.len() < MIN_SECRET_LENGTH {
73 warnings.push(ShortSecretWarning {
74 key: format!("secret_{idx}"),
75 length: secret.len(),
76 });
77 } else {
78 valid_secrets.push(secret);
79 }
80 }
81
82 valid_secrets.sort_by_key(|s| std::cmp::Reverse(s.len()));
84
85 let max_secret_len = valid_secrets.iter().map(String::len).max().unwrap_or(0);
86
87 (
88 Self {
89 secrets: valid_secrets,
90 buffer: String::new(),
91 max_secret_len,
92 },
93 warnings,
94 )
95 }
96
97 #[must_use]
102 pub fn with_names(
103 secrets: impl IntoIterator<Item = (String, String)>,
104 ) -> (Self, Vec<ShortSecretWarning>) {
105 let mut warnings = Vec::new();
106 let mut valid_secrets: Vec<String> = Vec::new();
107
108 for (key, value) in secrets {
109 if value.len() < MIN_SECRET_LENGTH {
110 warnings.push(ShortSecretWarning {
111 key,
112 length: value.len(),
113 });
114 } else {
115 valid_secrets.push(value);
116 }
117 }
118
119 valid_secrets.sort_by_key(|s| std::cmp::Reverse(s.len()));
121
122 let unique: HashSet<String> = valid_secrets.into_iter().collect();
124 let mut valid_secrets: Vec<String> = unique.into_iter().collect();
125 valid_secrets.sort_by_key(|s| std::cmp::Reverse(s.len()));
126
127 let max_secret_len = valid_secrets.iter().map(String::len).max().unwrap_or(0);
128
129 (
130 Self {
131 secrets: valid_secrets,
132 buffer: String::new(),
133 max_secret_len,
134 },
135 warnings,
136 )
137 }
138
139 pub fn redact(&mut self, input: &str) -> String {
150 if self.secrets.is_empty() {
151 return input.to_string();
152 }
153
154 self.buffer.push_str(input);
156
157 let buffer_threshold = self.max_secret_len * 2;
159
160 if self.buffer.len() <= buffer_threshold {
161 return String::new();
163 }
164
165 let process_len = self.buffer.len() - buffer_threshold;
167 let to_process: String = self.buffer.drain(..process_len).collect();
168
169 self.redact_immediate(&to_process)
170 }
171
172 pub fn flush(&mut self) -> String {
176 if self.buffer.is_empty() {
177 return String::new();
178 }
179
180 let remaining = std::mem::take(&mut self.buffer);
181 self.redact_immediate(&remaining)
182 }
183
184 #[must_use]
188 pub fn redact_immediate(&self, input: &str) -> String {
189 let mut result = input.to_string();
190
191 for secret in &self.secrets {
192 result = result.replace(secret, REDACTED_PLACEHOLDER);
193 }
194
195 result
196 }
197
198 #[must_use]
200 pub fn has_secrets(&self) -> bool {
201 !self.secrets.is_empty()
202 }
203
204 #[must_use]
206 pub fn secret_count(&self) -> usize {
207 self.secrets.len()
208 }
209}
210
211#[must_use]
217pub fn redact_secrets(input: &str, secrets: &[String]) -> String {
218 if secrets.is_empty() {
219 return input.to_string();
220 }
221
222 let mut result = input.to_string();
223
224 let mut sorted_secrets: Vec<&String> = secrets.iter().collect();
226 sorted_secrets.sort_by_key(|s| std::cmp::Reverse(s.len()));
227
228 for secret in sorted_secrets {
229 if secret.len() >= MIN_SECRET_LENGTH {
230 result = result.replace(secret.as_str(), REDACTED_PLACEHOLDER);
231 }
232 }
233
234 result
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_simple_redaction() {
243 let (redactor, _) = LogRedactor::new(vec!["secret123".to_string()]);
244 let result = redactor.redact_immediate("The password is secret123, don't share it");
245 assert_eq!(result, "The password is [REDACTED], don't share it");
246 }
247
248 #[test]
249 fn test_multiple_secrets() {
250 let (redactor, _) =
251 LogRedactor::new(vec!["password123".to_string(), "api_key_xyz".to_string()]);
252 let result = redactor.redact_immediate("password123 and api_key_xyz are both secrets");
253 assert_eq!(result, "[REDACTED] and [REDACTED] are both secrets");
254 }
255
256 #[test]
257 fn test_repeated_secret() {
258 let (redactor, _) = LogRedactor::new(vec!["secret".to_string()]);
259 let result = redactor.redact_immediate("secret appears twice: secret");
260 assert_eq!(result, "[REDACTED] appears twice: [REDACTED]");
261 }
262
263 #[test]
264 fn test_short_secret_warning() {
265 let (redactor, warnings) = LogRedactor::new(vec![
266 "ab".to_string(), "abc".to_string(), "abcd".to_string(), ]);
270
271 assert_eq!(warnings.len(), 2);
272 assert_eq!(redactor.secret_count(), 1);
273 }
274
275 #[test]
276 fn test_named_secrets_warning() {
277 let secrets = vec![
278 ("DB_PASS".to_string(), "longpassword".to_string()),
279 ("SHORT".to_string(), "ab".to_string()),
280 ];
281 let (_, warnings) = LogRedactor::with_names(secrets);
282
283 assert_eq!(warnings.len(), 1);
284 assert_eq!(warnings[0].key, "SHORT");
285 assert_eq!(warnings[0].length, 2);
286 }
287
288 #[test]
289 fn test_streaming_redaction() {
290 let (mut redactor, _) = LogRedactor::new(vec!["secretpassword".to_string()]);
291
292 let chunk1 = "The password is secret";
294 let chunk2 = "password which is bad";
295
296 let out1 = redactor.redact(chunk1);
297 let out2 = redactor.redact(chunk2);
298 let out3 = redactor.flush();
299
300 let combined = format!("{out1}{out2}{out3}");
301 assert!(combined.contains("[REDACTED]"));
302 assert!(!combined.contains("secretpassword"));
303 }
304
305 #[test]
306 fn test_no_secrets() {
307 let (redactor, warnings) = LogRedactor::new(vec![]);
308 assert!(warnings.is_empty());
309 assert!(!redactor.has_secrets());
310
311 let result = redactor.redact_immediate("nothing to redact here");
312 assert_eq!(result, "nothing to redact here");
313 }
314
315 #[test]
316 fn test_greedy_matching() {
317 let (redactor, _) = LogRedactor::new(vec!["pass".to_string(), "password".to_string()]);
319 let result = redactor.redact_immediate("the password is set");
320 assert_eq!(result, "the [REDACTED] is set");
322 }
323
324 #[test]
325 fn test_redact_secrets_function() {
326 let secrets = vec!["mysecret".to_string(), "another".to_string()];
327 let result = redact_secrets("mysecret and another value", &secrets);
328 assert_eq!(result, "[REDACTED] and [REDACTED] value");
329 }
330
331 #[test]
332 fn test_empty_input() {
333 let (redactor, _) = LogRedactor::new(vec!["secret".to_string()]);
334 let result = redactor.redact_immediate("");
335 assert_eq!(result, "");
336 }
337
338 #[test]
339 fn test_secret_at_boundaries() {
340 let (redactor, _) = LogRedactor::new(vec!["secret".to_string()]);
341
342 let result = redactor.redact_immediate("secret is here");
344 assert_eq!(result, "[REDACTED] is here");
345
346 let result = redactor.redact_immediate("here is secret");
348 assert_eq!(result, "here is [REDACTED]");
349 }
350
351 #[test]
352 fn test_duplicate_secrets_deduplicated() {
353 let secrets = vec![
354 ("KEY1".to_string(), "samevalue".to_string()),
355 ("KEY2".to_string(), "samevalue".to_string()),
356 ];
357 let (redactor, _) = LogRedactor::with_names(secrets);
358
359 assert_eq!(redactor.secret_count(), 1);
361 }
362
363 #[test]
364 fn test_special_characters() {
365 let (redactor, _) = LogRedactor::new(vec!["pass$word!@#".to_string()]);
366 let result = redactor.redact_immediate("the pass$word!@# is special");
367 assert_eq!(result, "the [REDACTED] is special");
368 }
369
370 #[test]
371 fn test_multiline_content() {
372 let (redactor, _) = LogRedactor::new(vec!["secretkey".to_string()]);
373 let input = "line1\nsecretkey\nline3";
374 let result = redactor.redact_immediate(input);
375 assert_eq!(result, "line1\n[REDACTED]\nline3");
376 }
377}