liminal-server 0.27.3

Standalone server for the liminal messaging bus
Documentation
//! `[[auth.lys.templates]]` (SRV-028 R1) or the top-level `[[templates]]`
//! (ACCESS-012 R1): the only command lines this server runs for a seat.
//!
//! They are checked whole at start, so a bad template stops the server by name
//! instead of failing the first seat that uses it. Each fault is named with
//! the place the templates were written in.

use std::collections::BTreeSet;
use std::os::unix::fs::PermissionsExt;

use super::types::{ServerConfig, ServiceTemplateConfig};

/// Pushes one named error for each fault in the configured templates.
pub(super) fn validate_templates(config: &ServerConfig, errors: &mut Vec<String>) {
    let templates = config.template_sources();
    if templates.is_empty() {
        return;
    }
    let place = config.templates_place();
    if config.persistence_path.is_none() {
        errors.push(format!(
            "{place}: a service registry needs persistence_path to be kept"
        ));
    }
    let lys = config.auth.as_ref().and_then(|auth| auth.lys.as_ref());
    if lys.is_some_and(|lys| lys.app_credential_path.is_none()) {
        errors.push(format!(
            "{place}: app_credential_path is required to place a new service in Lys"
        ));
    }
    let mut names = BTreeSet::new();
    let mut roots = BTreeSet::new();
    for template in templates {
        if !names.insert(template.name.as_str()) {
            errors.push(format!(
                "{place} '{}': the name is used twice",
                template.name
            ));
        }
        for root in &template.subjects {
            if !roots.insert(root.as_str()) {
                errors.push(format!(
                    "{place} '{}': subject '{root}' is another template's too",
                    template.name
                ));
            }
        }
        errors.extend(
            template_faults(template)
                .into_iter()
                .map(|fault| format!("{place} '{}': {fault}", template.name)),
        );
    }
}

fn template_faults(template: &ServiceTemplateConfig) -> Vec<String> {
    let mut faults = Vec::new();
    if !is_segment(&template.name) {
        faults.push("the name must be one segment of letters, digits, '-' or '_'".to_owned());
    }
    if template.subjects.is_empty() {
        faults.push("subjects must name at least one subject".to_owned());
    }
    if let Some(bad) = template.subjects.iter().find(|root| !is_subject(root)) {
        faults.push(format!(
            "subject '{bad}' must be dot-separated segments with no wildcard"
        ));
    }
    if template.stop_timeout_ms == 0 {
        faults.push("stop_timeout_ms must be positive".to_owned());
    }
    if template.command.is_absolute() {
        match std::fs::metadata(&template.command) {
            Ok(meta) if meta.is_file() && meta.permissions().mode() & 0o111 != 0 => {}
            Ok(_) => faults.push(format!(
                "command '{}' is not an executable file",
                template.command.display()
            )),
            Err(error) => faults.push(format!(
                "command '{}' cannot be read: {}",
                template.command.display(),
                error.kind()
            )),
        }
    } else {
        faults.push(format!(
            "command '{}' must be an absolute path",
            template.command.display()
        ));
    }
    faults
}

/// One subject segment: non-empty letters, digits, `-` or `_`.
#[must_use]
pub fn is_segment(value: &str) -> bool {
    !value.is_empty()
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}

fn is_subject(value: &str) -> bool {
    value.split('.').all(is_segment)
}

#[cfg(test)]
#[path = "templates_tests.rs"]
mod tests;