csaf-core 1.5.28

CSAF storage, validation, sidecar generation, import/export
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! Application configuration loaded from TOML.
//!
//! **The port rule** (`skills/rust-ip-ports`): a listen port is accepted iff it
//! is a canonical decimal number in 1025..=65535. `listen_port` is the TCP
//! port. The HTTP/3 (UDP) port is its own setting, `quic_port`; see
//! [`QuicPort`].

use std::path::{Path, PathBuf};

use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::error::{CsafError, Result};
use crate::listen_port::{ListenPort, ListenPortError};

/// The text that switches the HTTP/3 listener off (any case). The number `0`
/// does the same.
const OFF: &str = "off";

/// The default TCP listen port. A value the rule refuses does not compile.
const DEFAULT_LISTEN_PORT: ListenPort = ListenPort::from_const::<8443>();

/// Bootstrap application configuration (from TOML file).
///
/// Runtime-changeable settings (theme, CSAF mode, etc.) are stored in redb
/// via the `Settings` model. This config only contains startup parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    /// Listen address (default: `"127.0.0.1"`).
    #[serde(default = "default_listen_addr")]
    pub listen_addr: String,

    /// TCP listen port (default: `8443`): a canonical decimal number in
    /// 1025-65535. A refused value stops the server with exit status 64.
    #[serde(default = "default_listen_port")]
    pub listen_port: ListenPort,

    /// UDP port of the HTTP/3 listener (default: `listen_port` + 1 when that
    /// is a valid port). See [`QuicPort`].
    #[serde(default, skip_serializing_if = "QuicPort::is_derived")]
    pub quic_port: QuicPort,

    /// Data directory for databases.
    #[serde(default = "default_data_dir")]
    pub data_dir: PathBuf,

    /// TLS certificate file path (PEM). Auto-generated if absent.
    pub tls_cert: Option<PathBuf>,

    /// TLS private key file path (PEM). Auto-generated if absent.
    pub tls_key: Option<PathBuf>,
}

/// The `quic_port` setting: where the HTTP/3 (UDP) listener binds.
///
/// Before this setting existed the port was always `listen_port` + 1, which
/// overflowed for `listen_port = 65535`. TCP and UDP are different port
/// spaces, so the HTTP/3 port is now its own value.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QuicPort {
    /// Not set: `listen_port` + 1 when that is a valid port, otherwise no
    /// HTTP/3 listener (there is no UDP port above 65535).
    #[default]
    Derived,
    /// `"off"` (any case) or `0`: no HTTP/3 listener.
    Off,
    /// That port, checked by the same rule as `listen_port`.
    Port(ListenPort),
}

impl QuicPort {
    /// Whether the port is derived from `listen_port` (the setting is absent).
    #[must_use]
    pub const fn is_derived(&self) -> bool {
        matches!(self, Self::Derived)
    }

    /// Parse the text form: `off` in any case and `0` switch HTTP/3 off, every
    /// other text goes through the rule.
    ///
    /// # Errors
    ///
    /// The part of the rule the text breaks.
    pub fn from_text(text: &str) -> std::result::Result<Self, ListenPortError> {
        if text.eq_ignore_ascii_case(OFF) || text == "0" {
            return Ok(Self::Off);
        }
        ListenPort::parse(text).map(Self::Port)
    }

    /// Convert a TOML integer: `0` switches HTTP/3 off, every other value goes
    /// through the rule.
    ///
    /// # Errors
    ///
    /// The part of the rule the number breaks.
    pub fn from_integer(value: i64) -> std::result::Result<Self, ListenPortError> {
        if value == 0 {
            return Ok(Self::Off);
        }
        port_of_integer(value).map(Self::Port)
    }
}

impl Serialize for QuicPort {
    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
        match *self {
            Self::Port(port) => serializer.serialize_u16(port.get()),
            // `Derived` is skipped by the field attribute; it has no value.
            Self::Off | Self::Derived => serializer.serialize_str(OFF),
        }
    }
}

/// What `quic_port` may be in a config file: a number or a text.
#[derive(Deserialize)]
#[serde(untagged)]
enum RawQuicPort {
    Number(i64),
    Text(String),
}

impl<'de> Deserialize<'de> for QuicPort {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
        let parsed = match RawQuicPort::deserialize(deserializer)? {
            RawQuicPort::Number(number) => Self::from_integer(number),
            RawQuicPort::Text(text) => Self::from_text(&text),
        };
        parsed.map_err(serde::de::Error::custom)
    }
}

impl Default for AppConfig {
    fn default() -> Self {
        Self {
            listen_addr: default_listen_addr(),
            listen_port: default_listen_port(),
            quic_port: QuicPort::default(),
            data_dir: default_data_dir(),
            tls_cert: None,
            tls_key: None,
        }
    }
}

impl AppConfig {
    /// Load configuration from a TOML file, falling back to defaults.
    ///
    /// # Errors
    ///
    /// Returns [`CsafError::ListenPort`] if `listen_port` or `quic_port`
    /// breaks the port rule, and [`CsafError::Config`] if the file exists but
    /// cannot be read or parsed.
    pub fn load(path: &Path) -> Result<Self> {
        if !path.exists() {
            return Ok(Self::default());
        }
        let content =
            std::fs::read_to_string(path).map_err(|e| CsafError::Config(e.to_string()))?;
        check_ports(&content)?;
        toml::from_str(&content).map_err(|e| CsafError::Config(e.to_string()))
    }

    /// Path to the redb database file.
    #[must_use]
    pub fn redb_path(&self) -> PathBuf {
        self.data_dir.join("csaf.redb")
    }

