siderite_config/
redact.rs1use crate::error::is_secret_key;
10
11pub const REDACTED: &str = "***";
13
14pub fn redact_field(key: &str, value: &str) -> String {
24 if is_secret_key(key) {
25 REDACTED.to_owned()
26 } else {
27 redact_text(value)
28 }
29}
30
31pub fn redact_text(text: &str) -> String {
53 redact_pairs(&redact_urls(text))
54}
55
56fn redact_urls(text: &str) -> String {
57 let mut out = String::with_capacity(text.len());
58 let mut rest = text;
59 while let Some(pos) = rest.find("://") {
60 let (head, tail) = rest.split_at(pos);
61 out.push_str(head);
62 out.push_str("://");
63 let after = &tail[3..];
64 let region = &after[..region_end(after)];
65 let consumed = push_userinfo(&mut out, region);
66 rest = &after[consumed..];
67 }
68 out.push_str(rest);
69 out
70}
71
72fn region_end(after: &str) -> usize {
75 let line = after.find(['\n', '\r']).unwrap_or(after.len());
76 match after[..line].find("://") {
77 Some(next) => scheme_start(&after[..next]),
78 None => line,
79 }
80}
81
82fn scheme_start(head: &str) -> usize {
84 head.char_indices()
85 .rev()
86 .find(|(_, c)| !is_scheme_char(*c))
87 .map_or(0, |(i, c)| i + c.len_utf8())
88}
89
90fn is_scheme_char(c: char) -> bool {
91 c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')
92}
93
94fn push_userinfo(out: &mut String, region: &str) -> usize {
98 let Some(stop) = region.find(|c: char| {
99 let quote = matches!(c, '"' | '\'' | '`' | '<' | '>');
100 quote || matches!(c, ':' | '@' | '/') || c.is_whitespace()
101 }) else {
102 return 0;
103 };
104 match region.as_bytes()[stop] {
105 b'@' => {
106 out.push_str(REDACTED);
107 out.push('@');
108 stop + 1
109 }
110 b':' => match region.rfind('@') {
111 Some(at) if at > stop => {
112 out.push_str(®ion[..=stop]);
113 out.push_str(REDACTED);
114 out.push('@');
115 at + 1
116 }
117 _ => 0,
118 },
119 _ => 0,
120 }
121}
122
123fn redact_pairs(text: &str) -> String {
124 let mut out = String::with_capacity(text.len());
125 let mut copied = 0;
126 let mut search = 0;
127 while let Some(offset) = text[search..].find('=') {
128 let eq = search + offset;
129 search = eq + 1;
130 let key_start = text[..eq]
131 .char_indices()
132 .rev()
133 .find(|(_, c)| !is_key_char(*c))
134 .map_or(0, |(i, c)| i + c.len_utf8());
135 let key = &text[key_start..eq];
136 if key.is_empty() || !is_secret_key(key) {
137 continue;
138 }
139 let (start, end) = value_span(&text[eq + 1..]);
140 out.push_str(&text[copied..=eq + start]);
141 out.push_str(REDACTED);
142 copied = eq + 1 + end;
143 search = copied.max(search);
144 }
145 out.push_str(&text[copied..]);
146 out
147}
148
149fn is_key_char(c: char) -> bool {
150 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
151}
152
153fn value_span(value: &str) -> (usize, usize) {
156 let first = value.chars().next();
157 let Some(quote) = first.filter(|c| matches!(c, '\'' | '"')) else {
158 let end = value
159 .find(|c: char| c.is_whitespace() || matches!(c, ';' | '&'))
160 .unwrap_or(value.len());
161 return (0, end);
162 };
163 let mut escaped = false;
164 for (i, c) in value.char_indices().skip(1) {
165 if escaped {
166 escaped = false;
167 } else if c == '\\' {
168 escaped = true;
169 } else if c == quote {
170 return (1, i);
171 }
172 }
173 (1, value.len())
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn leaks(text: &str, secret: &str) {
181 let out = redact_text(text);
182 assert!(!out.contains(secret), "{secret} leaked from {out}");
183 }
184
185 #[test]
186 fn userinfo_ends_at_the_last_at_sign() {
187 for (text, secret) in [
188 ("postgres://u:p/ss@h/db", "p/ss"),
189 ("postgres://u:p?ss@h/db", "p?ss"),
190 ("postgres://u:p#ss@h/db", "p#ss"),
191 ("postgres://u:pa ss@h/db", "pa ss"),
192 ("postgres://u:pa\"ss@h/db", "pa\"ss"),
193 ("postgres://u:pa'ss@h/db", "pa'ss"),
194 ("postgres://u:p@ss@h/db", "p@ss"),
195 ] {
196 leaks(text, secret);
197 }
198 assert_eq!(
199 redact_text("postgres://u:p/s s@h:5432/db x"),
200 "postgres://u:***@h:5432/db x"
201 );
202 assert_eq!(redact_text("redis://tok@h"), "redis://***@h");
203 assert_eq!(redact_text("redis://:pw@h"), "redis://:***@h");
204 }
205
206 #[test]
207 fn url_lists_are_redacted_one_by_one() {
208 let text = "redis://h1:6379,redis://:pw1@h2,redis://u:pw2@h3";
209 assert_eq!(
210 redact_text(text),
211 "redis://h1:6379,redis://:***@h2,redis://u:***@h3"
212 );
213 assert_eq!(
214 redact_text("a postgres://u:p1@h/x\nb mysql://u:p2@h/y"),
215 "a postgres://u:***@h/x\nb mysql://u:***@h/y"
216 );
217 }
218
219 #[test]
220 fn urls_without_credentials_are_unchanged() {
221 for text in [
222 "no url here",
223 "sqlite::memory:",
224 "sqlite://app.db?mode=rwc",
225 "postgres://h:5432/db",
226 "https://example.com/a@b",
227 "expected a == b",
228 ] {
229 assert_eq!(redact_text(text), text);
230 }
231 }
232
233 #[test]
234 fn key_value_secrets_are_redacted() {
235 assert_eq!(
236 redact_text("host=h password=hunter2 user=u"),
237 "host=h password=*** user=u"
238 );
239 assert_eq!(
240 redact_text("Server=h;Pwd=hunter2;Database=d"),
241 "Server=h;Pwd=***;Database=d"
242 );
243 assert_eq!(
244 redact_text("host=h password='a b\\'c' port=1"),
245 "host=h password='***' port=1"
246 );
247 assert_eq!(
248 redact_text("https://x/blob?sv=1&sig=abc%2B&se=2"),
249 "https://x/blob?sv=1&sig=***&se=2"
250 );
251 assert_eq!(
252 redact_text("postgres://h/x?sslmode=require&password=p"),
253 "postgres://h/x?sslmode=require&password=***"
254 );
255 assert_eq!(redact_text("password=\"open"), "password=\"***");
256 let pairs = "signature=s token=";
257 assert_eq!(redact_text(pairs), "signature=*** token=***");
258 }
259}