Skip to main content

facto_core/
http.rs

1use std::time::Duration;
2
3use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
4
5/// Maximum body size accepted from any upstream HTTP response. Anything
6/// larger is rejected with [`HttpClientError::BodyTooLarge`] to prevent
7/// allocator DoS from a compromised upstream or malicious CDN edge.
8pub const MAX_RESPONSE_BYTES: usize = 50 * 1024 * 1024; // 50 MB
9
10/// Errors that can occur when building or using an HTTP client.
11#[derive(Debug, thiserror::Error)]
12pub enum HttpClientError {
13    #[error("invalid header value: {0}")]
14    InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
15    #[error("failed to build HTTP client: {0}")]
16    Build(#[from] reqwest::Error),
17    #[error("response body exceeded {limit} bytes")]
18    BodyTooLarge { limit: usize },
19    #[error("failed to read response body: {0}")]
20    BodyRead(reqwest::Error),
21}
22
23/// Default HTTP client with timeouts, user-agent, strict redirect policy,
24/// and HTTPS-only enforcement.
25pub fn default_client() -> Result<reqwest::Client, HttpClientError> {
26    Ok(reqwest::Client::builder()
27        .user_agent(format!("facto/{}", env!("CARGO_PKG_VERSION")))
28        .connect_timeout(Duration::from_secs(5))
29        .timeout(Duration::from_secs(10))
30        .redirect(reqwest::redirect::Policy::none())
31        .https_only(true)
32        .build()?)
33}
34
35/// HTTP client with a Bearer token for authenticated APIs.
36pub fn bearer_client(token: &str) -> Result<reqwest::Client, HttpClientError> {
37    let mut headers = HeaderMap::new();
38    headers.insert(
39        AUTHORIZATION,
40        HeaderValue::from_str(&format!("Bearer {}", token))?,
41    );
42    Ok(reqwest::Client::builder()
43        .user_agent(format!("facto/{}", env!("CARGO_PKG_VERSION")))
44        .connect_timeout(Duration::from_secs(5))
45        .timeout(Duration::from_secs(10))
46        .default_headers(headers)
47        .redirect(reqwest::redirect::Policy::none())
48        .https_only(true)
49        .build()?)
50}
51
52/// HTTP client with a Private-Token header (GitLab).
53pub fn private_token_client(token: &str) -> Result<reqwest::Client, HttpClientError> {
54    let mut headers = HeaderMap::new();
55    headers.insert("PRIVATE-TOKEN", HeaderValue::from_str(token)?);
56    Ok(reqwest::Client::builder()
57        .user_agent(format!("facto/{}", env!("CARGO_PKG_VERSION")))
58        .connect_timeout(Duration::from_secs(5))
59        .timeout(Duration::from_secs(10))
60        .default_headers(headers)
61        .redirect(reqwest::redirect::Policy::none())
62        .https_only(true)
63        .build()?)
64}
65
66/// Read a response body into a `Vec<u8>`, aborting as soon as the byte
67/// count exceeds `limit`. Prevents a hostile or buggy upstream from
68/// OOM-killing the process by streaming an unbounded body.
69///
70/// The caller is responsible for JSON/UTF-8 interpretation. Use together
71/// with [`MAX_RESPONSE_BYTES`] for the default cap.
72pub async fn bytes_with_limit(
73    mut resp: reqwest::Response,
74    limit: usize,
75) -> Result<Vec<u8>, HttpClientError> {
76    let mut buf = Vec::new();
77    while let Some(chunk) = resp.chunk().await.map_err(HttpClientError::BodyRead)? {
78        if buf.len() + chunk.len() > limit {
79            return Err(HttpClientError::BodyTooLarge { limit });
80        }
81        buf.extend_from_slice(&chunk);
82    }
83    Ok(buf)
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[tokio::test]
91    async fn default_client_builds() {
92        default_client().unwrap();
93    }
94
95    #[tokio::test]
96    async fn bearer_client_builds() {
97        bearer_client("ghp_test").unwrap();
98    }
99
100    #[tokio::test]
101    async fn private_token_client_builds() {
102        private_token_client("glpat_test").unwrap();
103    }
104}