Skip to main content

anodizer_core/
http.rs

1//! HTTP client helpers shared by every stage that talks to a remote.
2//!
3//! All anodizer HTTP traffic should go through `blocking_client(...)` so that
4//! the `User-Agent`, default-roots, and timeout policy stay consistent across
5//! publishers, announcers, and the release backends.
6
7use std::time::Duration;
8
9use anyhow::{Context as _, Result};
10
11/// Canonical user-agent string sent with every anodizer HTTP request.
12///
13/// Versioning the UA matters for upstream services that rate-limit or
14/// fingerprint by client identity (Discourse, Reddit, GitHub, etc.).
15pub const USER_AGENT: &str = concat!("anodizer/", env!("CARGO_PKG_VERSION"));
16
17/// Build a blocking `reqwest::Client` configured with the canonical UA,
18/// the requested per-request timeout, and the platform's built-in roots.
19pub fn blocking_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
20    reqwest::blocking::Client::builder()
21        .user_agent(USER_AGENT)
22        .timeout(timeout)
23        .build()
24        .context("build blocking HTTP client")
25}
26
27/// [`blocking_client`] with redirect-following disabled: a 3xx response is
28/// returned to the caller instead of being chased. For probes whose verdict
29/// depends on whether a SPECIFIC URL resolves (e.g. a registry version page),
30/// silently following a redirect to a different page would misattribute the
31/// destination's body to the requested resource.
32pub fn blocking_client_no_redirect(timeout: Duration) -> Result<reqwest::blocking::Client> {
33    reqwest::blocking::Client::builder()
34        .user_agent(USER_AGENT)
35        .timeout(timeout)
36        .redirect(reqwest::redirect::Policy::none())
37        .build()
38        .context("build blocking HTTP client (no redirects)")
39}
40
41/// Async equivalent of `blocking_client`.
42pub fn async_client(timeout: Duration) -> Result<reqwest::Client> {
43    reqwest::Client::builder()
44        .user_agent(USER_AGENT)
45        .timeout(timeout)
46        .build()
47        .context("build async HTTP client")
48}
49
50/// Resolve the GitHub REST API base URL through an injected env source.
51///
52/// Honors the undocumented `ANODIZER_GITHUB_API_BASE` override so unit tests
53/// can redirect GitHub REST calls to an in-process responder via a
54/// [`MapEnvSource`](crate::MapEnvSource); defaults to the canonical
55/// `https://api.github.com` in production where callers pass
56/// [`ProcessEnvSource`](crate::ProcessEnvSource) and the var is unset. Any
57/// trailing `/` is stripped so callers can unconditionally `format!` with a
58/// `/`-prefixed suffix without producing a double slash.
59///
60/// Every GitHub REST caller (release-stage rate-limit polls, publish-stage
61/// default-branch / PR lookups) must resolve its base through this one
62/// helper so a single override redirects the whole run to the same host.
63pub fn github_api_base<E: crate::EnvSource + ?Sized>(env: &E) -> String {
64    let raw = env
65        .var("ANODIZER_GITHUB_API_BASE")
66        .unwrap_or_else(|| "https://api.github.com".to_string());
67    raw.trim_end_matches('/').to_string()
68}
69
70/// Like [`github_api_base`], but honoring a configured `github_urls.api`
71/// (GitHub Enterprise Server) first.
72///
73/// Preflight probes and milestone operations must contact the same host the
74/// release backend will (see `build_octocrab_client`): probing github.com
75/// for a repo that lives on a GHES host false-404s (Blocker for a release
76/// that would succeed), or worse, returns a verdict for an unrelated
77/// same-named public repo. Precedence: `github_urls.api` >
78/// `ANODIZER_GITHUB_API_BASE` > `https://api.github.com`. Any trailing `/`
79/// is stripped, as in [`github_api_base`].
80pub fn github_api_base_with_config<E: crate::EnvSource + ?Sized>(
81    github_urls: Option<&crate::config::GitHubUrlsConfig>,
82    env: &E,
83) -> String {
84    github_urls
85        .and_then(|u| u.api.as_deref())
86        .map(|api| api.trim_end_matches('/').to_string())
87        .unwrap_or_else(|| github_api_base(env))
88}
89
90/// Format an HTTP body-read failure as a descriptive placeholder string.
91///
92/// Used by [`body_of`] / [`body_of_blocking`]: a transport-level
93/// read error becomes `"could not read response body: <err>"` rather than
94/// silently truncating to `""`. Exposed as a free function so unit tests can
95/// pin the exact wording without standing up a fault-injecting HTTP server.
96pub fn body_read_error_message<E: std::fmt::Display>(err: E) -> String {
97    format!("could not read response body: {err}")
98}
99
100/// Read an HTTP response body to a `String`, returning a descriptive
101/// placeholder on read failure.
102///
103/// Reads and scrubs an HTTP response body for error reporting after
104/// commit `8b77358`: a transport-level read error becomes
105/// `"could not read response body: <err>"` rather than silently truncating
106/// to an empty string. Callers typically pass the resulting text into a
107/// larger error context (e.g. `"GitHub API returned 422: {body}"`), so the
108/// placeholder still surfaces a usable diagnostic instead of a confusing
109/// empty payload.
110///
111/// Use this when the body will be interpolated into a downstream error
112/// message; use `resp.text().await?` directly when the caller will
113/// propagate the read failure as its own error rather than substituting
114/// a placeholder.
115pub async fn body_of(resp: reqwest::Response) -> String {
116    match resp.text().await {
117        Ok(s) => s,
118        Err(err) => body_read_error_message(err),
119    }
120}
121
122/// Blocking analogue of [`body_of`].
123///
124/// Use this when the body will be interpolated into a downstream error
125/// message; use `resp.text()?` directly when the caller will propagate
126/// the read failure as its own error rather than substituting a
127/// placeholder.
128pub fn body_of_blocking(resp: reqwest::blocking::Response) -> String {
129    match resp.text() {
130        Ok(s) => s,
131        Err(err) => body_read_error_message(err),
132    }
133}
134
135/// Download `url` (blocking, 5-minute timeout) and return the lowercase-hex
136/// SHA-256 of its body — the canonical "fetch a release artifact, hash it"
137/// helper for publishers that must fill a digest they were not handed (e.g.
138/// the homebrew-core formula bump when no `sha256:` override is configured).
139///
140/// The 5-minute timeout accommodates multi-MB release tarballs; a non-2xx
141/// response is a hard error carrying the status and body so the caller need
142/// not re-classify.
143pub fn sha256_url(url: &str) -> Result<String> {
144    use sha2::Digest as _;
145    let client = blocking_client(Duration::from_secs(300)).context("build download client")?;
146    let resp = client
147        .get(url)
148        .send()
149        .with_context(|| format!("download {url}"))?;
150    let status = resp.status();
151    if !status.is_success() {
152        anyhow::bail!(
153            "download {} returned HTTP {}: {}",
154            url,
155            status,
156            body_of_blocking(resp)
157        );
158    }
159    let bytes = resp
160        .bytes()
161        .with_context(|| format!("read download body from {url}"))?;
162    let mut hasher = sha2::Sha256::new();
163    hasher.update(&bytes);
164    Ok(crate::hashing::hex_lower(&hasher.finalize()))
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn github_api_base_strips_trailing_slash() {
173        let env =
174            crate::MapEnvSource::new().with("ANODIZER_GITHUB_API_BASE", "https://example.com/api/");
175        assert_eq!(github_api_base(&env), "https://example.com/api");
176    }
177
178    #[test]
179    fn github_api_base_defaults_when_env_unset() {
180        let env = crate::MapEnvSource::new();
181        assert_eq!(github_api_base(&env), "https://api.github.com");
182    }
183
184    #[test]
185    fn github_api_base_with_config_prefers_configured_ghes_api() {
186        let urls = crate::config::GitHubUrlsConfig {
187            api: Some("https://github.example.com/api/v3/".to_string()),
188            ..Default::default()
189        };
190        let env =
191            crate::MapEnvSource::new().with("ANODIZER_GITHUB_API_BASE", "https://override.test");
192        assert_eq!(
193            github_api_base_with_config(Some(&urls), &env),
194            "https://github.example.com/api/v3"
195        );
196    }
197
198    #[test]
199    fn github_api_base_with_config_falls_back_to_env_resolver() {
200        let env =
201            crate::MapEnvSource::new().with("ANODIZER_GITHUB_API_BASE", "https://override.test/");
202        assert_eq!(
203            github_api_base_with_config(None, &env),
204            "https://override.test"
205        );
206        let unset = crate::MapEnvSource::new();
207        assert_eq!(
208            github_api_base_with_config(None, &unset),
209            "https://api.github.com"
210        );
211    }
212
213    #[test]
214    fn test_body_read_error_message_uses_descriptive_prefix() {
215        // Pin the exact wording: callers may parse / match on this string,
216        // and the error-body contract requires the
217        // `"could not read response body: "` prefix verbatim.
218        let formatted = body_read_error_message("connection reset by peer");
219        assert_eq!(
220            formatted,
221            "could not read response body: connection reset by peer"
222        );
223    }
224
225    #[test]
226    fn test_body_read_error_message_with_io_error() {
227        let io_err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "stream ended early");
228        let formatted = body_read_error_message(io_err);
229        assert!(
230            formatted.starts_with("could not read response body: "),
231            "format must keep the descriptive prefix: {formatted}"
232        );
233        assert!(
234            formatted.contains("stream ended early"),
235            "format must include the underlying error: {formatted}"
236        );
237    }
238}