1use std::path::PathBuf;
2
3const BODY_EXCERPT_LIMIT: usize = 512;
7
8const REDACTED: &str = "***";
10
11#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum Error {
18 #[error("could not read audio file {path}: {source}")]
19 ReadFile {
20 path: PathBuf,
21 source: std::io::Error,
22 },
23
24 #[error("could not build the HTTP client: {source}")]
27 Client { source: reqwest::Error },
28
29 #[error("request to {url} failed: {source}")]
33 Http { url: String, source: reqwest::Error },
34
35 #[error("endpoint returned HTTP {status}: {body}")]
36 Api { status: u16, body: String },
37
38 #[error(
45 "endpoint redirected (HTTP {status}{})—point --endpoint at the final URL instead; \
46 redirects are not followed, because following one would either upload nothing or \
47 fabricate a transcript from the wrong response",
48 .location.as_ref().map(|l| format!(" to {l}")).unwrap_or_default()
49 )]
50 Redirect {
51 status: u16,
52 location: Option<String>,
53 },
54
55 #[error("--endpoint {url:?} is not a usable URL: {reason}")]
57 InvalidEndpoint { url: String, reason: String },
58
59 #[error(
61 "endpoint response is too large ({got} bytes, limit {limit}); a transcript is \
62 normally a few kilobytes, so this endpoint is probably not returning one"
63 )]
64 ResponseTooLarge { got: u64, limit: u64 },
65
66 #[error("could not parse endpoint response (expected a JSON object with a string \"text\" field), got: {body}")]
67 BadResponse { body: String },
68}
69
70pub(crate) fn redact_url(raw: &str) -> String {
79 let Ok(mut parsed) = url::Url::parse(raw) else {
80 return raw.to_string();
81 };
82 if parsed.username().is_empty() && parsed.password().is_none() {
83 return raw.to_string();
84 }
85 let _ = parsed.set_username(REDACTED);
88 let _ = parsed.set_password(Some(REDACTED));
89 parsed.to_string()
90}
91
92pub(crate) fn scrub_secret(text: String, secret: Option<&str>) -> String {
98 match secret {
99 Some(s) if !s.is_empty() => text.replace(s, REDACTED),
100 _ => text,
101 }
102}
103
104pub(crate) fn excerpt(body: String) -> String {
109 if body.len() <= BODY_EXCERPT_LIMIT {
110 return body;
111 }
112 let cut = (0..=BODY_EXCERPT_LIMIT)
113 .rev()
114 .find(|&i| body.is_char_boundary(i))
115 .unwrap_or(0);
116 format!("{}… ({} bytes total)", &body[..cut], body.len())
117}
118
119pub type Result<T> = std::result::Result<T, Error>;
120
121#[cfg(test)]
122mod tests {
123 use super::{excerpt, redact_url, scrub_secret, BODY_EXCERPT_LIMIT};
124
125 #[test]
126 fn a_url_password_never_survives_redaction() {
127 let out = redact_url("https://bob:hunter2@host/v1/audio/transcriptions");
128 assert!(!out.contains("hunter2"), "{out}");
129 assert!(!out.contains("bob"), "{out}");
130 assert!(
131 out.contains("host"),
132 "the host is what makes it diagnosable: {out}"
133 );
134 }
135
136 #[test]
137 fn a_username_alone_is_still_redacted() {
138 let out = redact_url("https://bob@host/v1");
139 assert!(!out.contains("bob"), "{out}");
140 }
141
142 #[test]
143 fn a_url_without_credentials_is_left_alone() {
144 let plain = "https://api.cohere.com/v2/audio/transcriptions";
145 assert_eq!(redact_url(plain), plain);
146 }
147
148 #[test]
151 fn an_unparseable_url_passes_through() {
152 assert_eq!(redact_url("not-a-url"), "not-a-url");
153 assert_eq!(redact_url(""), "");
154 }
155
156 #[test]
157 fn a_reflected_api_key_is_scrubbed_from_a_body() {
158 let body = r#"{"error": "bad key: Bearer sk-LEAKME-9999"}"#.to_string();
159 let out = scrub_secret(body, Some("sk-LEAKME-9999"));
160 assert!(!out.contains("sk-LEAKME-9999"), "{out}");
161 assert!(out.contains("bad key"), "the diagnosis survives: {out}");
162 }
163
164 #[test]
165 fn scrubbing_without_a_key_changes_nothing() {
166 let body = "plain failure".to_string();
167 assert_eq!(scrub_secret(body.clone(), None), body);
168 assert_eq!(scrub_secret(body.clone(), Some("")), body);
169 }
170
171 #[test]
172 fn a_short_body_is_untouched() {
173 assert_eq!(excerpt("unauthorized".into()), "unauthorized");
174 }
175
176 #[test]
177 fn a_long_body_is_cut_and_marked() {
178 let out = excerpt("x".repeat(BODY_EXCERPT_LIMIT * 3));
179 assert!(
180 out.len() < BODY_EXCERPT_LIMIT * 2,
181 "still huge: {}",
182 out.len()
183 );
184 assert!(out.contains("bytes total"), "{out}");
185 }
186
187 #[test]
188 fn cutting_multibyte_arabic_does_not_panic() {
189 let out = excerpt("ط".repeat(BODY_EXCERPT_LIMIT));
191 assert!(out.contains("bytes total"), "{out}");
192 }
193}