Skip to main content

liminal_server/services/
templates.rs

1//! Live approved templates, constrained by operator roots and SHA-256 pins.
2
3use 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/// Required roots and positive bounds for live executable records.
15#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct TemplatePolicy {
18    /// Commands run as the exchange's user; only operator-approved directories
19    /// may supply them. Canonical paths prevent symlinks escaping these roots.
20    pub roots: Vec<PathBuf>,
21    /// Bounds command bytes read and hashed at proposal, execution and start.
22    pub command_bytes: NonZeroU64,
23    /// Bounds live and retired template records; no historical record is evicted.
24    pub records: NonZeroUsize,
25    /// Bounds argument count before constructing a process invocation.
26    pub arguments: NonZeroUsize,
27    /// Bounds environment-name count; values are never accepted from a request.
28    pub environment_names: NonZeroUsize,
29    /// Bounds each argument, environment name, path and template name.
30    pub text_bytes: NonZeroUsize,
31    /// Bounds a requested termination deadline so approval cannot stall shutdown.
32    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    /// Reads one regular executable under a canonical root with a bounded hash.
51    ///
52    /// # Errors
53    /// A named path, root, regular-file, execute-bit or byte-bound refusal.
54    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    /// Validates the whole invocation before any draft is held.
153    ///
154    /// # Errors
155    /// Invalid names, counts, arguments, environment names or stop deadline.
156    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    /// Validates and pins a proposed command against the site's exact contracts.
196    ///
197    /// # Errors
198    /// No roots, invalid invocation, path or digest failure.
199    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    /// Prepares an update on an existing record without changing its contracts.
229    /// The caller's template update action is still decided and held by Lys.
230    ///
231    /// # Errors
232    /// Missing record, roots, invalid invocation or retired site.
233    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;