shipper_output_sanitizer/
lib.rs1pub 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 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 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 _ => i += 2,
60 }
61 } else {
62 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
72pub fn tail_lines(s: &str, n: usize) -> String {
83 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
94pub fn redact_sensitive(s: &str) -> String {
115 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 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 #[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 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 let input = "token = ";
368 let out = redact_sensitive(input);
369 assert_eq!(out, "token = ");
371 }
372
373 #[test]
374 fn redact_cargo_registries_without_token_suffix_not_matched() {
375 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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}