anodizer_core/
content_source.rs1use 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
28pub 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 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}