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::ops::ControlFlow;
12use std::time::Duration;
13
14use anyhow::{Context as _, Result};
15
16use crate::config::ContentSource;
17use crate::context::Context;
18use crate::retry::{RetryPolicy, retry_sync};
19
20const MAX_BODY_BYTES: usize = 256 * 1024;
21const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
22const POLICY: RetryPolicy = RetryPolicy {
23    max_attempts: 3,
24    base_delay: Duration::from_millis(500),
25    max_delay: Duration::from_secs(2),
26};
27
28/// Resolve a [`ContentSource`] to its string content.
29///
30/// `kind` is a short label (e.g. `"release header"`, `"changelog footer"`)
31/// surfaced in error messages so misconfigured fields are easy to identify.
32pub fn resolve(source: &ContentSource, kind: &str, ctx: &Context) -> Result<String> {
33    match source {
34        ContentSource::Inline(s) => Ok(s.clone()),
35        ContentSource::FromFile { from_file } => {
36            let rendered_path = ctx
37                .render_template(from_file)
38                .with_context(|| format!("{kind}: render from_file path '{from_file}'"))?;
39            std::fs::read_to_string(&rendered_path)
40                .with_context(|| format!("{kind}: read from_file '{rendered_path}'"))
41        }
42        ContentSource::FromUrl { from_url, headers } => {
43            let rendered_url = ctx
44                .render_template(from_url)
45                .with_context(|| format!("{kind}: render from_url '{from_url}'"))?;
46
47            // Render header values (keys are literal per GoReleaser docs).
48            // Reject CR/LF anywhere in keys or rendered values — a template
49            // interpolating user-tainted data could otherwise inject a new
50            // header line.
51            let mut rendered_headers: Vec<(String, String)> = Vec::new();
52            if let Some(map) = headers {
53                for (k, v) in map {
54                    if k.contains('\r') || k.contains('\n') {
55                        anyhow::bail!(
56                            "{kind} from_url header key contains CR/LF (possible injection): {:?}",
57                            k
58                        );
59                    }
60                    let rendered_v = ctx.render_template(v).with_context(|| {
61                        format!("{kind}: render header value for '{k}' at URL {rendered_url}")
62                    })?;
63                    if rendered_v.contains('\r') || rendered_v.contains('\n') {
64                        anyhow::bail!(
65                            "{kind} from_url header '{}' rendered to a value containing \
66                             CR/LF (possible injection): {:?}",
67                            k,
68                            rendered_v
69                        );
70                    }
71                    rendered_headers.push((k.clone(), rendered_v));
72                }
73            }
74
75            let client = crate::http::blocking_client(HTTP_TIMEOUT)?;
76
77            retry_sync(&POLICY, |attempt| {
78                let mut req = client.get(&rendered_url);
79                for (k, v) in &rendered_headers {
80                    req = req.header(k.as_str(), v.as_str());
81                }
82                match req.send() {
83                    Ok(response) => {
84                        let status = response.status();
85                        if status.is_success() {
86                            match response.bytes() {
87                                Ok(bytes) => {
88                                    if bytes.len() > MAX_BODY_BYTES {
89                                        return Err(ControlFlow::Break(anyhow::anyhow!(
90                                            "{kind} from_url {} body is {} bytes, exceeds \
91                                             {} KiB limit",
92                                            rendered_url,
93                                            bytes.len(),
94                                            MAX_BODY_BYTES / 1024,
95                                        )));
96                                    }
97                                    match String::from_utf8(bytes.to_vec()) {
98                                        Ok(text) => Ok(text),
99                                        Err(e) => Err(ControlFlow::Break(anyhow::anyhow!(e))),
100                                    }
101                                }
102                                Err(e) => Err(ControlFlow::Break(anyhow::anyhow!(e))),
103                            }
104                        } else if status.is_client_error() {
105                            Err(ControlFlow::Break(anyhow::anyhow!(
106                                "{kind} content URL {} returned HTTP {} (no retry on 4xx)",
107                                rendered_url,
108                                status
109                            )))
110                        } else {
111                            Err(ControlFlow::Continue(anyhow::anyhow!(
112                                "{kind} content URL {} returned HTTP {} (attempt {}/{})",
113                                rendered_url,
114                                status,
115                                attempt,
116                                POLICY.max_attempts
117                            )))
118                        }
119                    }
120                    Err(e) => Err(ControlFlow::Continue(anyhow::anyhow!(
121                        "{kind} fetch {} failed (attempt {}/{}): {}",
122                        rendered_url,
123                        attempt,
124                        POLICY.max_attempts,
125                        e
126                    ))),
127                }
128            })
129        }
130    }
131}