    /// Path to the `SQLite` database file.
    #[must_use]
    pub fn sqlite_path(&self) -> PathBuf {
        self.data_dir.join("csaf.sqlite")
    }

    /// Full listen address string (e.g. `"127.0.0.1:8443"`).
    #[must_use]
    pub fn listen_address(&self) -> String {
        format!("{}:{}", self.listen_addr, self.listen_port)
    }

    /// The UDP port of the HTTP/3 listener, or `None` for no listener.
    ///
    /// Derived from `listen_port` + 1 when `quic_port` is not set; at
    /// `listen_port = 65535` there is no UDP port above it, so the answer is
    /// `None` instead of an overflow.
    #[must_use]
    pub const fn quic_listen_port(&self) -> Option<ListenPort> {
        match self.quic_port {
            QuicPort::Off => None,
            QuicPort::Port(port) => Some(port),
            QuicPort::Derived => derived_quic_port(self.listen_port),
        }
    }
}

/// The HTTP/3 port that follows `listen` when `quic_port` is not set:
/// `listen` + 1, or `None` at 65535, where there is no UDP port above it.
///
/// A function of its own so a Kani proof can cover every valid port. The sum
/// in a bare `u16` overflowed at 65535 and aborted the server at start.
const fn derived_quic_port(listen: ListenPort) -> Option<ListenPort> {
    match listen.get().checked_add(1) {
        Some(port) => match ListenPort::try_new(port) {
            Ok(derived) => Some(derived),
            Err(_) => None,
        },
        None => None,
    }
}

/// Check the two port keys of a config document against the rule, before the
/// full parse, so a refused port is a typed error and not a text.
///
/// A document that is not TOML, and a value of the wrong type, are left to the
/// full parse, which reports them as ordinary configuration errors.
fn check_ports(content: &str) -> Result<()> {
    let Ok(table) = content.parse::<toml::Table>() else {
        return Ok(());
    };
    if let Some(value) = table.get("listen_port") {
        check_listen_port(value).map_err(|source| CsafError::ListenPort {
            key: "listen_port",
            source,
            or_off: false,
        })?;
    }
    if let Some(value) = table.get("quic_port") {
        check_quic_port(value).map_err(|source| CsafError::ListenPort {
            key: "quic_port",
            source,
            or_off: true,
        })?;
    }
    Ok(())
}

fn check_listen_port(value: &toml::Value) -> std::result::Result<(), ListenPortError> {
    match value {
        toml::Value::Integer(number) => port_of_integer(*number).map(drop),
        _ => Ok(()),
    }
}

fn check_quic_port(value: &toml::Value) -> std::result::Result<(), ListenPortError> {
    match value {
        toml::Value::Integer(number) => QuicPort::from_integer(*number).map(drop),
        toml::Value::String(text) => QuicPort::from_text(text).map(drop),
        _ => Ok(()),
    }
}

/// A TOML integer as a listen port. A negative number carries a sign, so it is
/// not a canonical decimal; a number above 65535 is not a port number.
fn port_of_integer(value: i64) -> std::result::Result<ListenPort, ListenPortError> {
    match u16::try_from(value) {
        Ok(port) => ListenPort::try_new(port),
        Err(_) if value < 0 => Err(ListenPortError::NotDecimal),
        Err(_) => Err(ListenPortError::NotAPortNumber),
    }
}

fn default_listen_addr() -> String {
    "127.0.0.1".to_owned()
}

const fn default_listen_port() -> ListenPort {
    DEFAULT_LISTEN_PORT
}

fn default_data_dir() -> PathBuf {
    PathBuf::from("./data")
}

/// Kani proofs (`skills/rust-kani`): a harness takes a symbolic input, so each
/// claim holds for every value, not for the samples a test draws. Run:
/// `cargo kani -p csaf-core --lib`. Compiled only under `cfg(kani)`.
#[cfg(kani)]
mod proofs {
    use super::{ListenPort, derived_quic_port, port_of_integer};

    /// For every valid TCP port: the HTTP/3 port is the next one, except at
    /// 65535 where there is none, and the sum never overflows.
    #[kani::proof]
    fn the_derived_http3_port_follows_by_one_and_never_overflows() {
        let port: u16 = kani::any();
        kani::assume(port >= ListenPort::MIN);
        let Ok(listen) = ListenPort::try_new(port) else {
            return;
        };
        let derived = derived_quic_port(listen);
        if port == u16::MAX {
            assert!(derived.is_none());
        } else {
            assert_eq!(derived.map(ListenPort::get), Some(port + 1));
        }
    }

    /// For every TOML integer: accepted iff it is inside the rule, and the
    /// accepted value is the number itself.
    #[kani::proof]
    fn a_toml_integer_is_accepted_iff_it_is_inside_the_rule() {
        let value: i64 = kani::any();
        let result = port_of_integer(value);
        assert_eq!(result.is_ok(), (1025..=65535).contains(&value));
        if let Ok(port) = result {
            assert_eq!(i64::from(port.get()), value);
        }
    }
}

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

    #[test]
    fn test_default_config() {
        let config = AppConfig::default();
        assert_eq!(config.listen_addr, "127.0.0.1");
        assert_eq!(config.listen_port.get(), 8443);
        assert_eq!(config.listen_address(), "127.0.0.1:8443");
        assert_eq!(config.redb_path(), PathBuf::from("./data/csaf.redb"));
        assert_eq!(config.sqlite_path(), PathBuf::from("./data/csaf.sqlite"));
    }

    #[test]
    fn test_load_missing_file_uses_defaults() {
        let config =
            AppConfig::load(Path::new("/nonexistent/config.toml")).expect("should return defaults");
        assert_eq!(config.listen_port.get(), 8443);
    }
}