Skip to main content

siderite_config/
redact.rs

1//! The single redaction helper: configuration errors, the configuration
2//! explanation and every CLI report use it.
3//!
4//! Secret-looking keys use [`is_secret_key`], the
5//! same rule the configuration errors apply. Redaction errs toward hiding
6//! too much: a false positive only hides part of a message, a false
7//! negative leaks a credential.
8
9use crate::error::is_secret_key;
10
11/// What a redacted value is replaced with.
12pub const REDACTED: &str = "***";
13
14/// Redact the value of a field called `key`: all of it when the key looks
15/// secret, otherwise any credentials inside it (see [`redact_text`]).
16///
17/// Args:
18///   key: The field name.
19///   value: The field value.
20///
21/// Returns:
22///   The value safe to print.
23pub 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
31/// Redact every credential found in `text`.
32///
33/// Two passes run in order:
34///
35/// 1. **URLs.** In each `scheme://` token the password of the userinfo
36///    (or the whole userinfo when it has no `user:` part) becomes
37///    [`REDACTED`]. The userinfo ends at the *last* `@` before the end
38///    of the line or the next `scheme://`, so a password holding an
39///    unencoded `/ ? # @`, space or quote is still hidden. A comma
40///    separated list of URLs is redacted URL by URL.
41/// 2. **`key=value` pairs** anywhere in the text: URL query parameters,
42///    libpq DSNs (`password=x`), ADO strings (`;Pwd=x;`) and the like.
43///    When `is_secret_key(key)` holds, the value becomes [`REDACTED`].
44///    A quoted value is redacted up to its closing quote; an unquoted
45///    one up to whitespace, `;` or `&`.
46///
47/// Args:
48///   text: Free text that may hold connection strings.
49///
50/// Returns:
51///   The text with every recognised secret replaced.
52pub 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
72/// Where the text belonging to one URL ends: the end of the line or the
73/// start of the next `scheme://`, whichever comes first.
74fn 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
82/// Byte index where the URL scheme ending `head` starts.
83fn 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
94/// Write the redacted userinfo of `region` (the text after `://`) and
95/// return how many bytes of it were consumed; `0` when there is no
96/// userinfo.
97fn 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(&region[..=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
153/// The `(start, end)` byte span of the secret in `value`, the text after
154/// `=`. A quoted value keeps its quotes outside the span.
155fn 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}