Skip to main content

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::log::StageLogger;
18use crate::retry::{RetryLog, RetryPolicy, SuccessClass, retry_http_blocking_deadline};
19
20const MAX_BODY_BYTES: usize = 256 * 1024;
21/// Total per-request deadline. `reqwest::blocking::ClientBuilder` does
22/// not expose a separate `read_timeout` (the API is async-only); the
23/// total `timeout` bounds connect + transfer for the blocking surface,
24/// so a stalled server cannot hold the connection open past 30 s.
25const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
26/// Connect-only deadline. Allows the connect phase to fail fast on a
27/// dead host without consuming the full request budget; the remaining
28/// time is then available for the actual transfer.
29const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
30const POLICY: RetryPolicy = RetryPolicy {
31    max_attempts: 3,
32    base_delay: Duration::from_millis(500),
33    max_delay: Duration::from_secs(2),
34};
35
36/// Resolve a [`ContentSource`] to its string content.
37///
38/// `kind` is a short label (e.g. `"release header"`, `"changelog footer"`)
39/// surfaced in error messages so misconfigured fields are easy to identify.
40pub fn resolve(
41    source: &ContentSource,
42    kind: &str,
43    ctx: &Context,
44    log: &StageLogger,
45) -> Result<String> {
46    match source {
47        ContentSource::Inline(s) => Ok(s.clone()),
48        ContentSource::FromFile { from_file } => {
49            let rendered_path = ctx
50                .render_template(from_file)
51                .with_context(|| format!("{kind}: render from_file path '{from_file}'"))?;
52            std::fs::read_to_string(&rendered_path)
53                .with_context(|| format!("{kind}: read from_file '{rendered_path}'"))
54        }
55        ContentSource::FromUrl { from_url, headers } => {
56            let rendered_url = ctx
57                .render_template(from_url)
58                .with_context(|| format!("{kind}: render from_url '{from_url}'"))?;
59
60            // Render header values (keys are literal).
61            // Reject CR/LF anywhere in keys or rendered values — a template
62            // interpolating user-tainted data could otherwise inject a new
63            // header line.
64            let mut rendered_headers: Vec<(String, String)> = Vec::new();
65            if let Some(map) = headers {
66                for (k, v) in map {
67                    if k.contains('\r') || k.contains('\n') {
68                        anyhow::bail!(
69                            "{kind} from_url header key contains CR/LF (possible injection): {:?}",
70                            k
71                        );
72                    }
73                    let rendered_v = ctx.render_template(v).with_context(|| {
74                        format!("{kind}: render header value for '{k}' at URL {rendered_url}")
75                    })?;
76                    if rendered_v.contains('\r') || rendered_v.contains('\n') {
77                        anyhow::bail!(
78                            "{kind} from_url header '{}' rendered to a value containing \
79                             CR/LF (possible injection): {:?}",
80                            k,
81                            rendered_v
82                        );
83                    }
84                    rendered_headers.push((k.clone(), rendered_v));
85                }
86            }
87
88            let client = reqwest::blocking::Client::builder()
89                .user_agent(crate::http::USER_AGENT)
90                .timeout(HTTP_TIMEOUT)
91                .connect_timeout(HTTP_CONNECT_TIMEOUT)
92                .build()
93                .context("build blocking HTTP client for ContentSource::FromUrl")?;
94
95            // `retry_http_blocking_deadline` handles 5xx → retry, 4xx →
96            // fast-fail, and transport errors via the shared `is_retriable`
97            // classifier, and stops once the invocation's wall-clock budget is
98            // spent. Body-cap and label-formatting are applied on the returned
99            // body string.
100            let label = format!("{kind} from_url {rendered_url}");
101            let rendered_url_for_err = rendered_url.clone();
102            let (_status, body) = retry_http_blocking_deadline(
103                RetryLog::new(&label, log),
104                &POLICY,
105                ctx.retry_deadline(),
106                SuccessClass::Strict,
107                |_attempt| {
108                    let mut req = client.get(&rendered_url);
109                    for (k, v) in &rendered_headers {
110                        req = req.header(k.as_str(), v.as_str());
111                    }
112                    req.send()
113                },
114                |status, _body| format!("returned HTTP {status}"),
115            )?;
116
117            if body.len() > MAX_BODY_BYTES {
118                anyhow::bail!(
119                    "{kind} from_url {} body is {} bytes, exceeds {} KiB limit",
120                    rendered_url_for_err,
121                    body.len(),
122                    MAX_BODY_BYTES / 1024,
123                );
124            }
125            Ok(body)
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::config::Config;
134    use crate::context::{Context, ContextOptions};
135    use crate::test_helpers::responder::{
136        spawn_oneshot_http_responder, spawn_request_capturing_responder,
137    };
138    use std::collections::HashMap;
139    use std::sync::atomic::Ordering;
140
141    fn ctx() -> Context {
142        let config = Config {
143            project_name: "myapp".to_string(),
144            ..Config::default()
145        };
146        Context::new(config, ContextOptions::default())
147    }
148
149    fn tlog() -> &'static StageLogger {
150        static L: std::sync::OnceLock<StageLogger> = std::sync::OnceLock::new();
151        L.get_or_init(|| StageLogger::new("test", crate::log::Verbosity::Quiet))
152    }
153
154    // ---- Inline ----
155
156    #[test]
157    fn inline_returns_string_verbatim() {
158        let src = ContentSource::Inline("hello world".to_string());
159        assert_eq!(resolve(&src, "k", &ctx(), tlog()).unwrap(), "hello world");
160    }
161
162    // ---- FromFile ----
163
164    #[test]
165    fn from_file_renders_path_template_and_reads_contents() {
166        let dir = tempfile::tempdir().unwrap();
167        let body = "release header from disk\n";
168        // Render a path template that interpolates a template var so the
169        // rendered path differs from the raw template string — proves the
170        // template engine actually ran on the path.
171        let file_path = dir.path().join("myapp-notes.md");
172        std::fs::write(&file_path, body).unwrap();
173        let template = format!("{}/{{{{ .ProjectName }}}}-notes.md", dir.path().display());
174        let src = ContentSource::FromFile {
175            from_file: template,
176        };
177        assert_eq!(
178            resolve(&src, "release header", &ctx(), tlog()).unwrap(),
179            body
180        );
181    }
182
183    #[test]
184    fn from_file_bails_when_path_template_invalid() {
185        // Unknown filter is a recognized template parse error (see
186        // `template/tests.rs::test_unknown_filter_error`). Routes through
187        // the `render from_file path` `with_context` arm.
188        let src = ContentSource::FromFile {
189            from_file: "{{ ProjectName | nonexistent_filter }}".to_string(),
190        };
191        let err = resolve(&src, "release header", &ctx(), tlog()).unwrap_err();
192        let chain = format!("{err:#}");
193        assert!(
194            chain.contains("render from_file path"),
195            "context missing: {chain}"
196        );
197    }
198
199    #[test]
200    fn from_file_bails_when_file_missing() {
201        let dir = tempfile::tempdir().unwrap();
202        let missing = dir.path().join("does-not-exist.md");
203        let src = ContentSource::FromFile {
204            from_file: missing.display().to_string(),
205        };
206        let err = resolve(&src, "release header", &ctx(), tlog()).unwrap_err();
207        let chain = format!("{err:#}");
208        assert!(chain.contains("read from_file"), "context missing: {chain}");
209    }
210
211    // ---- FromUrl ----
212
213    #[test]
214    fn from_url_success_returns_body() {
215        let body = "remote header body";
216        let body_len = body.len();
217        let response: &'static str = Box::leak(
218            format!("HTTP/1.1 200 OK\r\nContent-Length: {body_len}\r\n\r\n{body}").into_boxed_str(),
219        );
220        let (addr, calls) = spawn_oneshot_http_responder(vec![response]);
221        let src = ContentSource::FromUrl {
222            from_url: format!("http://{addr}/header.md"),
223            headers: None,
224        };
225        let got = resolve(&src, "release header", &ctx(), tlog()).unwrap();
226        assert_eq!(got, body);
227        assert_eq!(calls.load(Ordering::SeqCst), 1, "single attempt on 200");
228    }
229
230    #[test]
231    fn from_url_renders_header_values_and_sends_them_verbatim() {
232        let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok";
233        let (addr, captured) = spawn_request_capturing_responder(response);
234
235        let mut headers = HashMap::new();
236        // Key is literal; value is template-rendered.
237        headers.insert(
238            "X-App-Name".to_string(),
239            "name={{ .ProjectName }}".to_string(),
240        );
241        let src = ContentSource::FromUrl {
242            from_url: format!("http://{addr}/h.md"),
243            headers: Some(headers),
244        };
245        let body = resolve(&src, "release header", &ctx(), tlog()).unwrap();
246        assert_eq!(body, "ok");
247
248        // Poll the capture briefly — the responder thread writes the
249        // captured string asynchronously.
250        let deadline = std::time::Instant::now() + Duration::from_secs(2);
251        let captured_str = loop {
252            let s = captured.lock().unwrap().clone();
253            if !s.is_empty() || std::time::Instant::now() >= deadline {
254                break s;
255            }
256            std::thread::sleep(Duration::from_millis(10));
257        };
258        let lower = captured_str.to_ascii_lowercase();
259        assert!(
260            lower.contains("x-app-name: name=myapp"),
261            "header missing or unrendered in request: {captured_str:?}"
262        );
263    }
264
265    #[test]
266    fn from_url_rejects_crlf_in_header_key() {
267        let mut headers = HashMap::new();
268        headers.insert("X-Bad\r\nInjected".to_string(), "v".to_string());
269        let src = ContentSource::FromUrl {
270            // Address doesn't matter; the key check fires before any
271            // network IO.
272            from_url: "http://127.0.0.1:1/".to_string(),
273            headers: Some(headers),
274        };
275        let err = resolve(&src, "release header", &ctx(), tlog()).unwrap_err();
276        let chain = format!("{err:#}");
277        assert!(
278            chain.contains("header key contains CR/LF"),
279            "expected CR/LF key guard, got: {chain}"
280        );
281    }
282
283    #[test]
284    fn from_url_rejects_crlf_in_rendered_header_value() {
285        // The header value template renders a literal CR/LF sequence —
286        // simulating an attacker-controlled template var that injected
287        // a header-line terminator. `Env.X` falls back to literal-empty
288        // when unset, so embed the CR/LF directly to make the test
289        // deterministic.
290        let mut headers = HashMap::new();
291        headers.insert("X-Hdr".to_string(), "ok\r\nX-Injected: yes".to_string());
292        let src = ContentSource::FromUrl {
293            from_url: "http://127.0.0.1:1/".to_string(),
294            headers: Some(headers),
295        };
296        let err = resolve(&src, "release header", &ctx(), tlog()).unwrap_err();
297        let chain = format!("{err:#}");
298        assert!(
299            chain.contains("rendered to a value containing CR/LF"),
300            "expected CR/LF value guard, got: {chain}"
301        );
302    }
303
304    #[test]
305    fn from_url_bails_when_body_exceeds_cap() {
306        // 256 KiB + 1 byte body — the cap is a strict `>` check.
307        let oversize = "x".repeat(MAX_BODY_BYTES + 1);
308        let body_len = oversize.len();
309        let response: &'static str = Box::leak(
310            format!("HTTP/1.1 200 OK\r\nContent-Length: {body_len}\r\n\r\n{oversize}")
311                .into_boxed_str(),
312        );
313        let (addr, _calls) = spawn_oneshot_http_responder(vec![response]);
314        let src = ContentSource::FromUrl {
315            from_url: format!("http://{addr}/big.md"),
316            headers: None,
317        };
318        let err = resolve(&src, "release header", &ctx(), tlog()).unwrap_err();
319        let chain = format!("{err:#}");
320        assert!(
321            chain.contains("exceeds 256 KiB limit"),
322            "expected body-cap error, got: {chain}"
323        );
324    }
325
326    #[test]
327    fn from_url_4xx_fast_fails_no_retry() {
328        let (addr, calls) = spawn_oneshot_http_responder(vec![
329            "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n",
330        ]);
331        let src = ContentSource::FromUrl {
332            from_url: format!("http://{addr}/missing.md"),
333            headers: None,
334        };
335        let err = resolve(&src, "release header", &ctx(), tlog()).unwrap_err();
336        let chain = format!("{err:#}");
337        assert!(chain.contains("404"), "status missing from chain: {chain}");
338        assert_eq!(
339            calls.load(Ordering::SeqCst),
340            1,
341            "4xx must not retry (only one attempt observed)"
342        );
343    }
344
345    #[test]
346    fn from_url_5xx_exhausts_retries_then_fails() {
347        // Drive exactly POLICY.max_attempts canned 500s so the responder
348        // counter pins to the configured retry budget. Wiring through the
349        // const means a future bump of POLICY.max_attempts updates the
350        // test atomically without a stale literal silently passing.
351        let max_attempts = POLICY.max_attempts as usize;
352        let responses: Vec<&'static str> = std::iter::repeat_n(
353            "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n",
354            max_attempts,
355        )
356        .collect();
357        let (addr, calls) = spawn_oneshot_http_responder(responses);
358        let src = ContentSource::FromUrl {
359            from_url: format!("http://{addr}/flaky.md"),
360            headers: None,
361        };
362        let err = resolve(&src, "release header", &ctx(), tlog()).unwrap_err();
363        let chain = format!("{err:#}");
364        assert!(chain.contains("500"), "status missing from chain: {chain}");
365        assert_eq!(
366            calls.load(Ordering::SeqCst),
367            max_attempts as u32,
368            "all POLICY.max_attempts retries must run before bailing"
369        );
370    }
371}