1use crate::error::ApiError;
4
5pub 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
33pub 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
41pub 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}