Skip to main content

cellos_core/
redaction.rs

1//! Redact secrets from strings intended for **operator logs** (not a crypto primitive).
2
3use url::Url;
4
5/// Remove URL username/password (userinfo) so connection strings can be logged safely.
6///
7/// Uses [`Url::parse`] when possible (handles IPv6, encoded passwords, etc.). If parsing fails,
8/// applies a conservative heuristic: the first `@` after `://` is treated as separating userinfo
9/// from host.
10pub fn redact_url_credentials_for_logs(input: &str) -> String {
11    if let Ok(mut u) = Url::parse(input) {
12        let has_user = !u.username().is_empty();
13        let has_pass = u.password().is_some();
14        if has_user || has_pass {
15            let _ = u.set_username("");
16            let _ = u.set_password(None);
17            return u.to_string();
18        }
19        // Url::parse succeeded but found no credentials on the first URL.
20        // Fall through to the heuristic so credentials in a trailing
21        // URL (multi-URL log lines) are still caught.
22    }
23    heuristic_redact_userinfo(input)
24}
25
26/// Replace exact occurrences of `raw_connection_url` in `err_text` with
27/// [`redact_url_credentials_for_logs`] output when they differ.
28///
29/// Some NATS/async clients echo the full connection string (including userinfo) in `Display`
30/// errors — use this before logging or wrapping in [`crate::CellosError`].
31pub fn redact_url_if_echoed_in_text(err_text: &str, raw_connection_url: &str) -> String {
32    let redacted = redact_url_credentials_for_logs(raw_connection_url);
33    if raw_connection_url == redacted || !err_text.contains(raw_connection_url) {
34        return err_text.to_string();
35    }
36    err_text.replace(raw_connection_url, &redacted)
37}
38
39fn heuristic_redact_userinfo(s: &str) -> String {
40    let Some(pos) = s.find("://") else {
41        return s.to_string();
42    };
43    let after_scheme = &s[pos + 3..];
44    let Some(at_rel) = after_scheme.find('@') else {
45        return s.to_string();
46    };
47    // Skip the `@` itself so the output is idempotent (no stray `@` for the
48    // next pass to re-process as a new userinfo boundary).
49    format!("{}<redacted>{}", &s[..pos + 3], &after_scheme[at_rel + 1..])
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn leaves_bare_nats_url_unchanged() {
58        assert_eq!(
59            redact_url_credentials_for_logs("nats://127.0.0.1:4222"),
60            "nats://127.0.0.1:4222"
61        );
62    }
63
64    #[test]
65    fn strips_user_password_nats() {
66        let out = redact_url_credentials_for_logs("nats://alice:secret@broker.internal:4222");
67        assert!(!out.contains("alice"), "{out}");
68        assert!(!out.contains("secret"), "{out}");
69        assert!(out.contains("broker.internal"), "{out}");
70    }
71
72    #[test]
73    fn heuristic_when_not_parseable() {
74        let out = heuristic_redact_userinfo("nats://x:y@host:1/extra");
75        assert_eq!(out, "nats://<redacted>host:1/extra");
76    }
77
78    #[test]
79    fn redacts_echoed_url_in_error_blob() {
80        let raw = "nats://alice:hunter2@10.0.0.5:4222";
81        let err = format!("connection refused: {raw} (try again)");
82        let out = redact_url_if_echoed_in_text(&err, raw);
83        assert!(!out.contains("alice"), "{out}");
84        assert!(!out.contains("hunter2"), "{out}");
85        assert!(out.contains("10.0.0.5"), "{out}");
86    }
87
88    #[test]
89    fn leaves_error_unchanged_when_url_not_echoed() {
90        let err = "IO error: connection refused";
91        assert_eq!(
92            redact_url_if_echoed_in_text(err, "nats://u:p@h:1"),
93            err.to_string()
94        );
95    }
96}