anodizer_core/
template_file_render.rs1use std::path::Path;
13
14use anyhow::{Context as _, Result, bail};
15
16use crate::config::TemplateFileConfig;
17use crate::context::Context;
18
19#[derive(Debug)]
27pub struct TemplatedFileRender {
28 pub rendered_dst: String,
29 pub rendered_contents: String,
30}
31
32pub fn render_templated_file_entry(
43 ctx: &Context,
44 entry: &TemplateFileConfig,
45 label_prefix: &str,
46) -> Result<Option<TemplatedFileRender>> {
47 if let Some(ref skip) = entry.skip {
48 let off = skip
49 .try_evaluates_to_true(|tmpl| ctx.render_template(tmpl))
50 .with_context(|| format!("{label_prefix}: render skip template"))?;
51 if off {
52 return Ok(None);
53 }
54 }
55
56 let rendered_src = ctx
57 .render_template(&entry.src)
58 .with_context(|| format!("{label_prefix}: render src path"))?;
59 let src_path = Path::new(&rendered_src).to_path_buf();
60 let src_bytes = std::fs::read(&src_path).with_context(|| {
61 format!(
62 "{label_prefix}: source file '{}' not found",
63 src_path.display()
64 )
65 })?;
66 let src_contents = std::str::from_utf8(&src_bytes).map_err(|_| {
67 anyhow::anyhow!(
68 "{label_prefix}: source file '{}' is not valid UTF-8 — \
69 templated files must be text. Use a `files:` entry for binary contents.",
70 src_path.display()
71 )
72 })?;
73 let rendered_contents = ctx.render_template(src_contents).with_context(|| {
74 format!(
75 "{label_prefix}: render contents of '{}'",
76 src_path.display()
77 )
78 })?;
79
80 let rendered_dst = ctx
81 .render_template(&entry.dst)
82 .with_context(|| format!("{label_prefix}: render dst path"))?;
83 if rendered_dst.is_empty() {
84 return Ok(None);
85 }
86 if rendered_dst.contains("..") || Path::new(&rendered_dst).is_absolute() {
87 bail!(
88 "{label_prefix}: dst '{}' must be a relative path with no '..' segments",
89 rendered_dst
90 );
91 }
92
93 Ok(Some(TemplatedFileRender {
94 rendered_dst,
95 rendered_contents,
96 }))
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use crate::test_helpers::TestContextBuilder;
103 use tempfile::TempDir;
104
105 fn build_ctx() -> Context {
106 TestContextBuilder::new()
107 .project_name("myapp")
108 .tag("v1.0.0")
109 .build()
110 }
111
112 #[test]
113 fn renders_contents_and_dst() {
114 let tmp = TempDir::new().unwrap();
115 let src = tmp.path().join("input.tpl");
116 std::fs::write(&src, "name={{ .ProjectName }} version={{ .Version }}").unwrap();
117
118 let ctx = build_ctx();
119 let entry = TemplateFileConfig {
120 id: Some("e1".to_string()),
121 src: src.to_string_lossy().to_string(),
122 dst: "{{ .ProjectName }}.txt".to_string(),
123 mode: None,
124 skip: None,
125 };
126
127 let out = render_templated_file_entry(&ctx, &entry, "test")
128 .unwrap()
129 .expect("entry should render");
130 assert_eq!(out.rendered_dst, "myapp.txt");
131 assert_eq!(out.rendered_contents, "name=myapp version=1.0.0");
132 }
133
134 #[test]
135 fn skip_eval_true_returns_none() {
136 let tmp = TempDir::new().unwrap();
137 let src = tmp.path().join("input.tpl");
138 std::fs::write(&src, "hi").unwrap();
139
140 let ctx = build_ctx();
141 let entry = TemplateFileConfig {
142 id: None,
143 src: src.to_string_lossy().to_string(),
144 dst: "out.txt".to_string(),
145 mode: None,
146 skip: Some(crate::config::StringOrBool::Bool(true)),
147 };
148
149 let out = render_templated_file_entry(&ctx, &entry, "test").unwrap();
150 assert!(out.is_none(), "skip=true must short-circuit to None");
151 }
152
153 #[test]
154 fn empty_dst_returns_none() {
155 let tmp = TempDir::new().unwrap();
156 let src = tmp.path().join("input.tpl");
157 std::fs::write(&src, "hi").unwrap();
158
159 let ctx = build_ctx();
160 let entry = TemplateFileConfig {
161 id: None,
162 src: src.to_string_lossy().to_string(),
163 dst: String::new(),
165 mode: None,
166 skip: None,
167 };
168
169 let out = render_templated_file_entry(&ctx, &entry, "test").unwrap();
170 assert!(out.is_none(), "empty rendered dst must short-circuit");
171 }
172
173 #[test]
174 fn path_traversal_rejected() {
175 let tmp = TempDir::new().unwrap();
176 let src = tmp.path().join("input.tpl");
177 std::fs::write(&src, "hi").unwrap();
178
179 let ctx = build_ctx();
180 let entry = TemplateFileConfig {
181 id: None,
182 src: src.to_string_lossy().to_string(),
183 dst: "../escape.txt".to_string(),
184 mode: None,
185 skip: None,
186 };
187
188 let err = render_templated_file_entry(&ctx, &entry, "test").unwrap_err();
189 let chain = format!("{err:#}");
190 assert!(
191 chain.contains("relative path") || chain.contains(".."),
192 "expected traversal rejection, got {chain}"
193 );
194 }
195
196 #[test]
197 fn non_utf8_source_errors_clearly() {
198 let tmp = TempDir::new().unwrap();
199 let src = tmp.path().join("binary.bin");
200 std::fs::write(&src, [0xFF, 0xFE, 0x00, 0x80]).unwrap();
201
202 let ctx = build_ctx();
203 let entry = TemplateFileConfig {
204 id: None,
205 src: src.to_string_lossy().to_string(),
206 dst: "out.txt".to_string(),
207 mode: None,
208 skip: None,
209 };
210 let err = render_templated_file_entry(&ctx, &entry, "test").unwrap_err();
211 let chain = format!("{err:#}");
212 assert!(
213 chain.contains("not valid UTF-8"),
214 "expected UTF-8 error, got {chain}"
215 );
216 }
217
218 #[test]
219 fn missing_src_errors_with_label() {
220 let ctx = build_ctx();
221 let entry = TemplateFileConfig {
222 id: None,
223 src: "/nonexistent/file.tpl".to_string(),
224 dst: "out.txt".to_string(),
225 mode: None,
226 skip: None,
227 };
228 let err = render_templated_file_entry(&ctx, &entry, "mylabel").unwrap_err();
229 let chain = format!("{err:#}");
230 assert!(
231 chain.contains("mylabel") && chain.contains("not found"),
232 "expected label + 'not found' in chain, got {chain}"
233 );
234 }
235}