1use serde::Deserialize;
4use sha2::{Digest, Sha256};
5use std::io::Read;
6use std::num::{NonZeroU64, NonZeroUsize};
7use std::os::unix::fs::PermissionsExt;
8use std::path::{Path, PathBuf};
9
10use super::site::SiteError;
11use super::{Lifecycle, LifecycleError, Registry, ServiceRecord};
12use crate::config::types::ServiceTemplateConfig;
13
14#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct TemplatePolicy {
18 pub roots: Vec<PathBuf>,
21 pub command_bytes: NonZeroU64,
23 pub records: NonZeroUsize,
25 pub arguments: NonZeroUsize,
27 pub environment_names: NonZeroUsize,
29 pub text_bytes: NonZeroUsize,
31 pub stop_timeout_ms: NonZeroU64,
33}
34
35#[path = "templates/configuration.rs"]
36mod configuration;
37pub use configuration::{TemplateSources, collect_errors};
38#[path = "templates/command_file.rs"]
39mod command_file;
40pub(super) use command_file::open_command;
41
42#[path = "templates/records.rs"]
43mod records;
44pub use records::{TemplateProposal, TemplateRecord};
45#[path = "templates/staging.rs"]
46mod staging;
47pub(super) use staging::StagedCommand;
48
49impl TemplatePolicy {
50 pub fn digest(&self, name: &str, command: &Path) -> Result<(PathBuf, String), SiteError> {
55 let (canonical, file) = self.executable(name, command)?;
56 Ok((canonical, self.hash_command(file)?))
57 }
58 fn executable(
59 &self,
60 name: &str,
61 command: &Path,
62 ) -> Result<(PathBuf, std::fs::File), SiteError> {
63 if self.roots.is_empty() {
64 return Err(SiteError::Invalid("[auth.lys.templates] roots"));
65 }
66 if !command.is_absolute()
67 || command.as_os_str().len() > self.text_bytes.get()
68 || command
69 .to_str()
70 .is_none_or(|path| path.chars().any(char::is_control))
71 {
72 return Err(SiteError::Refused(format!(
73 "template {name}: command must be an absolute path"
74 )));
75 }
76 let canonical = command
77 .canonicalize()
78 .map_err(|_| SiteError::Refused(format!("template {name}: command cannot be read")))?;
79 let roots = self
80 .roots
81 .iter()
82 .map(|root| {
83 if !root.is_absolute() {
84 return Err(SiteError::Invalid("[auth.lys.templates] roots"));
85 }
86 let root = root
87 .canonicalize()
88 .map_err(|_| SiteError::Invalid("[auth.lys.templates] roots"))?;
89 if !root.is_dir() {
90 return Err(SiteError::Invalid("[auth.lys.templates] roots"));
91 }
92 Ok(root)
93 })
94 .collect::<Result<Vec<_>, _>>()?;
95 if !roots.iter().any(|root| canonical.starts_with(root)) {
96 return Err(SiteError::Refused(format!(
97 "template {name}: {} is outside [auth.lys.templates] roots",
98 command.display()
99 )));
100 }
101 let before = std::fs::metadata(&canonical)
102 .map_err(|_| SiteError::Invalid("template command metadata"))?;
103 if !before.is_file() {
104 return Err(SiteError::Refused(format!(
105 "template {name}: {} is not a regular file",
106 command.display()
107 )));
108 }
109 let file = command_file::open_command(&canonical)?;
110 let metadata = file
111 .metadata()
112 .map_err(|_| SiteError::Invalid("template command metadata"))?;
113 if !metadata.is_file() {
114 return Err(SiteError::Refused(format!(
115 "template {name}: {} is not a regular file",
116 command.display()
117 )));
118 }
119 if metadata.permissions().mode() & 0o111 == 0 {
120 return Err(SiteError::Refused(format!(
121 "template {name}: {} has no execute bit",
122 command.display()
123 )));
124 }
125 if metadata.len() > self.command_bytes.get() {
126 return Err(SiteError::Bound("command_bytes"));
127 }
128 Ok((canonical, file))
129 }
130 fn hash_command(&self, file: std::fs::File) -> Result<String, SiteError> {
131 let mut bounded = file.take(self.command_bytes.get().saturating_add(1));
132 let mut hash = Sha256::new();
133 let mut buffer = [0_u8; 8192];
134 let mut total = 0_u64;
135 loop {
136 let n = bounded
137 .read(&mut buffer)
138 .map_err(|_| SiteError::Invalid("template command read"))?;
139 if n == 0 {
140 break;
141 }
142 total = total
143 .saturating_add(u64::try_from(n).map_err(|_| SiteError::Bound("command_bytes"))?);
144 if total > self.command_bytes.get() {
145 return Err(SiteError::Bound("command_bytes"));
146 }
147 hash.update(buffer.get(..n).ok_or(SiteError::Store)?);
148 }
149 Ok(hex::encode(hash.finalize()))
150 }
151
152 pub fn validate(&self, proposal: &TemplateProposal) -> Result<(), SiteError> {
157 if !crate::config::templates::is_segment(&proposal.name)
158 || proposal.name.len() > self.text_bytes.get()
159 {
160 return Err(SiteError::Invalid("template name"));
161 }
162 if proposal.args.len() > self.arguments.get()
163 || proposal.env.len() > self.environment_names.get()
164 {
165 return Err(SiteError::Bound("template invocation"));
166 }
167 if proposal
168 .args
169 .iter()
170 .any(|arg| arg.len() > self.text_bytes.get() || arg.contains('\0'))
171 {
172 return Err(SiteError::Invalid("template args"));
173 }
174 let mut seen = std::collections::BTreeSet::new();
175 for name in &proposal.env {
176 if name.is_empty()
177 || name.len() > self.text_bytes.get()
178 || name.starts_with("LIMINAL_")
179 || !name.bytes().enumerate().all(|(n, byte)| {
180 byte.is_ascii_alphabetic() || byte == b'_' || (n > 0 && byte.is_ascii_digit())
181 })
182 || !seen.insert(name)
183 {
184 return Err(SiteError::Invalid("template env names"));
185 }
186 }
187 if proposal.stop_timeout_ms == 0 || proposal.stop_timeout_ms > self.stop_timeout_ms.get() {
188 return Err(SiteError::Bound("stop_timeout_ms"));
189 }
190 Ok(())
191 }
192}
193
194impl Lifecycle {
195 pub(crate) fn propose_template(
200 &self,
201 service: &str,
202 command: &Path,
203 args: Vec<String>,
204 env: Vec<String>,
205 stop_timeout_ms: u64,
206 ) -> Result<TemplateProposal, SiteError> {
207 let record = self.building(service)?;
208 let policy = self
209 .site_bindings()?
210 .templates
211 .as_ref()
212 .ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
213 let (command, digest) = policy.digest(&record.name, command)?;
214 let proposal = TemplateProposal {
215 name: record.name,
216 service: record.id,
217 command,
218 digest,
219 args,
220 env,
221 stop_timeout_ms,
222 subjects: record.subjects,
223 };
224 policy.validate(&proposal)?;
225 Ok(proposal)
226 }
227
228 pub(crate) fn prepare_template_update(
234 &self,
235 service: &str,
236 command: &Path,
237 args: Vec<String>,
238 env: Vec<String>,
239 stop_timeout_ms: u64,
240 ) -> Result<TemplateProposal, SiteError> {
241 let record = self.record(service)?;
242 if record.purpose.is_none() || record.state == super::ServiceState::Retired {
243 return Err(SiteError::Invalid("template site absent or retired"));
244 }
245 let name = record
246 .template
247 .clone()
248 .ok_or(SiteError::Invalid("template absent"))?;
249 let policy = self
250 .site_bindings()?
251 .templates
252 .as_ref()
253 .ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
254 let (command, digest) = policy.digest(&name, command)?;
255 let proposal = TemplateProposal {
256 name,
257 service: record.id,
258 command,
259 args,
260 env,
261 stop_timeout_ms,
262 digest,
263 subjects: record.subjects,
264 };
265 policy.validate(&proposal)?;
266 Ok(proposal)
267 }
268
269 pub(super) fn template_for(
270 &self,
271 registry: &Registry,
272 record: &ServiceRecord,
273 ) -> Result<ServiceTemplateConfig, LifecycleError> {
274 let name = record
275 .template
276 .as_ref()
277 .ok_or_else(|| LifecycleError::UnknownTemplate("none".into()))?;
278 if let Some(template) = self.templates.get(name) {
279 return Ok(template.clone());
280 }
281 let template = registry
282 .construction
283 .templates
284 .get(name)
285 .filter(|template| !template.retired)
286 .ok_or_else(|| LifecycleError::UnknownTemplate(name.clone()))?;
287 if template.proposal.subjects != record.subjects {
288 return Err(LifecycleError::Process(format!(
289 "template {name}: subjects differ from site {}",
290 record.name
291 )));
292 }
293 Ok(ServiceTemplateConfig {
294 name: name.clone(),
295 command: template.proposal.command.clone(),
296 args: template.proposal.args.clone(),
297 env: template.proposal.env.clone(),
298 subjects: template.proposal.subjects.clone(),
299 stop_timeout_ms: template.proposal.stop_timeout_ms,
300 })
301 }
302
303 pub(super) fn verify_template(&self, template: &TemplateRecord) -> Result<(), SiteError> {
304 if template.retired {
305 return Err(SiteError::Refused(format!(
306 "template {} is retired",
307 template.proposal.name
308 )));
309 }
310 let policy = self
311 .site_bindings()?
312 .templates
313 .as_ref()
314 .ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
315 let (_, actual) = policy.digest(&template.proposal.name, &template.proposal.command)?;
316 if actual != template.proposal.digest {
317 return Err(staging::digest_changed(template, &actual));
318 }
319 Ok(())
320 }
321}
322
323#[cfg(test)]
324#[path = "templates_tests.rs"]
325mod tests;