1use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28use std::{fmt, fs};
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)]
54pub struct Served {
55 target: PathBuf,
56 body: String,
57 executable: bool,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct ServeError {
63 pub template: String,
65 pub message: String,
67}
68
69impl fmt::Display for ServeError {
70 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71 write!(formatter, "{}: {}", self.template, self.message)
72 }
73}
74
75impl std::error::Error for ServeError {}
76
77pub fn render(
89 template: &Path,
90 source: &str,
91 suffix: &str,
92 context: &impl Serialize,
93) -> Result<Served, ServeError> {
94 let failed = |message: String| ServeError {
95 template: template.display().to_string(),
96 message,
97 };
98 let own_name = template
99 .file_name()
100 .and_then(|name| name.to_str())
101 .and_then(|name| name.strip_suffix(suffix))
102 .filter(|name| !name.is_empty())
103 .ok_or_else(|| failed(format!("template name does not end in {suffix}")))?;
104 let environment: Environment<'static> = crate::surface::environment()
105 .map_err(|error| failed(format!("register ctl-core fragments: {error}")))?;
106 let name = template.display().to_string();
107 let compiled = environment
108 .template_from_named_str(&name, source)
109 .map_err(|error| failed(format!("parse: {error}")))?;
110 let captured = compiled
111 .render_captured(Value::from_serialize(context))
112 .map_err(|error| failed(format!("render: {error}")))?;
113 let declared = declarations(captured.state(), own_name)
114 .map_err(|message| failed(format!("declarations: {message}")))?;
115 Ok(Served {
116 target: declared.path.join(&declared.name),
117 body: captured.output().to_owned(),
118 executable: declared.executable,
119 })
120}
121
122fn declarations(state: &State<'_, '_>, own_name: &str) -> Result<Declared, String> {
125 let mut exports: BTreeMap<&str, Value> = state
126 .exports()
127 .into_iter()
128 .filter_map(|name| state.lookup(name).map(|value| (name, value)))
129 .filter(|(_, value)| !value.is_undefined() && !value.is_none())
130 .collect();
131 exports
132 .entry("name")
133 .or_insert_with(|| Value::from(own_name));
134 let declared = Declared::deserialize(Value::from_serialize(&exports))
135 .map_err(|error| error.to_string())?;
136 declared.validate().map_err(|report| report.to_string())?;
137 Ok(declared)
138}
139
140impl Served {
141 #[must_use]
143 pub fn target(&self) -> &Path {
144 &self.target
145 }
146
147 #[must_use]
149 pub fn body(&self) -> &str {
150 &self.body
151 }
152
153 #[must_use]
155 pub fn executable(&self) -> bool {
156 self.executable
157 }
158
159 #[cfg(feature = "baseline")]
162 pub(crate) fn with_body(self, body: String) -> Self {
163 Self { body, ..self }
164 }
165
166 pub fn write(&self, root: &Path) -> Result<PathBuf, ServeError> {
176 let failed = |message: String| ServeError {
177 template: self.target.display().to_string(),
178 message,
179 };
180 let mut walked = root.to_path_buf();
181 for part in self.target.components() {
182 walked.push(part);
183 if fs::symlink_metadata(&walked).is_ok_and(|meta| meta.file_type().is_symlink()) {
184 return Err(failed(format!(
185 "{} must not be a symlink",
186 walked.strip_prefix(root).unwrap_or(&walked).display()
187 )));
188 }
189 }
190 let path = root.join(&self.target);
191 if let Some(parent) = path.parent() {
192 fs::create_dir_all(parent)
193 .map_err(|error| failed(format!("create {}: {error}", parent.display())))?;
194 let inside = fs::canonicalize(root)
195 .and_then(|real_root| Ok(fs::canonicalize(parent)?.starts_with(real_root)))
196 .map_err(|error| failed(format!("resolve {}: {error}", parent.display())))?;
197 if !inside {
198 return Err(failed("target must stay inside the repository".to_owned()));
199 }
200 }
201 fs::write(&path, &self.body).map_err(|error| failed(format!("write: {error}")))?;
202 #[cfg(unix)]
203 {
204 use std::os::unix::fs::PermissionsExt;
205 let mode = if self.executable { 0o755 } else { 0o644 };
206 fs::set_permissions(&path, fs::Permissions::from_mode(mode))
207 .map_err(|error| failed(format!("chmod: {error}")))?;
208 }
209 Ok(path)
210 }
211}