use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
pub const SECRET_PARAMS: &[&str] = &["apikey", "api_key", "key", "token", "access_token"];
pub const SECRET_HEADERS: &[&str] = &["authorization", "x-api-key", "cookie", "set-cookie"];
fn hex_sha256(data: &[u8]) -> String {
Sha256::digest(data)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn clean_url(url: &str) -> String {
let Ok(mut parsed) = reqwest::Url::parse(url) else {
return format!("invalid-url:{}", hex_sha256(url.as_bytes()));
};
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.set_fragment(None);
let mut pairs: Vec<(String, String)> = parsed
.query_pairs()
.filter(|(k, _)| !SECRET_PARAMS.contains(&k.to_lowercase().as_str()))
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
pairs.sort();
if pairs.is_empty() {
parsed.set_query(None);
} else {
parsed.query_pairs_mut().clear().extend_pairs(pairs);
}
parsed.to_string()
}
pub fn body_sha256(body: Option<&str>) -> String {
hex_sha256(body.unwrap_or("").as_bytes())
}
pub fn fixture_key(method: &str, url: &str, body: Option<&str>) -> String {
format!(
"{} {} {}",
method.to_uppercase(),
clean_url(url),
body_sha256(body)
)
}
pub fn fixture_path(dir: &Path, method: &str, url: &str, body: Option<&str>) -> PathBuf {
let host = reqwest::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_else(|| "unknown".to_string());
dir.join(host).join(format!(
"{}.json",
hex_sha256(fixture_key(method, url, body).as_bytes())
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_url_removes_secret_params_in_any_case() {
let u = clean_url(
"https://api.etherscan.io/v2/api?module=account&ApiKey=SECRET123&address=0x1",
);
assert!(!u.contains("SECRET123"), "{u}");
assert!(!u.to_lowercase().contains("apikey"), "{u}");
assert_eq!(
u,
"https://api.etherscan.io/v2/api?address=0x1&module=account"
);
}
#[test]
fn test_each_secret_param_is_removed() {
for name in SECRET_PARAMS {
let u = clean_url(&format!("https://h.io/p?{name}=SECRET&a=1"));
assert!(!u.contains("SECRET"), "{name}: {u}");
assert_eq!(u, "https://h.io/p?a=1", "{name}");
}
}
#[test]
fn test_encoded_secret_name_is_removed() {
let u = clean_url("https://h.io/p?%61pikey=S&a=1");
assert!(!u.contains("S&") && !u.contains("=S"), "{u}");
assert_eq!(u, "https://h.io/p?a=1");
}
#[test]
fn test_repeated_secret_param_is_removed() {
let u = clean_url("https://h.io/p?token=a&token=b&a=1");
assert!(!u.contains("=a") && !u.contains("=b"), "{u}");
assert_eq!(u, "https://h.io/p?a=1");
}
#[test]
fn test_only_secret_params_leave_no_trailing_query_mark() {
assert_eq!(clean_url("https://h.io/p?apikey=SECRET"), "https://h.io/p");
}
#[test]
fn test_userinfo_is_removed() {
let u = clean_url("https://user:pass@h.io/p");
assert!(!u.contains("user") && !u.contains("pass"), "{u}");
assert_eq!(u, "https://h.io/p");
}
#[test]
fn test_fragment_is_removed() {
let u = clean_url("https://h.io/p#access_token=X");
assert!(!u.contains('X'), "{u}");
assert_eq!(u, "https://h.io/p");
}
#[test]
fn test_unparseable_url_fails_closed() {
let u = clean_url("not a url?apikey=SECRET");
assert!(u.starts_with("invalid-url:"), "{u}");
assert!(!u.contains("SECRET"), "{u}");
}
#[test]
fn test_absent_body_hashes_like_empty_body() {
assert_eq!(body_sha256(None), body_sha256(Some("")));
assert_eq!(
body_sha256(None),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn test_param_order_does_not_change_the_key() {
let a = fixture_key("GET", "https://x.io/p?b=2&a=1", None);
let b = fixture_key("GET", "https://x.io/p?a=1&b=2", None);
assert_eq!(a, b);
}
#[test]
fn test_body_changes_the_key() {
let a = fixture_key("POST", "https://rpc.io/", Some(r#"{"id":1}"#));
let b = fixture_key("POST", "https://rpc.io/", Some(r#"{"id":2}"#));
assert_ne!(a, b);
}
#[test]
fn test_fixture_path_groups_by_host() {
let p = fixture_path(
std::path::Path::new("/f"),
"GET",
"https://api.binance.com/api/v3/depth?symbol=X",
None,
);
assert!(p.starts_with("/f/api.binance.com"), "{}", p.display());
assert_eq!(p.extension().unwrap(), "json");
}
}