1use 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;
21const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
26const 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
36pub 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 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 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 #[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 #[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 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 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 #[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 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 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 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 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 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 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}