Skip to main content

scope/http/replay/
key.rs

1//! Fixture keys: a request's identity with secrets removed.
2
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5
6/// Query parameter names (lowercase) whose values are removed from fixture keys.
7pub const SECRET_PARAMS: &[&str] = &["apikey", "api_key", "key", "token", "access_token"];
8/// Header names (lowercase) that are never written to fixtures.
9pub 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
18/// The URL with credentials, fragment and secret query parameters removed,
19/// and the rest of the query sorted. An unparseable URL becomes a hash of
20/// itself, so a secret inside it never reaches the key.
21pub 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
42/// Hex SHA-256 of the request body.
43pub fn body_sha256(body: Option<&str>) -> String {
44    hex_sha256(body.unwrap_or("").as_bytes())
45}
46
47/// The identity of a request.
48pub 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
57/// Where the fixture for a request lives.
58pub 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}