nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! Optional shared-secret wrapper for bus frames (full-admin or scoped module tokens).

use std::collections::HashSet;

use kdl::KdlNode;

/// Bus authorization level resolved from configured tokens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BusAccess {
    /// No bus tokens configured — commands are unauthenticated.
    Open,
    /// Matched the primary `bus_token` — all modules permitted.
    Full,
    /// Matched a scoped token — only listed modules permitted.
    Scoped(HashSet<String>),
}

/// Resolved bus authorization for one request frame.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BusAuth {
    pub access: BusAccess,
}

impl BusAuth {
    #[must_use]
    pub fn open() -> Self {
        Self {
            access: BusAccess::Open,
        }
    }

    #[must_use]
    pub fn allows_module(&self, module: &str) -> bool {
        match &self.access {
            BusAccess::Open | BusAccess::Full => true,
            BusAccess::Scoped(modules) => modules.contains(module),
        }
    }

    /// `ping` / `modules` require open bus or full-admin token — not scoped tokens.
    #[must_use]
    pub fn permits_global_probe(&self) -> bool {
        matches!(self.access, BusAccess::Open | BusAccess::Full)
    }
}

/// Load expected full-admin token from security config (env, file, or KDL).
#[must_use]
pub fn token_from_env() -> Option<String> {
    crate::security::bus_token()
}

/// True when at least one bus credential (full or scoped) is configured.
#[must_use]
pub fn credentials_configured() -> bool {
    crate::security::bus_credentials_configured()
}

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

fn resolve_access(provided: &str) -> Result<BusAccess, String> {
    if let Some(expected) = token_from_env() {
        if constant_time_eq(expected.as_bytes(), provided.as_bytes()) {
            return Ok(BusAccess::Full);
        }
    }
    let scoped = crate::security::bus_scoped_tokens();
    for (token, modules) in &scoped {
        if constant_time_eq(token.as_bytes(), provided.as_bytes()) {
            return Ok(BusAccess::Scoped(modules.clone()));
        }
    }
    Err("invalid bus token".into())
}

/// When credentials are configured, the frame must be `auth token="…" { …command… }`.
pub fn authenticate<'a>(
    nodes: &'a [&'a KdlNode],
) -> Result<(BusAuth, &'a KdlNode, Vec<&'a KdlNode>), String> {
    let Some(head) = nodes.first() else {
        return Err("empty KDL document".into());
    };
    if !credentials_configured() {
        return Ok((BusAuth::open(), head, nodes[1..].to_vec()));
    }

    if head.name().value() != "auth" {
        return Err("missing auth wrapper (auth token=\"\" { … })".into());
    }

    let Some(provided) = head
        .get("token")
        .and_then(|v| v.as_string().map(str::to_string))
    else {
        return Err("auth node requires token=\"\" property".into());
    };

    let access = resolve_access(&provided)?;

    let Some(children) = head.children() else {
        return Err("auth wrapper requires a nested command".into());
    };

    let inner: Vec<_> = children.nodes().iter().collect();
    let Some(command) = inner.first().copied() else {
        return Err("auth wrapper requires a nested command".into());
    };

    Ok((
        BusAuth { access },
        command,
        inner[1..].to_vec(),
    ))
}

/// Legacy helper — unwrap when only a full token is used (tests).
pub fn unwrap_command<'a>(
    expected: Option<&str>,
    node: &'a KdlNode,
) -> Result<(&'a KdlNode, Vec<&'a KdlNode>), String> {
    let Some(expected) = expected else {
        return Ok((node, Vec::new()));
    };

    if node.name().value() != "auth" {
        return Err("missing auth wrapper (auth token=\"\" { … })".into());
    }

    let Some(provided) = node
        .get("token")
        .and_then(|v| v.as_string().map(str::to_string))
    else {
        return Err("auth node requires token=\"\" property".into());
    };

    if !constant_time_eq(expected.as_bytes(), provided.as_bytes()) {
        return Err("invalid bus token".into());
    }

    let Some(children) = node.children() else {
        return Err("auth wrapper requires a nested command".into());
    };

    let inner: Vec<_> = children.nodes().iter().collect();
    let Some(head) = inner.first().copied() else {
        return Err("auth wrapper requires a nested command".into());
    };

    Ok((head, inner[1..].to_vec()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use kdl::KdlDocument;

    #[test]
    fn passthrough_when_no_token_configured() {
        let doc: KdlDocument = "filter status".parse().unwrap();
        let nodes: Vec<_> = doc.nodes().iter().collect();
        let (auth, head, tail) = authenticate(&nodes).unwrap();
        assert_eq!(auth.access, BusAccess::Open);
        assert_eq!(head.name().value(), "filter");
        assert!(tail.is_empty());
    }

    #[test]
    fn rejects_missing_auth_when_token_required() {
        let doc: KdlDocument = "filter status".parse().unwrap();
        let nodes: Vec<_> = doc.nodes().iter().collect();
        // Simulate configured credentials without calling security::init.
        assert!(unwrap_command(Some("secret"), nodes[0]).is_err());
    }

    #[test]
    fn unwraps_authenticated_command() {
        let doc: KdlDocument = r#"auth token="secret" {
    filter {
        status
    }
}"#
        .parse()
        .unwrap();
        let node = doc.nodes().first().unwrap();
        let (head, tail) = unwrap_command(Some("secret"), node).unwrap();
        assert_eq!(head.name().value(), "filter");
        assert!(tail.is_empty());
    }

    #[test]
    fn rejects_wrong_token_without_leaking_length() {
        let doc: KdlDocument = r#"auth token="wrong" {
    ping
}"#
        .parse()
        .unwrap();
        let node = doc.nodes().first().unwrap();
        assert!(unwrap_command(Some("secret"), node).is_err());
    }

    #[test]
    fn scoped_access_allows_only_listed_modules() {
        let auth = BusAuth {
            access: BusAccess::Scoped(HashSet::from(["filter".into()])),
        };
        assert!(auth.allows_module("filter"));
        assert!(!auth.allows_module("tls"));
    }
}