liminal-server 0.24.9

Standalone server for the liminal messaging bus
Documentation
//! Live approved templates, constrained by operator roots and SHA-256 pins.

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;

/// Required roots and positive bounds for live executable records.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplatePolicy {
    /// Commands run as the exchange's user; only operator-approved directories
    /// may supply them. Canonical paths prevent symlinks escaping these roots.
    pub roots: Vec<PathBuf>,
    /// Bounds command bytes read and hashed at proposal, execution and start.
    pub command_bytes: NonZeroU64,
    /// Bounds live and retired template records; no historical record is evicted.
    pub records: NonZeroUsize,
    /// Bounds argument count before constructing a process invocation.
    pub arguments: NonZeroUsize,
    /// Bounds environment-name count; values are never accepted from a request.
    pub environment_names: NonZeroUsize,
    /// Bounds each argument, environment name, path and template name.
    pub text_bytes: NonZeroUsize,
    /// Bounds a requested termination deadline so approval cannot stall shutdown.
    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 {
    /// Reads one regular executable under a canonical root with a bounded hash.
    ///
    /// # Errors
    /// A named path, root, regular-file, execute-bit or byte-bound refusal.
    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()))
    }

    /// Validates the whole invocation before any draft is held.
    ///
    /// # Errors
    /// Invalid names, counts, arguments, environment names or stop deadline.
    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 {
    /// Validates and pins a proposed command against the site's exact contracts.
    ///
    /// # Errors
    /// No roots, invalid invocation, path or digest failure.
    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)
    }

    /// Prepares an update on an existing record without changing its contracts.
    /// The caller's template update action is still decided and held by Lys.
    ///
    /// # Errors
    /// Missing record, roots, invalid invocation or retired site.
    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;