liminal-server 0.24.9

Standalone server for the liminal messaging bus
Documentation
use crate::config::types::ServerConfig;
use std::collections::BTreeSet;

/// Semantic checks for the optional `[websocket]` section (LP-WS-TRANSPORT R1.1).
///
/// The acceptor is explicit opt-in; when the section is present its listen
/// address must be a usable, isolated port and its upgrade path must be a single
/// exact absolute path. The origin allow-list FAILS CLOSED by design, so an
/// absent or empty list is VALID configuration (it refuses every Origin-bearing
/// upgrade); individual entries are still checked for obvious malformation so a
/// typo'd entry cannot silently never match. A configured-but-zero keepalive
/// interval is rejected: zero is not "disabled" (absence is) and would otherwise
/// be an unbounded ping rate.
pub(super) fn validate_websocket(config: &ServerConfig, errors: &mut Vec<String>) {
    let Some(websocket) = config.websocket.as_ref() else {
        return;
    };
    validate_websocket_endpoint(config, websocket, errors);
    validate_websocket_origins(websocket, errors);
    validate_websocket_origin_ports(websocket, errors);
    validate_websocket_keepalive(websocket, errors);
}

/// Address and upgrade-path rules for the `[websocket]` section.
fn validate_websocket_endpoint(
    config: &ServerConfig,
    websocket: &crate::config::types::WebSocketConfig,
    errors: &mut Vec<String>,
) {
    if websocket.listen_address.port() == 0 {
        errors.push("websocket.listen_address: port must be non-zero".to_owned());
    }
    if websocket.listen_address == config.listen_address {
        errors.push(
            "websocket.listen_address: must differ from listen_address (the WebSocket \
             acceptor is a sibling listener, not the main wire port)"
                .to_owned(),
        );
    }
    if websocket.listen_address == config.health_listen_address {
        errors.push("websocket.listen_address: must differ from health_listen_address".to_owned());
    }
    if let Some(cluster) = config.cluster.as_ref() {
        if websocket.listen_address == cluster.listen_address {
            errors.push(
                "websocket.listen_address: must differ from cluster.listen_address".to_owned(),
            );
        }
    }

    if websocket.path.is_empty() {
        errors.push("websocket.path: upgrade path must not be empty".to_owned());
    } else if !websocket.path.starts_with('/') {
        errors.push("websocket.path: upgrade path must start with '/'".to_owned());
    }
    if websocket
        .path
        .chars()
        .any(|character| character.is_ascii_whitespace() || character.is_ascii_control())
    {
        errors.push(
            "websocket.path: upgrade path must not contain whitespace or control characters"
                .to_owned(),
        );
    }
    if websocket.path.contains(['?', '#']) {
        errors.push(
            "websocket.path: upgrade path is a single exact path and must not carry a query \
             or fragment"
                .to_owned(),
        );
    }
}

/// F6 allow-list entry hygiene: the list itself may be empty (fail closed),
/// but a present entry must be a serialized origin that COULD ever match.
fn validate_websocket_origins(
    websocket: &crate::config::types::WebSocketConfig,
    errors: &mut Vec<String>,
) {
    let mut seen_origins = BTreeSet::new();
    for origin in &websocket.allowed_origins {
        if origin.is_empty() {
            errors.push("websocket.allowed_origins: origin entries must not be empty".to_owned());
            continue;
        }
        if origin
            .chars()
            .any(|character| character.is_ascii_whitespace() || character.is_ascii_control())
        {
            errors.push(format!(
                "websocket.allowed_origins: origin '{origin}' must not contain whitespace or \
                 control characters"
            ));
        }
        // `null` is the serialized opaque origin (sandboxed documents); every
        // other legal entry is a scheme://host[:port] serialization with no
        // trailing slash or path (RFC 6454 — the browser sends exactly that
        // serialization, so anything else could never match).
        if origin != "null" {
            match origin.split_once("://") {
                None => errors.push(format!(
                    "websocket.allowed_origins: origin '{origin}' must be a serialized origin \
                     (scheme://host[:port]) or the literal 'null'"
                )),
                Some((scheme, rest)) => {
                    if scheme.is_empty() || rest.is_empty() {
                        errors.push(format!(
                            "websocket.allowed_origins: origin '{origin}' must name both a \
                             scheme and a host"
                        ));
                    }
                    if rest.contains('/') {
                        errors.push(format!(
                            "websocket.allowed_origins: origin '{origin}' must not contain a \
                             path or trailing slash (a serialized Origin header never does)"
                        ));
                    }
                }
            }
        }
        if !seen_origins.insert(origin.as_str()) {
            errors.push(format!(
                "websocket.allowed_origins: duplicate origin '{origin}'"
            ));
        }
    }
}

/// CON-014 R2: a listed console port is a real port, listed once.
fn validate_websocket_origin_ports(
    websocket: &crate::config::types::WebSocketConfig,
    errors: &mut Vec<String>,
) {
    let mut seen = BTreeSet::new();
    for port in &websocket.allowed_origin_ports {
        if *port == 0 {
            errors.push("websocket.allowed_origin_ports: port 0 is not a port".to_owned());
        }
        if !seen.insert(*port) {
            errors.push(format!(
                "websocket.allowed_origin_ports: duplicate port {port}"
            ));
        }
    }
}

/// Q-A keepalive rules: zero is not "disabled" (absence is), and an extreme
/// interval must refuse at validation rather than panic on clock arithmetic.
fn validate_websocket_keepalive(
    websocket: &crate::config::types::WebSocketConfig,
    errors: &mut Vec<String>,
) {
    match websocket.ping_interval_ms {
        Some(0) => errors.push(
            "websocket.ping_interval_ms: must be greater than zero when configured (omit the \
             key to disable keepalive pings)"
                .to_owned(),
        ),
        Some(interval_ms) => {
            // S5 precedent: an extreme duration must be a typed refusal at
            // validation, never a later monotonic-clock addition panic.
            let interval = std::time::Duration::from_millis(interval_ms);
            if std::time::Instant::now().checked_add(interval).is_none() {
                errors.push(format!(
                    "websocket.ping_interval_ms: {interval_ms} overflows the monotonic clock"
                ));
            }
        }
        None => {}
    }
}