liminal-server 0.25.4

Standalone server for the liminal messaging bus
Documentation
//! `[[auth.lys.templates]]` (SRV-028 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.

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 Some(lys) = config.auth.as_ref().and_then(|auth| auth.lys.as_ref()) else {
        return;
    };
    if lys.templates.is_empty() {
        return;
    }
    if config.persistence_path.is_none() {
        errors.push(
            "auth.lys.templates: a service registry needs persistence_path to be kept".to_owned(),
        );
    }
    if lys.app_credential_path.is_none() {
        errors.push(
            "auth.lys.templates: app_credential_path is required to place a new service in Lys"
                .to_owned(),
        );
    }
    let mut names = BTreeSet::new();
    let mut roots = BTreeSet::new();
    for template in &lys.templates {
        if !names.insert(template.name.as_str()) {
            errors.push(format!(
                "auth.lys.templates '{}': the name is used twice",
                template.name
            ));
        }
        for root in &template.subjects {
            if !roots.insert(root.as_str()) {
                errors.push(format!(
                    "auth.lys.templates '{}': subject '{root}' is another template's too",
                    template.name
                ));
            }
        }
        errors.extend(
            template_faults(template)
                .into_iter()
                .map(|fault| format!("auth.lys.templates '{}': {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;