Skip to main content

hpr_net/
http.rs

1//! The HTTP transport: blocking HTTP/1.1 through `ureq`, TLS through rustls.
2
3use std::io::Read;
4use std::time::Duration;
5
6use ureq::{Proxy, ProxyProtocol};
7
8use crate::Transport;
9
10/// How an [`Http`] transport behaves. Start from [`HttpConfig::default`] and change fields.
11#[non_exhaustive]
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct HttpConfig {
14    /// The time a whole request may take: finding the host, connecting, every redirect and
15    /// reading the body. Longer than [`Http::MAX_TIMEOUT`] counts as that. Default 60 s.
16    pub timeout: Duration,
17    /// The longest body accepted, counted after any gzip unpacking; one byte more is refused.
18    /// Default 64 MiB, room for a forecast grid and far past any JSON answer.
19    pub max_body_bytes: u64,
20    /// Whether to use the proxy the environment names (`ALL_PROXY`, `HTTPS_PROXY` or
21    /// `HTTP_PROXY`, the first set, in either case, for every URL, bar hosts in `NO_PROXY`).
22    /// Default `true`.
23    pub proxy_from_env: bool,
24}
25
26impl Default for HttpConfig {
27    fn default() -> Self {
28        Self {
29            timeout: Duration::from_secs(60),
30            max_body_bytes: 64 * 1024 * 1024,
31            proxy_from_env: true,
32        }
33    }
34}
35
36/// A [`Transport`] that fetches over HTTP and HTTPS, behind the crate's `http` feature.
37///
38/// TLS is rustls with Mozilla's root certificates compiled in (the `webpki-roots` crate), so it
39/// needs no OpenSSL and ignores the operating system's certificate store. It follows up to ten
40/// redirects and sends `Accept-Encoding: gzip`, unpacking gzip bodies. Any status but 2xx is an
41/// error, as are a timeout and a body that unpacks to more than the limit. An HTTP or HTTPS proxy
42/// from the environment is used. A SOCKS one is refused with an error rather than bypassed, since
43/// this build cannot speak SOCKS; hosts `NO_PROXY` exempts are fetched directly, with no redirect
44/// followed, since the next address might not be exempt.
45///
46/// Cloning is cheap and the clones share one pool of connections.
47///
48/// ```no_run
49/// use hpr_net::{Cache, Client, Http, Mode, Source};
50///
51/// let dir = Cache::platform_dir().expect("a home or local app data folder");
52/// let client = Client::new(Http::new(), Cache::new(dir), Mode::Online);
53/// let source = Source {
54///     name: "Example".into(),
55///     attribution: "Example data".into(),
56///     ttl_s: 3600,
57/// };
58/// let now_s = std::time::SystemTime::now()
59///     .duration_since(std::time::UNIX_EPOCH)
60///     .map_or(0, |d| d.as_secs());
61/// let answer = client.fetch(&source, "https://example.com/", now_s)?;
62/// println!("{} bytes, {:?}", answer.body.len(), answer.freshness);
63/// # Ok::<(), hpr_net::NetError>(())
64/// ```
65#[derive(Debug, Clone)]
66pub struct Http {
67    agent: ureq::Agent,
68    max_body_bytes: u64,
69    /// A SOCKS proxy the environment names, which every URL it covers is refused under.
70    socks: Option<Proxy>,
71}
72
73impl Http {
74    /// The `User-Agent` header sent: the crate's name and version and the project's address, so a
75    /// data provider can tell who is calling.
76    pub const USER_AGENT: &str = concat!(
77        "hpr-sim/",
78        env!("CARGO_PKG_VERSION"),
79        " (+https://github.com/nrdptel/hpr-sim)"
80    );
81    /// The longest timeout used: 30 days. A longer one, such as [`Duration::MAX`] for "none",
82    /// counts as this, since a deadline past the clock's range would overflow.
83    pub const MAX_TIMEOUT: Duration = Duration::from_secs(30 * 24 * 3600);
84
85    /// A transport with [`HttpConfig::default`].
86    pub fn new() -> Self {
87        Self::with_config(HttpConfig::default())
88    }
89
90    /// A transport with `config`.
91    pub fn with_config(config: HttpConfig) -> Self {
92        let proxy = if config.proxy_from_env {
93            Proxy::try_from_env()
94        } else {
95            None
96        };
97        Self::with_proxy(&config, proxy)
98    }
99
100    fn with_proxy(config: &HttpConfig, proxy: Option<Proxy>) -> Self {
101        let socks = proxy
102            .clone()
103            .filter(|p| !matches!(p.protocol(), ProxyProtocol::Http | ProxyProtocol::Https));
104        // Under a SOCKS proxy a redirect is refused, not followed: `NO_PROXY` could exempt the
105        // first address and not the next, which ureq would then reach directly.
106        let redirects = if socks.is_some() { 0 } else { 10 };
107        let agent = ureq::Agent::config_builder()
108            .timeout_global(Some(config.timeout.min(Self::MAX_TIMEOUT)))
109            .user_agent(Self::USER_AGENT)
110            .max_redirects(redirects)
111            .proxy(proxy)
112            .build()
113            .new_agent();
114        Self {
115            agent,
116            max_body_bytes: config.max_body_bytes,
117            socks,
118        }
119    }
120}
121
122impl Http {
123    /// Why `url` is refused, when the environment's proxy is SOCKS and `NO_PROXY` doesn't exempt
124    /// it. Without ureq's SOCKS support, ureq would warn and connect directly, around the proxy.
125    fn socks_refusal(&self, url: &str) -> Option<String> {
126        let socks = self.socks.as_ref()?;
127        let uri = url.parse::<ureq::http::Uri>().ok()?;
128        if socks.is_no_proxy(&uri) {
129            return None;
130        }
131        // The protocol alone: the address may carry a user name and password, and one with an
132        // unescaped `/` or `#` in the password ends early, leaving the user name as the "host".
133        Some(format!(
134            "the environment's proxy ({:?}) is SOCKS, which hpr-net cannot use; unset the \
135             variable naming it (ALL_PROXY, HTTPS_PROXY or HTTP_PROXY), add the host to NO_PROXY, \
136             or turn off HttpConfig::proxy_from_env",
137            socks.protocol(),
138        ))
139    }
140}
141
142impl Default for Http {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl Transport for Http {
149    fn get(&self, url: &str) -> Result<Vec<u8>, String> {
150        if let Some(refusal) = self.socks_refusal(url) {
151            return Err(refusal);
152        }
153        let mut response = self.agent.get(url).call().map_err(|e| e.to_string())?;
154        // ureq errors on 4xx and 5xx only; a 304 or an unfollowed 3xx would read as an empty body.
155        let status = response.status();
156        if !status.is_success() {
157            return Err(format!("http status: {}", status.as_u16()));
158        }
159        // The limit is on the unpacked body, read one byte past it to tell "at" from "over".
160        // ureq's own limit counts bytes on the wire, inside its gzip decoder, so a small compressed
161        // body could unpack past it. Wire bytes that unpack to nothing are bounded by the timeout.
162        let unpacked = response.body_mut().with_config().limit(u64::MAX).reader();
163        let mut body = Vec::new();
164        unpacked
165            .take(self.max_body_bytes.saturating_add(1))
166            .read_to_end(&mut body)
167            .map_err(|e| e.to_string())?;
168        if body.len() as u64 > self.max_body_bytes {
169            return Err(format!(
170                "the body is longer than the {} bytes allowed",
171                self.max_body_bytes
172            ));
173        }
174        Ok(body)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn under(proxy: Proxy) -> Http {
183        Http::with_proxy(&HttpConfig::default(), Some(proxy))
184    }
185
186    #[test]
187    fn a_socks_proxy_is_refused_not_bypassed() {
188        let http = under(Proxy::new("socks5://127.0.0.1:9").unwrap());
189        let reason = http.get("http://127.0.0.1:1/x").unwrap_err();
190        assert!(reason.contains("is SOCKS"), "{reason}");
191    }
192
193    #[test]
194    fn the_refusal_never_repeats_the_proxys_credentials() {
195        // Well formed, and with an unescaped `/` that ends the address inside the password.
196        for url in [
197            "socks5://alice:s3cret@proxy.example:1080",
198            "socks5://alice:12/cret@proxy.example:1080",
199        ] {
200            let reason = under(Proxy::new(url).unwrap())
201                .socks_refusal("https://example.test/x")
202                .unwrap();
203            assert!(
204                !reason.contains("cret") && !reason.contains("alice"),
205                "{reason}"
206            );
207        }
208    }
209
210    #[test]
211    fn no_proxy_exempts_a_host_from_the_refusal() {
212        let proxy = Proxy::builder(ProxyProtocol::Socks5)
213            .host("proxy.example")
214            .no_proxy("near.example")
215            .build()
216            .unwrap();
217        let http = under(proxy);
218        assert_eq!(http.socks_refusal("https://near.example/x"), None);
219        assert!(http.socks_refusal("https://far.example/x").is_some());
220    }
221
222    #[test]
223    fn under_a_socks_proxy_redirects_are_not_followed() {
224        // A request can't be made here: ureq panics on a SOCKS proxy not read from the
225        // environment, and only the environment makes one. With no redirects followed, a 3xx
226        // comes back as the response and fails as any status but 2xx does (`tests/http.rs`, 304).
227        let socks = under(Proxy::new("socks5://127.0.0.1:9").unwrap());
228        assert_eq!(socks.agent.config().max_redirects(), 0);
229        let http = under(Proxy::new("http://127.0.0.1:9").unwrap());
230        assert_eq!(http.agent.config().max_redirects(), 10);
231    }
232
233    #[test]
234    fn an_http_proxy_is_used_not_refused() {
235        for url in ["http://127.0.0.1:9", "https://127.0.0.1:9"] {
236            let http = under(Proxy::new(url).unwrap());
237            assert_eq!(http.socks_refusal("https://example.test/x"), None, "{url}");
238        }
239    }
240}