use crate::error::{Error, Result};
use serde::de::DeserializeOwned;
use std::sync::RwLock;
use std::time::Duration;
const USER_AGENT: &str = "(weathervane, https://gitlab.com/vintagetechie/weathervane)";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const TCP_KEEPALIVE: Duration = Duration::from_secs(30);
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
static CLIENT: RwLock<Option<reqwest::Client>> = RwLock::new(None);
pub(crate) fn http_client() -> Result<reqwest::Client> {
if let Some(client) = CLIENT.read().unwrap().as_ref() {
return Ok(client.clone());
}
let mut slot = CLIENT.write().unwrap();
if let Some(client) = slot.as_ref() {
return Ok(client.clone());
}
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(REQUEST_TIMEOUT)
.tcp_keepalive(Some(TCP_KEEPALIVE))
.pool_idle_timeout(Some(POOL_IDLE_TIMEOUT))
.pool_max_idle_per_host(0)
.build()
.map_err(|e| Error::HttpClient(e.to_string()))?;
*slot = Some(client.clone());
Ok(client)
}
pub fn reset_http_client() {
*CLIENT.write().unwrap() = None;
}
#[allow(dead_code)]
pub(crate) fn sanitize_url(url: &str) -> String {
if let Some(i) = url.find('?') {
format!("{}?[redacted]", &url[..i])
} else {
url.to_string()
}
}
pub(crate) async fn get_json<T: DeserializeOwned>(url: &str, ctx: &str) -> Option<T> {
http_client()
.ok()?
.get(url)
.send()
.await
.map_err(|e| tracing::debug!("{ctx} request failed: {}", e.without_url()))
.ok()?
.error_for_status()
.map_err(|e| tracing::debug!("{ctx} status error: {}", e.without_url()))
.ok()?
.json::<T>()
.await
.map_err(|e| tracing::debug!("{ctx} parse failed: {}", e.without_url()))
.ok()
}
pub(crate) async fn get_text(url: &str, ctx: &str) -> Option<String> {
http_client()
.ok()?
.get(url)
.send()
.await
.map_err(|e| tracing::debug!("{ctx} request failed: {}", e.without_url()))
.ok()?
.text()
.await
.map_err(|e| tracing::debug!("{ctx} body failed: {}", e.without_url()))
.ok()
}
#[cfg(test)]
mod tests {
use super::{get_json, get_text, sanitize_url};
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
const AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG: &str = "AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG";
fn build_url_with_sentinel(server_uri: &str) -> String {
format!("{server_uri}/feed?token={AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG}")
}
#[test]
fn sanitize_url_strips_sentinel_from_query_string() {
let base = "https://api.waqi.info/feed/geo:35.0;139.0/";
let url = format!("{base}?token={AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG}");
let sanitized = sanitize_url(&url);
assert!(
!sanitized.contains(AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG),
"sentinel leaked from sanitize_url: {sanitized}"
);
assert!(
sanitized.ends_with("?[redacted]"),
"unexpected sanitize_url output format: {sanitized}"
);
}
#[tokio::test]
#[tracing_test::traced_test]
async fn aqicn_token_does_not_leak_on_connect_failure() {
let url = format!("http://127.0.0.1:1/feed?token={AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG}");
let _ = get_json::<serde_json::Value>(&url, "aqicn").await;
assert!(
!logs_contain(AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG),
"aqicn token leaked into tracing output on connect failure"
);
assert!(
!logs_contain("?token="),
"raw token query string leaked into tracing output on connect failure"
);
}
#[tokio::test]
#[tracing_test::traced_test]
async fn aqicn_token_does_not_leak_on_status_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let url = build_url_with_sentinel(&server.uri());
let _ = get_json::<serde_json::Value>(&url, "aqicn").await;
assert!(
!logs_contain(AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG),
"aqicn token leaked into tracing output on HTTP 500"
);
assert!(
!logs_contain("?token="),
"raw token query string leaked into tracing output on HTTP 500"
);
}
#[tokio::test]
#[tracing_test::traced_test]
async fn aqicn_token_does_not_leak_on_body_failure() {
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((mut stream, _)) = listener.accept().await {
let headers =
"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\nContent-Type: text/plain\r\n\r\n";
let _ = stream.write_all(headers.as_bytes()).await;
let _ = stream.write_all(b"hello").await;
}
});
let url = format!("http://{addr}/feed?token={AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG}");
let _ = get_text(&url, "aqicn").await;
assert!(
!logs_contain(AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG),
"aqicn token leaked into tracing output on body failure"
);
assert!(
!logs_contain("?token="),
"raw token query string leaked into tracing output on body failure"
);
}
#[tokio::test]
#[tracing_test::traced_test]
async fn aqicn_token_does_not_leak_on_parse_failure() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(b"not valid json {{" as &[u8])
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let url = build_url_with_sentinel(&server.uri());
let _ = get_json::<serde_json::Value>(&url, "aqicn").await;
assert!(
!logs_contain(AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG),
"aqicn token leaked into tracing output on JSON parse failure"
);
assert!(
!logs_contain("?token="),
"raw token query string leaked into tracing output on JSON parse failure"
);
}
#[tokio::test]
#[tracing_test::traced_test]
async fn aqicn_token_does_not_leak_on_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&server)
.await;
let url = build_url_with_sentinel(&server.uri());
let result = get_json::<serde_json::Value>(&url, "aqicn").await;
assert!(result.is_some(), "expected successful JSON parse");
assert!(
!logs_contain(AQICN_LEAK_SENTINEL_TOKEN_DO_NOT_LOG),
"aqicn token leaked into tracing output on successful request"
);
assert!(
!logs_contain("?token="),
"raw token query string leaked into tracing output on successful request"
);
}
}