1const SENSITIVE: &[&str] = &["password", "token", "secret", "key", "auth"];
16
17#[must_use]
21pub fn redact_args(args: &[String], flags: &[(String, Option<String>)]) -> String {
22 let mut parts: Vec<String> = Vec::with_capacity(args.len() + flags.len());
23 for a in args {
24 parts.push(a.clone());
25 }
26 for (name, value) in flags {
27 match value {
28 Some(v) if is_sensitive(name) => parts.push(format!("--{name}=***")),
29 Some(v) => parts.push(format!("--{name}={v}")),
30 None => parts.push(format!("--{name}")),
31 }
32 }
33 parts.join(" ")
34}
35
36fn is_sensitive(name: &str) -> bool {
37 let lower = name.to_lowercase();
38 SENSITIVE.iter().any(|n| lower.contains(n))
39}
40
41#[must_use]
46pub fn redact_string(s: &str) -> String {
47 let mut out = String::with_capacity(s.len());
48 let lower = s.to_ascii_lowercase();
49 let bytes = s.as_bytes();
50 let mut i = 0;
51 while i < bytes.len() {
52 let rest = &lower[i..];
54 let mut hit = None;
55 for kw in [
56 "bearer ",
57 "authorization:",
58 "x-api-key:",
59 "secret",
60 "password",
61 "token",
62 ] {
63 if rest.starts_with(kw) {
64 hit = Some(kw.len());
65 break;
66 }
67 }
68 if let Some(kw_len) = hit {
69 out.push_str(&s[i..i + kw_len]);
70 let mut j = i + kw_len;
72 while j < bytes.len() && !matches!(bytes[j], b'"' | b'\'' | b';' | b'\n' | b'}' | b')')
73 {
74 j += 1;
75 }
76 if j > i + kw_len {
77 out.push_str("***");
78 }
79 i = j;
80 continue;
81 }
82 out.push(s.as_bytes()[i] as char);
83 i += 1;
84 }
85 out
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
93 fn no_flags_is_just_args() {
94 assert_eq!(redact_args(&["a".into(), "b".into()], &[]), "a b");
95 }
96
97 #[test]
98 fn bare_flag_kept() {
99 assert_eq!(
100 redact_args(&[], &[("full-page".into(), None)]),
101 "--full-page"
102 );
103 }
104
105 #[test]
106 fn token_flag_redacted() {
107 assert_eq!(
108 redact_args(&[], &[("token".into(), Some("abcdef0123456789".into()))],),
109 "--token=***",
110 );
111 }
112
113 #[test]
114 fn password_flag_redacted_case_insensitively() {
115 assert_eq!(
116 redact_args(&[], &[("Password".into(), Some("hunter2".into()))]),
117 "--Password=***",
118 );
119 }
120
121 #[test]
122 fn key_inside_name_triggers_redaction() {
123 assert_eq!(
124 redact_args(&[], &[("api-key".into(), Some("xxx".into()))]),
125 "--api-key=***",
126 );
127 }
128
129 #[test]
130 fn unrelated_flag_kept() {
131 assert_eq!(
132 redact_args(&[], &[("viewport".into(), Some("mobile".into()))]),
133 "--viewport=mobile",
134 );
135 }
136}