use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{CsafError, Result};
use crate::listen_port::{ListenPort, ListenPortError};
const OFF: &str = "off";
const DEFAULT_LISTEN_PORT: ListenPort = ListenPort::from_const::<8443>();
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default = "default_listen_addr")]
pub listen_addr: String,
#[serde(default = "default_listen_port")]
pub listen_port: ListenPort,
#[serde(default, skip_serializing_if = "QuicPort::is_derived")]
pub quic_port: QuicPort,
#[serde(default = "default_data_dir")]
pub data_dir: PathBuf,
pub tls_cert: Option<PathBuf>,
pub tls_key: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QuicPort {
#[default]
Derived,
Off,
Port(ListenPort),
}
impl QuicPort {
#[must_use]
pub const fn is_derived(&self) -> bool {
matches!(self, Self::Derived)
}
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)
}
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()),
Self::Off | Self::Derived => serializer.serialize_str(OFF),
}
}
}
#[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 {
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()))
}
#[must_use]
pub fn redb_path(&self) -> PathBuf {
self.data_dir.join("csaf.redb")
}
#[must_use]
pub fn sqlite_path(&self) -> PathBuf {
self.data_dir.join("csaf.sqlite")
}
#[must_use]
pub fn listen_address(&self) -> String {
format!("{}:{}", self.listen_addr, self.listen_port)
}
#[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),
}
}
}
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,
}
}
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(()),
}
}
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")
}
#[cfg(kani)]
mod proofs {
use super::{ListenPort, derived_quic_port, port_of_integer};
#[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));
}
}
#[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);
}
}