use std::path::PathBuf;
const BODY_EXCERPT_LIMIT: usize = 512;
const REDACTED: &str = "***";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("could not read audio file {path}: {source}")]
ReadFile {
path: PathBuf,
source: std::io::Error,
},
#[error("could not build the HTTP client: {source}")]
Client { source: reqwest::Error },
#[error("request to {url} failed: {source}")]
Http { url: String, source: reqwest::Error },
#[error("endpoint returned HTTP {status}: {body}")]
Api { status: u16, body: String },
#[error(
"endpoint redirected (HTTP {status}{})—point --endpoint at the final URL instead; \
redirects are not followed, because following one would either upload nothing or \
fabricate a transcript from the wrong response",
.location.as_ref().map(|l| format!(" to {l}")).unwrap_or_default()
)]
Redirect {
status: u16,
location: Option<String>,
},
#[error("--endpoint {url:?} is not a usable URL: {reason}")]
InvalidEndpoint { url: String, reason: String },
#[error(
"endpoint response is too large ({got} bytes, limit {limit}); a transcript is \
normally a few kilobytes, so this endpoint is probably not returning one"
)]
ResponseTooLarge { got: u64, limit: u64 },
#[error("could not parse endpoint response (expected a JSON object with a string \"text\" field), got: {body}")]
BadResponse { body: String },
}
pub(crate) fn redact_url(raw: &str) -> String {
let Ok(mut parsed) = url::Url::parse(raw) else {
return raw.to_string();
};
if parsed.username().is_empty() && parsed.password().is_none() {
return raw.to_string();
}
let _ = parsed.set_username(REDACTED);
let _ = parsed.set_password(Some(REDACTED));
parsed.to_string()
}
pub(crate) fn scrub_secret(text: String, secret: Option<&str>) -> String {
match secret {
Some(s) if !s.is_empty() => text.replace(s, REDACTED),
_ => text,
}
}
pub(crate) fn excerpt(body: String) -> String {
if body.len() <= BODY_EXCERPT_LIMIT {
return body;
}
let cut = (0..=BODY_EXCERPT_LIMIT)
.rev()
.find(|&i| body.is_char_boundary(i))
.unwrap_or(0);
format!("{}… ({} bytes total)", &body[..cut], body.len())
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::{excerpt, redact_url, scrub_secret, BODY_EXCERPT_LIMIT};
#[test]
fn a_url_password_never_survives_redaction() {
let out = redact_url("https://bob:hunter2@host/v1/audio/transcriptions");
assert!(!out.contains("hunter2"), "{out}");
assert!(!out.contains("bob"), "{out}");
assert!(
out.contains("host"),
"the host is what makes it diagnosable: {out}"
);
}
#[test]
fn a_username_alone_is_still_redacted() {
let out = redact_url("https://bob@host/v1");
assert!(!out.contains("bob"), "{out}");
}
#[test]
fn a_url_without_credentials_is_left_alone() {
let plain = "https://api.cohere.com/v2/audio/transcriptions";
assert_eq!(redact_url(plain), plain);
}
#[test]
fn an_unparseable_url_passes_through() {
assert_eq!(redact_url("not-a-url"), "not-a-url");
assert_eq!(redact_url(""), "");
}
#[test]
fn a_reflected_api_key_is_scrubbed_from_a_body() {
let body = r#"{"error": "bad key: Bearer sk-LEAKME-9999"}"#.to_string();
let out = scrub_secret(body, Some("sk-LEAKME-9999"));
assert!(!out.contains("sk-LEAKME-9999"), "{out}");
assert!(out.contains("bad key"), "the diagnosis survives: {out}");
}
#[test]
fn scrubbing_without_a_key_changes_nothing() {
let body = "plain failure".to_string();
assert_eq!(scrub_secret(body.clone(), None), body);
assert_eq!(scrub_secret(body.clone(), Some("")), body);
}
#[test]
fn a_short_body_is_untouched() {
assert_eq!(excerpt("unauthorized".into()), "unauthorized");
}
#[test]
fn a_long_body_is_cut_and_marked() {
let out = excerpt("x".repeat(BODY_EXCERPT_LIMIT * 3));
assert!(
out.len() < BODY_EXCERPT_LIMIT * 2,
"still huge: {}",
out.len()
);
assert!(out.contains("bytes total"), "{out}");
}
#[test]
fn cutting_multibyte_arabic_does_not_panic() {
let out = excerpt("ط".repeat(BODY_EXCERPT_LIMIT));
assert!(out.contains("bytes total"), "{out}");
}
}