1use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5
6pub const SECRET_PARAMS: &[&str] = &["apikey", "api_key", "key", "token", "access_token"];
8pub const SECRET_HEADERS: &[&str] = &["authorization", "x-api-key", "cookie", "set-cookie"];
10
11fn hex_sha256(data: &[u8]) -> String {
12 Sha256::digest(data)
13 .iter()
14 .map(|b| format!("{b:02x}"))
15 .collect()
16}
17
18pub fn clean_url(url: &str) -> String {
22 let Ok(mut parsed) = reqwest::Url::parse(url) else {
23 return format!("invalid-url:{}", hex_sha256(url.as_bytes()));
24 };
25 let _ = parsed.set_username("");
26 let _ = parsed.set_password(None);
27 parsed.set_fragment(None);
28 let mut pairs: Vec<(String, String)> = parsed
29 .query_pairs()
30 .filter(|(k, _)| !SECRET_PARAMS.contains(&k.to_lowercase().as_str()))
31 .map(|(k, v)| (k.into_owned(), v.into_owned()))
32 .collect();
33 pairs.sort();
34 if pairs.is_empty() {
35 parsed.set_query(None);
36 } else {
37 parsed.query_pairs_mut().clear().extend_pairs(pairs);
38 }
39 parsed.to_string()
40}
41
42pub fn body_sha256(body: Option<&str>) -> String {
44 hex_sha256(body.unwrap_or("").as_bytes())
45}
46
47pub fn fixture_key(method: &str, url: &str, body: Option<&str>) -> String {
49 format!(
50 "{} {} {}",
51 method.to_uppercase(),
52 clean_url(url),
53 body_sha256(body)
54 )
55}
56
57pub fn fixture_path(dir: &Path, method: &str, url: &str, body: Option<&str>) -> PathBuf {
59 let host = reqwest::Url::parse(url)
60 .ok()
61 .and_then(|u| u.host_str().map(str::to_string))
62 .unwrap_or_else(|| "unknown".to_string());
63 dir.join(host).join(format!(
64 "{}.json",
65 hex_sha256(fixture_key(method, url, body).as_bytes())
66 ))
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn test_clean_url_removes_secret_params_in_any_case() {
75 let u = clean_url(
76 "https://api.etherscan.io/v2/api?module=account&ApiKey=SECRET123&address=0x1",
77 );
78 assert!(!u.contains("SECRET123"), "{u}");
79 assert!(!u.to_lowercase().contains("apikey"), "{u}");
80 assert_eq!(
81 u,
82 "https://api.etherscan.io/v2/api?address=0x1&module=account"
83 );
84 }
85
86 #[test]
87 fn test_each_secret_param_is_removed() {
88 for name in SECRET_PARAMS {
89 let u = clean_url(&format!("https://h.io/p?{name}=SECRET&a=1"));
90 assert!(!u.contains("SECRET"), "{name}: {u}");
91 assert_eq!(u, "https://h.io/p?a=1", "{name}");
92 }
93 }
94
95 #[test]
96 fn test_encoded_secret_name_is_removed() {
97 let u = clean_url("https://h.io/p?%61pikey=S&a=1");
98 assert!(!u.contains("S&") && !u.contains("=S"), "{u}");
99 assert_eq!(u, "https://h.io/p?a=1");
100 }
101
102 #[test]
103 fn test_repeated_secret_param_is_removed() {
104 let u = clean_url("https://h.io/p?token=a&token=b&a=1");
105 assert!(!u.contains("=a") && !u.contains("=b"), "{u}");
106 assert_eq!(u, "https://h.io/p?a=1");
107 }
108
109 #[test]
110 fn test_only_secret_params_leave_no_trailing_query_mark() {
111 assert_eq!(clean_url("https://h.io/p?apikey=SECRET"), "https://h.io/p");
112 }
113
114 #[test]
115 fn test_userinfo_is_removed() {
116 let u = clean_url("https://user:pass@h.io/p");
117 assert!(!u.contains("user") && !u.contains("pass"), "{u}");
118 assert_eq!(u, "https://h.io/p");
119 }
120
121 #[test]
122 fn test_fragment_is_removed() {
123 let u = clean_url("https://h.io/p#access_token=X");
124 assert!(!u.contains('X'), "{u}");
125 assert_eq!(u, "https://h.io/p");
126 }
127
128 #[test]
129 fn test_unparseable_url_fails_closed() {
130 let u = clean_url("not a url?apikey=SECRET");
131 assert!(u.starts_with("invalid-url:"), "{u}");
132 assert!(!u.contains("SECRET"), "{u}");
133 }
134
135 #[test]
136 fn test_absent_body_hashes_like_empty_body() {
137 assert_eq!(body_sha256(None), body_sha256(Some("")));
138 assert_eq!(
139 body_sha256(None),
140 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
141 );
142 }
143
144 #[test]
145 fn test_param_order_does_not_change_the_key() {
146 let a = fixture_key("GET", "https://x.io/p?b=2&a=1", None);
147 let b = fixture_key("GET", "https://x.io/p?a=1&b=2", None);
148 assert_eq!(a, b);
149 }
150
151 #[test]
152 fn test_body_changes_the_key() {
153 let a = fixture_key("POST", "https://rpc.io/", Some(r#"{"id":1}"#));
154 let b = fixture_key("POST", "https://rpc.io/", Some(r#"{"id":2}"#));
155 assert_ne!(a, b);
156 }
157
158 #[test]
159 fn test_fixture_path_groups_by_host() {
160 let p = fixture_path(
161 std::path::Path::new("/f"),
162 "GET",
163 "https://api.binance.com/api/v3/depth?symbol=X",
164 None,
165 );
166 assert!(p.starts_with("/f/api.binance.com"), "{}", p.display());
167 assert_eq!(p.extension().unwrap(), "json");
168 }
169}