Skip to main content

romm_api/
log_redact.rs

1//! Redaction helpers for tracing and debug output (Gap 6: secrets never in logs).
2
3use crate::error::ApiError;
4
5/// Strip URL userinfo and query strings before verbose HTTP logging.
6pub fn redact_url_for_log(url: &str) -> String {
7    let (base, had_query) = match url.split_once('?') {
8        Some((b, _)) => (b, true),
9        None => (url, false),
10    };
11
12    let redacted_base = if let Some(at_idx) = base.find('@') {
13        if let Some(scheme_end) = base.find("://") {
14            format!(
15                "{}<redacted>@{}",
16                &base[..scheme_end + 3],
17                &base[at_idx + 1..]
18            )
19        } else {
20            format!("<redacted>@{}", &base[at_idx + 1..])
21        }
22    } else {
23        base.to_string()
24    };
25
26    if had_query {
27        format!("{redacted_base}?<redacted>")
28    } else {
29        redacted_base
30    }
31}
32
33/// Format a [`RommError`] for logs, redacting API response bodies.
34pub fn redact_romm_error_for_log(err: &crate::error::RommError) -> String {
35    match err {
36        crate::error::RommError::Api(api) => api.redacted_for_log(),
37        other => other.to_string(),
38    }
39}
40
41/// Format an `anyhow::Error` for logs, redacting [`ApiError`] response bodies in the chain.
42pub fn redact_anyhow_for_log(err: &anyhow::Error) -> String {
43    if let Some(api) = err.downcast_ref::<ApiError>() {
44        return api.redacted_for_log();
45    }
46    if let Some(romm) = err.downcast_ref::<crate::error::RommError>() {
47        return redact_romm_error_for_log(romm);
48    }
49    for cause in err.chain() {
50        if let Some(api) = cause.downcast_ref::<ApiError>() {
51            return api.redacted_for_log();
52        }
53        if let Some(romm) = cause.downcast_ref::<crate::error::RommError>() {
54            return redact_romm_error_for_log(romm);
55        }
56    }
57    err.to_string()
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::error::ApiError;
64    use std::io::Write;
65    use std::sync::{Arc, Mutex};
66    struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
67
68    impl Write for CaptureWriter {
69        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
70            self.0.lock().unwrap().extend_from_slice(buf);
71            Ok(buf.len())
72        }
73
74        fn flush(&mut self) -> std::io::Result<()> {
75            Ok(())
76        }
77    }
78
79    fn capture_logs<F: FnOnce()>(f: F) -> String {
80        let buf = Arc::new(Mutex::new(Vec::new()));
81        let writer_buf = buf.clone();
82        let subscriber = tracing_subscriber::fmt()
83            .with_writer(move || CaptureWriter(writer_buf.clone()))
84            .with_ansi(false)
85            .without_time()
86            .finish();
87        let _guard = tracing::subscriber::set_default(subscriber);
88        f();
89        let output = buf.lock().unwrap().clone();
90        String::from_utf8_lossy(&output).into_owned()
91    }
92
93    #[test]
94    fn redact_url_strips_userinfo() {
95        let url = "https://user:secret@romm.example/api/roms";
96        let out = redact_url_for_log(url);
97        assert!(!out.contains("secret"));
98        assert!(!out.contains("user"));
99        assert!(out.contains("romm.example/api/roms"));
100    }
101
102    #[test]
103    fn redact_url_strips_query_string() {
104        let url = "https://romm.example/api?token=abc123";
105        let out = redact_url_for_log(url);
106        assert!(!out.contains("abc123"));
107        assert!(out.ends_with("?<redacted>"));
108        assert!(out.contains("romm.example/api"));
109    }
110
111    #[test]
112    fn redact_url_leaves_clean_url_unchanged() {
113        let url = "https://romm.example/api/roms";
114        assert_eq!(redact_url_for_log(url), url);
115    }
116
117    #[test]
118    fn api_error_redacted_for_log_omits_body() {
119        let err = ApiError::Unauthorized {
120            body: "token=abc123".to_string(),
121        };
122        let out = err.redacted_for_log();
123        assert!(!out.contains("abc123"));
124        assert!(out.contains("401"));
125    }
126
127    #[test]
128    fn tracing_verbose_url_does_not_leak_credentials() {
129        let url = redact_url_for_log("https://admin:sekrit@host.example/dl");
130        let output = capture_logs(|| {
131            tracing::info!("[romm-cli] GET {} -> 200", url);
132        });
133        assert!(!output.contains("sekrit"));
134        assert!(!output.contains("admin"));
135    }
136
137    #[test]
138    fn tracing_api_error_warn_does_not_leak_body() {
139        let err = ApiError::Unauthorized {
140            body: "bearer leaked-token-value".to_string(),
141        };
142        let msg = err.redacted_for_log();
143        let output = capture_logs(|| {
144            tracing::warn!("preflight failed: {msg}");
145        });
146        assert!(!output.contains("leaked-token-value"));
147    }
148
149    #[test]
150    fn redact_anyhow_handles_api_error_in_chain() {
151        let api = ApiError::ClientError {
152            status: 400,
153            body: "secret-body".to_string(),
154        };
155        let err = anyhow::Error::from(api);
156        let out = redact_anyhow_for_log(&err);
157        assert!(!out.contains("secret-body"));
158        assert!(out.contains("400"));
159    }
160}