Skip to main content

bpi_rs/transport/
request.rs

1use reqwest::{Method, RequestBuilder, Url};
2
3/// Metadata safe to emit in request logs.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct RequestMetadata {
6    pub method: Method,
7    pub endpoint: String,
8    pub sanitized_url: String,
9}
10
11impl RequestMetadata {
12    /// Builds safe request metadata from a cloneable reqwest request builder.
13    pub fn from_builder(builder: &RequestBuilder, endpoint: impl Into<String>) -> Option<Self> {
14        let request = builder.try_clone()?.build().ok()?;
15
16        Some(Self {
17            method: request.method().clone(),
18            endpoint: endpoint.into(),
19            sanitized_url: sanitize_url_for_logging(request.url().as_str()),
20        })
21    }
22}
23
24/// Returns a URL string safe for logs.
25pub fn sanitize_url_for_logging(url: &str) -> String {
26    let Ok(mut parsed) = Url::parse(url) else {
27        return "<invalid-url>".to_string();
28    };
29
30    let safe_pairs = parsed
31        .query_pairs()
32        .filter(|(key, _)| !is_sensitive_query_key(key))
33        .map(|(key, value)| (key.into_owned(), value.into_owned()))
34        .collect::<Vec<_>>();
35
36    parsed.set_query(None);
37
38    if !safe_pairs.is_empty() {
39        let query = safe_pairs
40            .into_iter()
41            .map(|(key, value)| format!("{key}={value}"))
42            .collect::<Vec<_>>()
43            .join("&");
44        parsed.set_query(Some(&query));
45    }
46
47    parsed.to_string()
48}
49
50/// Returns a header pair safe for logs, or `None` when the header is sensitive.
51pub fn sanitize_header_for_logging(name: &str, value: &str) -> Option<(String, String)> {
52    if is_sensitive_header_name(name) {
53        return None;
54    }
55
56    Some((name.to_string(), value.to_string()))
57}
58
59fn is_sensitive_query_key(key: &str) -> bool {
60    matches!(
61        key.to_ascii_lowercase().as_str(),
62        "sessdata"
63            | "dedeuserid"
64            | "dedeuserid__ckmd5"
65            | "bili_jct"
66            | "csrf"
67            | "csrf_token"
68            | "w_rid"
69            | "access_key"
70            | "token"
71            | "cookie"
72    )
73}
74
75fn is_sensitive_header_name(name: &str) -> bool {
76    matches!(
77        name.to_ascii_lowercase().as_str(),
78        "cookie" | "authorization" | "proxy-authorization" | "set-cookie"
79    )
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn sanitize_url_for_logging_removes_sensitive_query_values() {
88        let sanitized = sanitize_url_for_logging(
89            "https://api.bilibili.com/x/test?mid=1&SESSDATA=secret&csrf=token&bili_jct=token&w_rid=signed",
90        );
91
92        assert_eq!(sanitized, "https://api.bilibili.com/x/test?mid=1");
93    }
94
95    #[test]
96    fn sanitize_url_for_logging_keeps_path_when_all_query_values_are_sensitive() {
97        let sanitized =
98            sanitize_url_for_logging("https://api.bilibili.com/x/test?SESSDATA=secret&csrf=token");
99
100        assert_eq!(sanitized, "https://api.bilibili.com/x/test");
101    }
102
103    #[test]
104    fn sanitize_url_for_logging_handles_invalid_url_without_panicking() {
105        let sanitized = sanitize_url_for_logging("not a url with SESSDATA=secret");
106
107        assert_eq!(sanitized, "<invalid-url>");
108    }
109
110    #[test]
111    fn sanitize_header_for_logging_removes_raw_cookie_header() {
112        let sanitized = sanitize_header_for_logging("Cookie", "SESSDATA=secret; bili_jct=token");
113
114        assert!(sanitized.is_none());
115    }
116
117    #[test]
118    fn request_metadata_from_builder_sanitizes_url_and_preserves_method() {
119        let client = reqwest::Client::new();
120        let builder = client.post("https://api.bilibili.com/x/test?mid=1&csrf=secret&w_rid=signed");
121
122        let metadata = RequestMetadata::from_builder(&builder, "test.endpoint").unwrap();
123
124        assert_eq!(metadata.method, Method::POST);
125        assert_eq!(metadata.endpoint, "test.endpoint");
126        assert_eq!(
127            metadata.sanitized_url,
128            "https://api.bilibili.com/x/test?mid=1"
129        );
130    }
131}