use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::Read;
use std::num::{NonZeroU64, NonZeroUsize};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use super::site::SiteError;
use super::{Lifecycle, LifecycleError, Registry, ServiceRecord};
use crate::config::types::ServiceTemplateConfig;
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplatePolicy {
pub roots: Vec<PathBuf>,
pub command_bytes: NonZeroU64,
pub records: NonZeroUsize,
pub arguments: NonZeroUsize,
pub environment_names: NonZeroUsize,
pub text_bytes: NonZeroUsize,
pub stop_timeout_ms: NonZeroU64,
}
#[path = "templates/configuration.rs"]
mod configuration;
pub use configuration::{TemplateSources, collect_errors};
#[path = "templates/command_file.rs"]
mod command_file;
pub(super) use command_file::open_command;
#[path = "templates/records.rs"]
mod records;
pub use records::{TemplateProposal, TemplateRecord};
#[path = "templates/staging.rs"]
mod staging;
pub(super) use staging::StagedCommand;
impl TemplatePolicy {
pub fn digest(&self, name: &str, command: &Path) -> Result<(PathBuf, String), SiteError> {
let (canonical, file) = self.executable(name, command)?;
Ok((canonical, self.hash_command(file)?))
}
fn executable(
&self,
name: &str,
command: &Path,
) -> Result<(PathBuf, std::fs::File), SiteError> {
if self.roots.is_empty() {
return Err(SiteError::Invalid("[auth.lys.templates] roots"));
}
if !command.is_absolute()
|| command.as_os_str().len() > self.text_bytes.get()
|| command
.to_str()
.is_none_or(|path| path.chars().any(char::is_control))
{
return Err(SiteError::Refused(format!(
"template {name}: command must be an absolute path"
)));
}
let canonical = command
.canonicalize()
.map_err(|_| SiteError::Refused(format!("template {name}: command cannot be read")))?;
let roots = self
.roots
.iter()
.map(|root| {
if !root.is_absolute() {
return Err(SiteError::Invalid("[auth.lys.templates] roots"));
}
let root = root
.canonicalize()
.map_err(|_| SiteError::Invalid("[auth.lys.templates] roots"))?;
if !root.is_dir() {
return Err(SiteError::Invalid("[auth.lys.templates] roots"));
}
Ok(root)
})
.collect::<Result<Vec<_>, _>>()?;
if !roots.iter().any(|root| canonical.starts_with(root)) {
return Err(SiteError::Refused(format!(
"template {name}: {} is outside [auth.lys.templates] roots",
command.display()
)));
}
let before = std::fs::metadata(&canonical)
.map_err(|_| SiteError::Invalid("template command metadata"))?;
if !before.is_file() {
return Err(SiteError::Refused(format!(
"template {name}: {} is not a regular file",
command.display()
)));
}
let file = command_file::open_command(&canonical)?;
let metadata = file
.metadata()
.map_err(|_| SiteError::Invalid("template command metadata"))?;
if !metadata.is_file() {
return Err(SiteError::Refused(format!(
"template {name}: {} is not a regular file",
command.display()
)));
}
if metadata.permissions().mode() & 0o111 == 0 {
return Err(SiteError::Refused(format!(
"template {name}: {} has no execute bit",
command.display()
)));
}
if metadata.len() > self.command_bytes.get() {
return Err(SiteError::Bound("command_bytes"));
}
Ok((canonical, file))
}
fn hash_command(&self, file: std::fs::File) -> Result<String, SiteError> {
let mut bounded = file.take(self.command_bytes.get().saturating_add(1));
let mut hash = Sha256::new();
let mut buffer = [0_u8; 8192];
let mut total = 0_u64;
loop {
let n = bounded
.read(&mut buffer)
.map_err(|_| SiteError::Invalid("template command read"))?;
if n == 0 {
break;
}
total = total
.saturating_add(u64::try_from(n).map_err(|_| SiteError::Bound("command_bytes"))?);
if total > self.command_bytes.get() {
return Err(SiteError::Bound("command_bytes"));
}
hash.update(buffer.get(..n).ok_or(SiteError::Store)?);
}
Ok(hex::encode(hash.finalize()))
}
pub fn validate(&self, proposal: &TemplateProposal) -> Result<(), SiteError> {
if !crate::config::templates::is_segment(&proposal.name)
|| proposal.name.len() > self.text_bytes.get()
{
return Err(SiteError::Invalid("template name"));
}
if proposal.args.len() > self.arguments.get()
|| proposal.env.len() > self.environment_names.get()
{
return Err(SiteError::Bound("template invocation"));
}
if proposal
.args
.iter()
.any(|arg| arg.len() > self.text_bytes.get() || arg.contains('\0'))
{
return Err(SiteError::Invalid("template args"));
}
let mut seen = std::collections::BTreeSet::new();
for name in &proposal.env {
if name.is_empty()
|| name.len() > self.text_bytes.get()
|| name.starts_with("LIMINAL_")
|| !name.bytes().enumerate().all(|(n, byte)| {
byte.is_ascii_alphabetic() || byte == b'_' || (n > 0 && byte.is_ascii_digit())
})
|| !seen.insert(name)
{
return Err(SiteError::Invalid("template env names"));
}
}
if proposal.stop_timeout_ms == 0 || proposal.stop_timeout_ms > self.stop_timeout_ms.get() {
return Err(SiteError::Bound("stop_timeout_ms"));
}
Ok(())
}
}
impl Lifecycle {
pub(crate) fn propose_template(
&self,
service: &str,
command: &Path,
args: Vec<String>,
env: Vec<String>,
stop_timeout_ms: u64,
) -> Result<TemplateProposal, SiteError> {
let record = self.building(service)?;
let policy = self
.site_bindings()?
.templates
.as_ref()
.ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
let (command, digest) = policy.digest(&record.name, command)?;
let proposal = TemplateProposal {
name: record.name,
service: record.id,
command,
digest,
args,
env,
stop_timeout_ms,
subjects: record.subjects,
};
policy.validate(&proposal)?;
Ok(proposal)
}
pub(crate) fn prepare_template_update(
&self,
service: &str,
command: &Path,
args: Vec<String>,
env: Vec<String>,
stop_timeout_ms: u64,
) -> Result<TemplateProposal, SiteError> {
let record = self.record(service)?;
if record.purpose.is_none() || record.state == super::ServiceState::Retired {
return Err(SiteError::Invalid("template site absent or retired"));
}
let name = record
.template
.clone()
.ok_or(SiteError::Invalid("template absent"))?;
let policy = self
.site_bindings()?
.templates
.as_ref()
.ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
let (command, digest) = policy.digest(&name, command)?;
let proposal = TemplateProposal {
name,
service: record.id,
command,
args,
env,
stop_timeout_ms,
digest,
subjects: record.subjects,
};
policy.validate(&proposal)?;
Ok(proposal)
}
pub(super) fn template_for(
&self,
registry: &Registry,
record: &ServiceRecord,
) -> Result<ServiceTemplateConfig, LifecycleError> {
let name = record
.template
.as_ref()
.ok_or_else(|| LifecycleError::UnknownTemplate("none".into()))?;
if let Some(template) = self.templates.get(name) {
return Ok(template.clone());
}
let template = registry
.construction
.templates
.get(name)
.filter(|template| !template.retired)
.ok_or_else(|| LifecycleError::UnknownTemplate(name.clone()))?;
if template.proposal.subjects != record.subjects {
return Err(LifecycleError::Process(format!(
"template {name}: subjects differ from site {}",
record.name
)));
}
Ok(ServiceTemplateConfig {
name: name.clone(),
command: template.proposal.command.clone(),
args: template.proposal.args.clone(),
env: template.proposal.env.clone(),
subjects: template.proposal.subjects.clone(),
stop_timeout_ms: template.proposal.stop_timeout_ms,
})
}
pub(super) fn verify_template(&self, template: &TemplateRecord) -> Result<(), SiteError> {
if template.retired {
return Err(SiteError::Refused(format!(
"template {} is retired",
template.proposal.name
)));
}
let policy = self
.site_bindings()?
.templates
.as_ref()
.ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
let (_, actual) = policy.digest(&template.proposal.name, &template.proposal.command)?;
if actual != template.proposal.digest {
return Err(staging::digest_changed(template, &actual));
}
Ok(())
}
}
#[cfg(test)]
#[path = "templates_tests.rs"]
mod tests;