Skip to main content

shipper_output_sanitizer/
lib.rs

1//! Output sanitization helpers for cargo command logs and evidence payloads.
2
3/// Strip ANSI escape sequences (CSI/OSC/etc.) from a string.
4///
5/// Cargo colorizes its output with SGR codes like `\x1b[1m` (bold) and
6/// `\x1b[92m` (bright green). Those bytes are noise in anything that will
7/// be parsed or rendered outside a terminal — events, receipts, sidecar
8/// files, dashboards. This function removes every `\x1b[...]` CSI sequence
9/// plus `\x1b]...\x07` OSC sequences and bare `\x1b` characters, leaving
10/// the underlying text intact.
11///
12/// Dependency-free and allocation-frugal — processes one byte at a time.
13///
14/// # Examples
15///
16/// ```
17/// use shipper_output_sanitizer::strip_ansi;
18///
19/// let colored = "\x1b[1m\x1b[92m   Compiling\x1b[0m demo v0.1.0";
20/// assert_eq!(strip_ansi(colored), "   Compiling demo v0.1.0");
21///
22/// // No-op on plain strings.
23/// assert_eq!(strip_ansi("hello"), "hello");
24/// ```
25pub fn strip_ansi(s: &str) -> String {
26    let bytes = s.as_bytes();
27    let mut out = String::with_capacity(bytes.len());
28    let mut i = 0;
29    while i < bytes.len() {
30        if bytes[i] == 0x1b && i + 1 < bytes.len() {
31            match bytes[i + 1] {
32                // CSI: \x1b[ ... <final byte 0x40..=0x7e>
33                b'[' => {
34                    i += 2;
35                    while i < bytes.len() {
36                        let b = bytes[i];
37                        i += 1;
38                        if (0x40..=0x7e).contains(&b) {
39                            break;
40                        }
41                    }
42                }
43                // OSC: \x1b] ... BEL (0x07) or ST (\x1b\\)
44                b']' => {
45                    i += 2;
46                    while i < bytes.len() {
47                        if bytes[i] == 0x07 {
48                            i += 1;
49                            break;
50                        }
51                        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
52                            i += 2;
53                            break;
54                        }
55                        i += 1;
56                    }
57                }
58                // Two-byte escape (e.g. \x1b(B, \x1b=, …) — skip next byte.
59                _ => i += 2,
60            }
61        } else {
62            // Non-ESC byte — append as UTF-8-safe codepoint.
63            let ch = s[i..].chars().next().unwrap_or('\0');
64            let len = ch.len_utf8();
65            out.push(ch);
66            i += len;
67        }
68    }
69    out
70}
71
72/// Return the last `n` lines from `s`, then apply sensitive redaction.
73///
74/// # Examples
75///
76/// ```
77/// use shipper_output_sanitizer::tail_lines;
78///
79/// let log = "line1\nline2\nline3\nline4";
80/// assert_eq!(tail_lines(log, 2), "line3\nline4");
81/// ```
82pub fn tail_lines(s: &str, n: usize) -> String {
83    // Normalize line endings before splitting to ensure consistent line counts.
84    let normalized = s.replace("\r\n", "\n").replace('\r', "\n");
85    let lines: Vec<&str> = normalized.lines().collect();
86    let tail = if lines.len() <= n {
87        normalized
88    } else {
89        lines[lines.len() - n..].join("\n")
90    };
91    redact_sensitive(&tail)
92}
93
94/// Redact sensitive token-like patterns from output lines.
95///
96/// Applied to stdout/stderr tails before they are persisted.
97///
98/// # Examples
99///
100/// ```
101/// use shipper_output_sanitizer::redact_sensitive;
102///
103/// assert_eq!(
104///     redact_sensitive("CARGO_REGISTRY_TOKEN=secret123"),
105///     "CARGO_REGISTRY_TOKEN=[REDACTED]"
106/// );
107///
108/// // Non-sensitive content passes through unchanged
109/// assert_eq!(
110///     redact_sensitive("Compiling demo v0.1.0"),
111///     "Compiling demo v0.1.0"
112/// );
113/// ```
114pub fn redact_sensitive(s: &str) -> String {
115    // Normalize line endings to \n for idempotence (\r\n → \n, bare \r → \n).
116    let normalized = s.replace("\r\n", "\n").replace('\r', "\n");
117    let mut result = String::with_capacity(normalized.len());
118    let mut first = true;
119    for line in normalized.lines() {
120        if !first {
121            result.push('\n');
122        }
123        first = false;
124        result.push_str(&redact_line(line));
125    }
126    // Preserve trailing newline if present.
127    if normalized.ends_with('\n') {
128        result.push('\n');
129    }
130    result
131}
132
133#[cfg(test)]
134mod strip_ansi_tests {
135    use super::strip_ansi;
136
137    #[test]
138    fn strips_sgr_color_codes() {
139        let input = "\x1b[1m\x1b[92m   Compiling\x1b[0m demo v0.1.0";
140        assert_eq!(strip_ansi(input), "   Compiling demo v0.1.0");
141    }
142
143    #[test]
144    fn strips_multiple_codes_and_preserves_newlines() {
145        let input = "\x1b[31merror\x1b[0m: thing\n\x1b[33mwarning\x1b[0m: other\n";
146        assert_eq!(strip_ansi(input), "error: thing\nwarning: other\n");
147    }
148
149    #[test]
150    fn noop_on_plain_strings() {
151        assert_eq!(strip_ansi("hello"), "hello");
152        assert_eq!(strip_ansi(""), "");
153        assert_eq!(strip_ansi("line1\nline2"), "line1\nline2");
154    }
155
156    #[test]
157    fn strips_cargo_style_dry_run_output() {
158        let input = "\x1b[1m\x1b[92m   Compiling\x1b[0m anstyle v1.0.14\n\x1b[1m\x1b[33mwarning\x1b[0m: aborting upload due to dry run\n";
159        let out = strip_ansi(input);
160        assert!(
161            !out.contains('\x1b'),
162            "no ESC bytes should remain: {:?}",
163            out
164        );
165        assert!(out.contains("Compiling"));
166        assert!(out.contains("warning"));
167        assert!(out.contains("aborting upload"));
168    }
169
170    #[test]
171    fn handles_utf8_between_escapes() {
172        let input = "\x1b[1mhello, 世界\x1b[0m";
173        assert_eq!(strip_ansi(input), "hello, 世界");
174    }
175
176    #[test]
177    fn strips_osc_sequences() {
178        let input = "\x1b]0;title\x07done";
179        assert_eq!(strip_ansi(input), "done");
180    }
181}
182
183fn redact_line(line: &str) -> String {
184    let mut out = line.to_string();
185
186    if let Some(pos) = out.to_ascii_lowercase().find("authorization:") {
187        let after = &out[pos..];
188        if let Some(bearer_pos) = after.to_ascii_lowercase().find("bearer ") {
189            let redact_start = pos + bearer_pos + "bearer ".len();
190            out = format!("{}[REDACTED]", &out[..redact_start]);
191        }
192    }
193
194    if let Some(pos) = out.to_ascii_lowercase().find("token") {
195        let after_key = &out[pos + "token".len()..];
196        let trimmed = after_key.trim_start();
197        if trimmed.starts_with("= ") || trimmed.starts_with("=") {
198            let eq_offset = pos + "token".len() + (after_key.len() - trimmed.len());
199            let after_eq = trimmed.trim_start_matches('=').trim_start();
200            if after_eq.starts_with('"') || after_eq.starts_with('\'') {
201                out = format!("{}= \"[REDACTED]\"", &out[..eq_offset]);
202            } else if !after_eq.is_empty() {
203                out = format!("{}= [REDACTED]", &out[..eq_offset]);
204            }
205        }
206    }
207
208    if let Some(pos) = find_cargo_token_env(&out)
209        && let Some(eq_pos) = out[pos..].find('=')
210    {
211        let abs_eq = pos + eq_pos;
212        out = format!("{}=[REDACTED]", &out[..abs_eq]);
213    }
214
215    out
216}
217
218fn find_cargo_token_env(s: &str) -> Option<usize> {
219    if let Some(pos) = s.find("CARGO_REGISTRY_TOKEN") {
220        return Some(pos);
221    }
222    if let Some(pos) = s.find("CARGO_REGISTRIES_") {
223        let after = &s[pos + "CARGO_REGISTRIES_".len()..];
224        if after.contains("_TOKEN") {
225            return Some(pos);
226        }
227    }
228    None
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn redact_authorization_bearer_header() {
237        let input = "Authorization: Bearer cio_abc123secret";
238        let out = redact_sensitive(input);
239        assert_eq!(out, "Authorization: Bearer [REDACTED]");
240    }
241
242    #[test]
243    fn redact_token_assignment_quoted() {
244        let input = r#"token = "cio_mysecrettoken""#;
245        let out = redact_sensitive(input);
246        assert!(out.contains("[REDACTED]"));
247        assert!(!out.contains("cio_mysecrettoken"));
248    }
249
250    #[test]
251    fn redact_cargo_registry_token_env() {
252        let input = "CARGO_REGISTRY_TOKEN=cio_secret123";
253        let out = redact_sensitive(input);
254        assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
255    }
256
257    #[test]
258    fn redact_cargo_registries_named_token_env() {
259        let input = "CARGO_REGISTRIES_MY_REG_TOKEN=secret456";
260        let out = redact_sensitive(input);
261        assert_eq!(out, "CARGO_REGISTRIES_MY_REG_TOKEN=[REDACTED]");
262    }
263
264    #[test]
265    fn redact_preserves_non_sensitive_content() {
266        let input = "Compiling demo v0.1.0\nFinished release target";
267        let out = redact_sensitive(input);
268        assert_eq!(out, input);
269    }
270
271    #[test]
272    fn tail_lines_takes_last_lines_then_redacts() {
273        let input = "first\nAuthorization: Bearer secret_token\nthird";
274        let out = tail_lines(input, 2);
275        assert_eq!(out, "Authorization: Bearer [REDACTED]\nthird");
276    }
277
278    #[test]
279    fn tail_lines_with_more_lines_than_input_returns_whole_tail() {
280        let input = "one\ntwo\nthree";
281        assert_eq!(tail_lines(input, 10), input);
282    }
283
284    // --- redact_sensitive edge cases ---
285
286    #[test]
287    fn redact_empty_input() {
288        assert_eq!(redact_sensitive(""), "");
289    }
290
291    #[test]
292    fn redact_very_long_token_value() {
293        let long_token = "a".repeat(2000);
294        let input = format!("CARGO_REGISTRY_TOKEN={long_token}");
295        let out = redact_sensitive(&input);
296        assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
297        assert!(!out.contains(&long_token));
298    }
299
300    #[test]
301    fn redact_unicode_surrounding_text() {
302        let input = "日本語 Authorization: Bearer secret_tok 中文";
303        let out = redact_sensitive(input);
304        assert_eq!(out, "日本語 Authorization: Bearer [REDACTED]");
305    }
306
307    #[test]
308    fn redact_token_at_string_start() {
309        let input = "token = abc123";
310        let out = redact_sensitive(input);
311        assert!(out.contains("[REDACTED]"));
312        assert!(!out.contains("abc123"));
313    }
314
315    #[test]
316    fn redact_multiple_sensitive_patterns_one_line() {
317        // Both "token=" and "CARGO_REGISTRY_TOKEN=" appear; at least one is redacted.
318        let input = "CARGO_REGISTRY_TOKEN=secret1 token = secret2";
319        let out = redact_sensitive(input);
320        assert!(out.contains("[REDACTED]"));
321        assert!(!out.contains("secret1"));
322    }
323
324    #[test]
325    fn redact_token_single_quoted() {
326        let input = "token = 'my_secret_value'";
327        let out = redact_sensitive(input);
328        assert!(out.contains("[REDACTED]"));
329        assert!(!out.contains("my_secret_value"));
330    }
331
332    #[test]
333    fn redact_token_unquoted_value() {
334        let input = "token = plainvalue";
335        let out = redact_sensitive(input);
336        assert!(out.contains("[REDACTED]"));
337        assert!(!out.contains("plainvalue"));
338    }
339
340    #[test]
341    fn redact_token_no_spaces_around_equals() {
342        let input = "token=nospacesecret";
343        let out = redact_sensitive(input);
344        assert!(out.contains("[REDACTED]"));
345        assert!(!out.contains("nospacesecret"));
346    }
347
348    #[test]
349    fn redact_authorization_case_insensitive() {
350        let input = "authorization: bearer my_secret";
351        let out = redact_sensitive(input);
352        assert!(out.contains("[REDACTED]"));
353        assert!(!out.contains("my_secret"));
354    }
355
356    #[test]
357    fn redact_preserves_trailing_newline() {
358        let input = "CARGO_REGISTRY_TOKEN=secret\n";
359        let out = redact_sensitive(input);
360        assert!(out.ends_with('\n'));
361        assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]\n");
362    }
363
364    #[test]
365    fn redact_token_with_empty_value_after_equals() {
366        // "token =" with nothing after — no redaction needed since value is empty.
367        let input = "token = ";
368        let out = redact_sensitive(input);
369        // The trimmed after-eq is empty, so no replacement occurs.
370        assert_eq!(out, "token = ");
371    }
372
373    #[test]
374    fn redact_cargo_registries_without_token_suffix_not_matched() {
375        // CARGO_REGISTRIES_FOO=bar has no _TOKEN suffix, should not be redacted.
376        let input = "CARGO_REGISTRIES_FOO=bar";
377        let out = redact_sensitive(input);
378        assert_eq!(out, "CARGO_REGISTRIES_FOO=bar");
379    }
380
381    #[test]
382    fn redact_mixed_case_authorization() {
383        let input = "AUTHORIZATION: BEARER top_secret";
384        let out = redact_sensitive(input);
385        assert!(out.contains("[REDACTED]"));
386        assert!(!out.contains("top_secret"));
387    }
388
389    #[test]
390    fn redact_multiline_preserves_all_lines() {
391        let input = "line1\nline2\nline3";
392        let out = redact_sensitive(input);
393        assert_eq!(out.lines().count(), 3);
394    }
395
396    // --- tail_lines edge cases ---
397
398    #[test]
399    fn tail_lines_empty_input() {
400        assert_eq!(tail_lines("", 5), "");
401    }
402
403    #[test]
404    fn tail_lines_zero_lines_requested() {
405        let out = tail_lines("one\ntwo\nthree", 0);
406        assert_eq!(out, "");
407    }
408
409    #[test]
410    fn tail_lines_newline_only_input() {
411        let out = tail_lines("\n", 5);
412        // "\n" has one empty line via .lines(), plus trailing newline
413        assert_eq!(out, "\n");
414    }
415
416    #[test]
417    fn tail_lines_single_line_input() {
418        assert_eq!(tail_lines("hello", 1), "hello");
419    }
420
421    #[test]
422    fn tail_lines_sensitive_data_before_cutoff_excluded() {
423        let input = "CARGO_REGISTRY_TOKEN=secret\nsafe line\nanother safe";
424        let out = tail_lines(input, 2);
425        assert!(!out.contains("CARGO_REGISTRY_TOKEN"));
426        assert!(!out.contains("secret"));
427        assert_eq!(out, "safe line\nanother safe");
428    }
429
430    #[test]
431    fn tail_lines_preserves_trailing_newline_when_all_lines() {
432        let input = "one\ntwo\n";
433        let out = tail_lines(input, 10);
434        assert!(out.ends_with('\n'));
435    }
436
437    #[test]
438    fn tail_lines_exact_count_match() {
439        let input = "a\nb\nc";
440        assert_eq!(tail_lines(input, 3), "a\nb\nc");
441    }
442
443    // --- Token pattern tests ---
444
445    #[test]
446    fn redact_bearer_jwt_like_token() {
447        let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.rXz";
448        let out = redact_sensitive(input);
449        assert_eq!(out, "Authorization: Bearer [REDACTED]");
450    }
451
452    #[test]
453    fn redact_basic_auth_not_touched() {
454        // Only bearer tokens are redacted; Basic auth passes through
455        let input = "Authorization: Basic dXNlcjpwYXNz";
456        let out = redact_sensitive(input);
457        assert_eq!(out, "Authorization: Basic dXNlcjpwYXNz");
458    }
459
460    #[test]
461    fn redact_multiple_bearer_across_lines() {
462        let input = "Authorization: Bearer tok1\nOther line\nAuthorization: Bearer tok2";
463        let out = redact_sensitive(input);
464        assert_eq!(
465            out,
466            "Authorization: Bearer [REDACTED]\nOther line\nAuthorization: Bearer [REDACTED]"
467        );
468    }
469
470    #[test]
471    fn redact_multiple_registries_tokens_multiline() {
472        let input = "CARGO_REGISTRIES_STAGING_TOKEN=stg\nCARGO_REGISTRIES_PROD_TOKEN=prd";
473        let out = redact_sensitive(input);
474        assert_eq!(
475            out,
476            "CARGO_REGISTRIES_STAGING_TOKEN=[REDACTED]\nCARGO_REGISTRIES_PROD_TOKEN=[REDACTED]"
477        );
478    }
479
480    #[test]
481    fn redact_token_in_url_query_param() {
482        let input = "https://crates.io/api?token=secret_api_key";
483        let out = redact_sensitive(input);
484        assert!(out.contains("[REDACTED]"));
485        assert!(!out.contains("secret_api_key"));
486    }
487
488    #[test]
489    fn redact_bearer_with_extra_whitespace() {
490        let input = "Authorization:   Bearer   tok123";
491        let out = redact_sensitive(input);
492        assert_eq!(out, "Authorization:   Bearer [REDACTED]");
493    }
494
495    #[test]
496    fn redact_credentials_toml_format() {
497        let input = "[registries.my-reg]\ntoken = \"cio_secret\"";
498        let out = redact_sensitive(input);
499        assert!(out.contains("[registries.my-reg]"));
500        assert!(out.contains("[REDACTED]"));
501        assert!(!out.contains("cio_secret"));
502    }
503
504    // --- No false positives ---
505
506    #[test]
507    fn no_false_positive_windows_path() {
508        let input = r"C:\Users\admin\.cargo\registry\cache\crate-0.1.0";
509        let out = redact_sensitive(input);
510        assert_eq!(out, input);
511    }
512
513    #[test]
514    fn no_false_positive_unix_path() {
515        let input = "/home/user/.cargo/registry/src/index.crates.io-1ecc6299db9ec823";
516        let out = redact_sensitive(input);
517        assert_eq!(out, input);
518    }
519
520    #[test]
521    fn no_false_positive_cargo_home_env() {
522        let input = "CARGO_HOME=/home/user/.cargo";
523        let out = redact_sensitive(input);
524        assert_eq!(out, input);
525    }
526
527    #[test]
528    fn no_false_positive_temp_path() {
529        let input = "/tmp/cargo-installXXXXXX/release/mycrate";
530        let out = redact_sensitive(input);
531        assert_eq!(out, input);
532    }
533
534    #[test]
535    fn no_false_positive_tokenize_word() {
536        let input = "We tokenize the input and parse it.";
537        let out = redact_sensitive(input);
538        assert_eq!(out, input);
539    }
540
541    #[test]
542    fn no_false_positive_token_in_prose_no_equals() {
543        let input = "Please provide your authentication token via the CLI.";
544        let out = redact_sensitive(input);
545        assert_eq!(out, input);
546    }
547
548    #[test]
549    fn no_false_positive_normal_cargo_compile_output() {
550        let input = "   Compiling serde v1.0.200\n   Compiling tokio v1.37.0\n    Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.34s";
551        let out = redact_sensitive(input);
552        assert_eq!(out, input);
553    }
554
555    // --- Mixed content ---
556
557    #[test]
558    fn mixed_token_and_cargo_output() {
559        let input =
560            "   Compiling mycrate v0.1.0\nAuthorization: Bearer secret123\n   Finished release";
561        let out = redact_sensitive(input);
562        assert!(out.contains("Compiling mycrate v0.1.0"));
563        assert!(out.contains("Bearer [REDACTED]"));
564        assert!(out.contains("Finished release"));
565        assert!(!out.contains("secret123"));
566    }
567
568    #[test]
569    fn mixed_env_vars_sensitive_and_benign() {
570        let input = "CARGO_HOME=/usr/local/cargo\nCARGO_REGISTRY_TOKEN=secret";
571        let out = redact_sensitive(input);
572        assert!(out.contains("CARGO_HOME=/usr/local/cargo"));
573        assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
574        assert!(!out.contains("=secret"));
575    }
576
577    // --- Unicode ---
578
579    #[test]
580    fn unicode_cjk_not_corrupted() {
581        let input = "ビルド成功: mycrate v0.1.0";
582        let out = redact_sensitive(input);
583        assert_eq!(out, input);
584    }
585
586    #[test]
587    fn unicode_emoji_preserved() {
588        let input = "✅ Published successfully! 🎉 crate uploaded.";
589        let out = redact_sensitive(input);
590        assert_eq!(out, input);
591    }
592
593    #[test]
594    fn unicode_with_token_redaction() {
595        let input = "🔑 CARGO_REGISTRY_TOKEN=secret123";
596        let out = redact_sensitive(input);
597        assert!(out.contains("🔑"));
598        assert!(out.contains("[REDACTED]"));
599        assert!(!out.contains("secret123"));
600    }
601
602    // --- Large output ---
603
604    #[test]
605    fn large_output_many_lines() {
606        let mut lines: Vec<String> = (0..10_000)
607            .map(|i| format!("Compiling crate_{i} v0.1.0"))
608            .collect();
609        lines[5000] = "CARGO_REGISTRY_TOKEN=hidden_secret".to_string();
610        let input = lines.join("\n");
611        let out = redact_sensitive(&input);
612        assert!(!out.contains("hidden_secret"));
613        assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
614        assert!(out.contains("Compiling crate_0 v0.1.0"));
615        assert!(out.contains("Compiling crate_9999 v0.1.0"));
616        assert_eq!(out.lines().count(), 10_000);
617    }
618
619    #[test]
620    fn large_single_line_with_token() {
621        let prefix = "x".repeat(100_000);
622        let input = format!("{prefix} CARGO_REGISTRY_TOKEN=longsecret");
623        let out = redact_sensitive(&input);
624        assert!(!out.contains("longsecret"));
625        assert!(out.contains("[REDACTED]"));
626    }
627
628    // --- tail_lines additional ---
629
630    #[test]
631    fn tail_lines_redacts_all_sensitive_in_window() {
632        let input = "safe\nAuthorization: Bearer tok1\ntoken = secret2\nlast";
633        let out = tail_lines(input, 3);
634        assert_eq!(
635            out,
636            "Authorization: Bearer [REDACTED]\ntoken = [REDACTED]\nlast"
637        );
638    }
639}
640
641#[cfg(test)]
642mod property_tests {
643    use proptest::prelude::*;
644
645    use super::*;
646
647    proptest! {
648        #[test]
649        fn redact_sensitive_is_idempotent(input in ".*") {
650            let once = redact_sensitive(&input);
651            let twice = redact_sensitive(&once);
652            prop_assert_eq!(once, twice);
653        }
654
655        #[test]
656        fn tail_lines_preserves_last_n_lines(
657            lines in prop::collection::vec("[A-Za-z0-9 ]{0,12}", 0..12),
658            n in 0usize..8,
659            tail_newline in prop::bool::ANY,
660        ) {
661            let joined = lines.join("\n");
662            let input = if tail_newline && !joined.is_empty() {
663                format!("{}\n", joined)
664            } else {
665                joined
666            };
667
668            let result = tail_lines(&input, n);
669            let expected_tail = if input.lines().count() <= n {
670                input.lines().collect::<Vec<_>>()
671            } else {
672                input.lines().collect::<Vec<_>>()[input.lines().count() - n..].to_vec()
673            };
674
675            let expected = expected_tail
676                .iter()
677                .map(|line| redact_line(line))
678                .collect::<Vec<String>>()
679                .join("\n");
680            let expected = if input.ends_with('\n') && input.lines().count() <= n {
681                format!("{expected}\n")
682            } else {
683                expected
684            };
685
686            prop_assert_eq!(result, expected);
687        }
688
689        #[test]
690        fn authorization_tokens_are_redacted(prefix in "[A-Za-z ]{0,12}", token in "[A-Za-z0-9_./-]{1,24}") {
691            let input = format!("{prefix}Authorization: Bearer {token}");
692            let out = redact_sensitive(&input);
693            prop_assert!(out.contains("[REDACTED]"));
694            prop_assert!(out.ends_with("Bearer [REDACTED]"), "Expected output to end with 'Bearer [REDACTED]', got: {}", out);
695        }
696
697        #[test]
698        fn cargo_registry_token_always_redacted(secret in "[a-z0-9]{1,30}") {
699            let input = format!("CARGO_REGISTRY_TOKEN={secret}");
700            let out = redact_sensitive(&input);
701            prop_assert!(!out.contains(&*secret), "Secret '{}' leaked in: {}", secret, out);
702            prop_assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
703        }
704
705        #[test]
706        fn token_assignment_always_redacted(secret in "[0-9]{3,20}") {
707            let input = format!("token = {secret}");
708            let out = redact_sensitive(&input);
709            prop_assert!(out.contains("[REDACTED]"), "Expected [REDACTED] in: {}", out);
710            prop_assert!(!out.contains(&*secret), "Secret '{}' leaked in: {}", secret, out);
711        }
712    }
713}
714
715#[cfg(test)]
716mod snapshot_tests {
717    use super::*;
718    use insta::assert_snapshot;
719
720    #[test]
721    fn snapshot_redact_bearer_token() {
722        assert_snapshot!(redact_sensitive("Authorization: Bearer cio_abc123secret"));
723    }
724
725    #[test]
726    fn snapshot_redact_cargo_registry_token() {
727        assert_snapshot!(redact_sensitive("CARGO_REGISTRY_TOKEN=mysecrettoken"));
728    }
729
730    #[test]
731    fn snapshot_redact_named_registry_token() {
732        assert_snapshot!(redact_sensitive(
733            "CARGO_REGISTRIES_PRIVATE_REG_TOKEN=secret456"
734        ));
735    }
736
737    #[test]
738    fn snapshot_redact_token_assignment() {
739        assert_snapshot!(redact_sensitive(r#"token = "cio_mysecrettoken""#));
740    }
741
742    #[test]
743    fn snapshot_passthrough_normal_output() {
744        assert_snapshot!(redact_sensitive(
745            "Compiling demo v0.1.0\nFinished release target\nUploading to crates.io"
746        ));
747    }
748
749    #[test]
750    fn snapshot_tail_lines_3() {
751        assert_snapshot!(tail_lines("line1\nline2\nline3\nline4\nline5", 3));
752    }
753
754    #[test]
755    fn snapshot_tail_lines_with_redaction() {
756        assert_snapshot!(tail_lines(
757            "normal line\nCARGO_REGISTRY_TOKEN=secret\nfinal line",
758            2
759        ));
760    }
761
762    #[test]
763    fn snapshot_mixed_sensitive_output() {
764        let input =
765            "Compiling foo\nAuthorization: Bearer secret123\nCARGO_REGISTRY_TOKEN=tok\nDone";
766        assert_snapshot!(redact_sensitive(input));
767    }
768
769    #[test]
770    fn snapshot_redact_empty() {
771        assert_snapshot!(redact_sensitive(""));
772    }
773
774    #[test]
775    fn snapshot_redact_multiple_sensitive_same_line() {
776        assert_snapshot!(redact_sensitive("CARGO_REGISTRY_TOKEN=abc token = xyz"));
777    }
778
779    #[test]
780    fn snapshot_tail_lines_zero() {
781        assert_snapshot!(tail_lines("one\ntwo\nthree", 0));
782    }
783
784    #[test]
785    fn snapshot_redact_case_insensitive_auth() {
786        assert_snapshot!(redact_sensitive("authorization: bearer lowercasetoken"));
787    }
788
789    #[test]
790    fn snapshot_redact_single_quoted_token() {
791        assert_snapshot!(redact_sensitive("token = 'single_quoted_secret'"));
792    }
793
794    #[test]
795    fn snapshot_tail_lines_newline_only() {
796        assert_snapshot!(tail_lines("\n\n\n", 2));
797    }
798
799    #[test]
800    fn snapshot_multiline_mixed_token_types() {
801        let input = "Compiling foo v1.0\nAuthorization: Bearer jwt_tok_123\nCARGO_REGISTRY_TOKEN=cio_abc\ntoken = \"mysecret\"\nDone publishing";
802        assert_snapshot!(redact_sensitive(input));
803    }
804
805    #[test]
806    fn snapshot_unicode_with_redaction() {
807        let input =
808            "🚀 Déploiement: mycrate v0.1.0\n🔑 CARGO_REGISTRY_TOKEN=secret_val\n✅ Terminé!";
809        assert_snapshot!(redact_sensitive(input));
810    }
811
812    #[test]
813    fn snapshot_typical_cargo_publish_output() {
814        let input = "   Compiling mycrate v0.2.0 (/home/user/project)\n    Finished `release` profile [optimized] target(s) in 3.42s\n   Uploading mycrate v0.2.0 (/home/user/project/Cargo.toml)\n    Uploaded mycrate v0.2.0 to `crates.io`\nnote: waiting for `mycrate v0.2.0` to be available at registry `crates.io`\npublished mycrate v0.2.0 at registry `crates.io`";
815        assert_snapshot!(redact_sensitive(input));
816    }
817}