anodizer_core/content_source.rs
1//! Resolve a [`ContentSource`] to its string content.
2//!
3//! Hoisted to core so multiple stages (release, changelog, ...) can share one
4//! implementation. Supports `Inline`, `FromFile` (template-render the path,
5//! read the file), and `FromUrl` (template-render URL + headers, fetch via
6//! HTTP GET with retries on transient errors / 5xx, fail fast on 4xx).
7//!
8//! `FromUrl` enforces a 256 KiB body cap and rejects CR/LF in rendered header
9//! values to defend against header-injection via templated user data.
10
11use std::time::Duration;
12
13use anyhow::{Context as _, Result};
14
15use crate::config::ContentSource;
16use crate::context::Context;
17use crate::retry::{RetryPolicy, SuccessClass, retry_http_blocking};
18
19const MAX_BODY_BYTES: usize = 256 * 1024;
20/// Total per-request deadline. `reqwest::blocking::ClientBuilder` does
21/// not expose a separate `read_timeout` (the API is async-only); the
22/// total `timeout` bounds connect + transfer for the blocking surface,
23/// so a stalled server cannot hold the connection open past 30 s.
24const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
25/// Connect-only deadline. Allows the connect phase to fail fast on a
26/// dead host without consuming the full request budget; the remaining
27/// time is then available for the actual transfer.
28const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
29const POLICY: RetryPolicy = RetryPolicy {
30 max_attempts: 3,
31 base_delay: Duration::from_millis(500),
32 max_delay: Duration::from_secs(2),
33};
34
35/// Resolve a [`ContentSource`] to its string content.
36///
37/// `kind` is a short label (e.g. `"release header"`, `"changelog footer"`)
38/// surfaced in error messages so misconfigured fields are easy to identify.
39pub fn resolve(source: &ContentSource, kind: &str, ctx: &Context) -> Result<String> {
40 match source {
41 ContentSource::Inline(s) => Ok(s.clone()),
42 ContentSource::FromFile { from_file } => {
43 let rendered_path = ctx
44 .render_template(from_file)
45 .with_context(|| format!("{kind}: render from_file path '{from_file}'"))?;
46 std::fs::read_to_string(&rendered_path)
47 .with_context(|| format!("{kind}: read from_file '{rendered_path}'"))
48 }
49 ContentSource::FromUrl { from_url, headers } => {
50 let rendered_url = ctx
51 .render_template(from_url)
52 .with_context(|| format!("{kind}: render from_url '{from_url}'"))?;
53
54 // Render header values (keys are literal per GoReleaser docs).
55 // Reject CR/LF anywhere in keys or rendered values — a template
56 // interpolating user-tainted data could otherwise inject a new
57 // header line.
58 let mut rendered_headers: Vec<(String, String)> = Vec::new();
59 if let Some(map) = headers {
60 for (k, v) in map {
61 if k.contains('\r') || k.contains('\n') {
62 anyhow::bail!(
63 "{kind} from_url header key contains CR/LF (possible injection): {:?}",
64 k
65 );
66 }
67 let rendered_v = ctx.render_template(v).with_context(|| {
68 format!("{kind}: render header value for '{k}' at URL {rendered_url}")
69 })?;
70 if rendered_v.contains('\r') || rendered_v.contains('\n') {
71 anyhow::bail!(
72 "{kind} from_url header '{}' rendered to a value containing \
73 CR/LF (possible injection): {:?}",
74 k,
75 rendered_v
76 );
77 }
78 rendered_headers.push((k.clone(), rendered_v));
79 }
80 }
81
82 let client = reqwest::blocking::Client::builder()
83 .user_agent(crate::http::USER_AGENT)
84 .timeout(HTTP_TIMEOUT)
85 .connect_timeout(HTTP_CONNECT_TIMEOUT)
86 .build()
87 .context("build blocking HTTP client for ContentSource::FromUrl")?;
88
89 // `retry_http_blocking` handles 5xx → retry, 4xx → fast-fail, and
90 // transport errors via the shared `is_retriable` classifier.
91 // Body-cap and label-formatting are applied on the returned
92 // body string.
93 let label = format!("{kind} from_url {rendered_url}");
94 let rendered_url_for_err = rendered_url.clone();
95 let (_status, body) = retry_http_blocking(
96 &label,
97 &POLICY,
98 SuccessClass::Strict,
99 |_attempt| {
100 let mut req = client.get(&rendered_url);
101 for (k, v) in &rendered_headers {
102 req = req.header(k.as_str(), v.as_str());
103 }
104 req.send()
105 },
106 |status, _body| format!("returned HTTP {status}"),
107 )?;
108
109 if body.len() > MAX_BODY_BYTES {
110 anyhow::bail!(
111 "{kind} from_url {} body is {} bytes, exceeds {} KiB limit",
112 rendered_url_for_err,
113 body.len(),
114 MAX_BODY_BYTES / 1024,
115 );
116 }
117 Ok(body)
118 }
119 }
120}