1use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28use std::{fmt, fs, io};
29
30use garde::Validate;
31use minijinja::value::Value;
32use minijinja::{Environment, State};
33use serde::{Deserialize, Serialize};
34
35use crate::validate::{inside_the_repo, one_file_name};
36
37#[derive(Debug, Deserialize, Validate)]
40#[garde(allow_unvalidated)]
41struct Declared {
42 #[serde(default)]
43 #[garde(custom(inside_the_repo))]
44 path: PathBuf,
45 #[garde(custom(one_file_name))]
47 name: String,
48 #[serde(default)]
49 executable: bool,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct Served {
58 target: PathBuf,
59 body: String,
60 executable: bool,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
71#[non_exhaustive]
72pub struct ServeError {
73 pub template: String,
76 pub message: String,
78 io_failure: Option<io::ErrorKind>,
80}
81
82impl ServeError {
83 const fn refusal(template: String, message: String) -> Self {
85 Self {
86 template,
87 message,
88 io_failure: None,
89 }
90 }
91
92 fn failed_io(template: String, message: String, error: &io::Error) -> Self {
94 Self {
95 io_failure: Some(error.kind()),
96 ..Self::refusal(template, message)
97 }
98 }
99
100 #[must_use]
105 pub const fn io_failure(&self) -> Option<io::ErrorKind> {
106 self.io_failure
107 }
108}
109
110impl fmt::Display for ServeError {
111 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112 write!(formatter, "{}: {}", self.template, self.message)
113 }
114}
115
116impl std::error::Error for ServeError {}
117
118pub fn render(
133 template: &Path,
134 source: &str,
135 suffix: &str,
136 context: &impl Serialize,
137) -> Result<Served, ServeError> {
138 let failed = |message: String| ServeError::refusal(template.display().to_string(), message);
139 let own_name = template
140 .file_name()
141 .and_then(|name| name.to_str())
142 .and_then(|name| name.strip_suffix(suffix))
143 .filter(|name| !name.is_empty())
144 .ok_or_else(|| failed(format!("template name does not end in {suffix}")))?;
145 let environment: Environment<'static> = crate::surface::environment()
146 .map_err(|error| failed(format!("register ctl-core fragments: {error}")))?;
147 let name = template.display().to_string();
148 let compiled = environment
149 .template_from_named_str(&name, source)
150 .map_err(|error| failed(format!("parse: {error}")))?;
151 let captured = compiled
152 .render_captured(Value::from_serialize(context))
153 .map_err(|error| failed(format!("render: {error}")))?;
154 let declared = declarations(captured.state(), own_name)
155 .map_err(|message| failed(format!("declarations: {message}")))?;
156 Ok(Served {
157 target: declared.path.join(&declared.name),
158 body: captured.output().to_owned(),
159 executable: declared.executable,
160 })
161}
162
163fn declarations(state: &State<'_, '_>, own_name: &str) -> Result<Declared, String> {
166 let mut exports: BTreeMap<&str, Value> = state
167 .exports()
168 .into_iter()
169 .filter_map(|name| state.lookup(name).map(|value| (name, value)))
170 .filter(|(_, value)| !value.is_undefined() && !value.is_none())
171 .collect();
172 exports
173 .entry("name")
174 .or_insert_with(|| Value::from(own_name));
175 let declared = Declared::deserialize(Value::from_serialize(&exports))
176 .map_err(|error| error.to_string())?;
177 declared.validate().map_err(|report| report.to_string())?;
178 Ok(declared)
179}
180
181impl Served {
182 #[must_use]
184 pub fn target(&self) -> &Path {
185 &self.target
186 }
187
188 #[must_use]
190 pub fn body(&self) -> &str {
191 &self.body
192 }
193
194 #[must_use]
196 pub fn executable(&self) -> bool {
197 self.executable
198 }
199
200 pub fn write(&self, root: &Path) -> Result<PathBuf, ServeError> {
212 let failed =
213 |message: String| ServeError::refusal(self.target.display().to_string(), message);
214 let io_failed = |message: String, error: &io::Error| {
215 ServeError::failed_io(self.target.display().to_string(), message, error)
216 };
217 let mut walked = root.to_path_buf();
218 for part in self.target.components() {
219 walked.push(part);
220 if fs::symlink_metadata(&walked).is_ok_and(|meta| meta.file_type().is_symlink()) {
221 return Err(failed(format!(
222 "{} must not be a symlink",
223 walked.strip_prefix(root).unwrap_or(&walked).display()
224 )));
225 }
226 }
227 let path = root.join(&self.target);
228 if let Some(parent) = path.parent() {
229 fs::create_dir_all(parent).map_err(|error| {
230 io_failed(format!("create {}: {error}", parent.display()), &error)
231 })?;
232 let inside = fs::canonicalize(root)
233 .and_then(|real_root| Ok(fs::canonicalize(parent)?.starts_with(real_root)))
234 .map_err(|error| {
235 io_failed(format!("resolve {}: {error}", parent.display()), &error)
236 })?;
237 if !inside {
238 return Err(failed("target must stay inside the repository".to_owned()));
239 }
240 }
241 fs::write(&path, &self.body)
242 .map_err(|error| io_failed(format!("write: {error}"), &error))?;
243 #[cfg(unix)]
244 {
245 use std::os::unix::fs::PermissionsExt;
246 let mode = if self.executable { 0o755 } else { 0o644 };
247 fs::set_permissions(&path, fs::Permissions::from_mode(mode))
248 .map_err(|error| io_failed(format!("chmod: {error}"), &error))?;
249 }
250 Ok(path)
251 }
252}