Skip to content
//! Server configuration and the API key that guards every mutating route.

use std::fs;
use std::io::Write as _;
use std::net::SocketAddr;
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::{Path, PathBuf};

use anyhow::{Context as _, Result, bail};
use rand::RngCore as _;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq as _;

/// A shared secret. Compared in constant time and never rendered by `Debug`.
#[derive(Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub(crate) struct ApiKey(String);

impl ApiKey {
    /// Generates a fresh 256-bit key rendered as lowercase hex.
    pub(crate) fn generate() -> Self {
        let mut bytes = [0_u8; 32];
        rand::rng().fill_bytes(&mut bytes);
        let mut hex = String::with_capacity(64);
        for byte in bytes {
            use std::fmt::Write as _;
            let _ = write!(hex, "{byte:02x}");
        }
        Self(hex)
    }

    /// Constant-time equality against a candidate presented by a client.
    pub(crate) fn matches(&self, candidate: &str) -> bool {
        let expected = self.0.as_bytes();
        let actual = candidate.as_bytes();
        // Length is not secret; the byte comparison below is what must not leak.
        expected.len() == actual.len() && expected.ct_eq(actual).into()
    }

    /// Reveals the key so it can be printed once at first start.
    pub(crate) fn reveal(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Debug for ApiKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("ApiKey(redacted)")
    }
}

/// On-disk configuration file contents.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct ServerConfig {
    /// Address the HTTP server binds to.
    pub bind: SocketAddr,
    /// Shared secret required by the `x-api-key` header.
    pub(crate) api_key: ApiKey,
    /// Path to the `once` binary this server drives.
    pub once_binary: PathBuf,
    /// Container namespace passed to `once -n`.
    pub namespace: String,
}

impl ServerConfig {
    /// Loads the config file, creating it with a fresh key on first run, then
    /// applies environment overrides.
    pub(crate) fn load_or_create(path: &Path) -> Result<(Self, bool)> {
        let (mut config, created) = if path.exists() {
            let raw = fs::read_to_string(path)
                .with_context(|| format!("reading config {}", path.display()))?;
            let parsed: Self = toml::from_str(&raw)
                .with_context(|| format!("parsing config {}", path.display()))?;
            (parsed, false)
        } else {
            let fresh = Self::fresh();
            fresh.write_private(path)?;
            (fresh, true)
        };
        config.apply_env()?;
        Ok((config, created))
    }

    fn fresh() -> Self {
        Self {
            bind: SocketAddr::from(([127, 0, 0, 1], 7373)),
            api_key: ApiKey::generate(),
            once_binary: PathBuf::from("/usr/bin/once"),
            namespace: "once".to_owned(),
        }
    }

    fn apply_env(&mut self) -> Result<()> {
        if let Ok(bind) = std::env::var("TWICE_BIND") {
            self.bind = bind.parse().with_context(|| format!("TWICE_BIND={bind}"))?;
        }
        if let Ok(key) = std::env::var("TWICE_API_KEY") {
            if key.len() < 16 {
                bail!("TWICE_API_KEY must be at least 16 characters");
            }
            self.api_key = ApiKey(key);
        }
        if let Ok(binary) = std::env::var("TWICE_ONCE_BINARY") {
            self.once_binary = PathBuf::from(binary);
        }
        if let Ok(namespace) = std::env::var("TWICE_NAMESPACE") {
            self.namespace = namespace;
        }
        Ok(())
    }

    fn write_private(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
        }
        let rendered = toml::to_string_pretty(self).context("rendering config")?;
        let mut file = fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .mode(0o600)
            .open(path)
            .with_context(|| format!("creating config {}", path.display()))?;
        file.write_all(rendered.as_bytes())
            .with_context(|| format!("writing config {}", path.display()))?;
        Ok(())
    }
}

/// Default config location: `$XDG_CONFIG_HOME/twice/server.toml`.
pub(crate) fn default_config_path() -> Result<PathBuf> {
    if let Ok(explicit) = std::env::var("TWICE_CONFIG") {
        return Ok(PathBuf::from(explicit));
    }
    let base = std::env::var("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .or_else(|_| {
            std::env::var("HOME")
                .map(|home| PathBuf::from(home).join(".config"))
                .context("neither XDG_CONFIG_HOME nor HOME is set")
        })?;
    Ok(base.join("twice").join("server.toml"))
}

#[cfg(test)]
mod tests {
    use std::os::unix::fs::PermissionsExt as _;

    use super::*;

    #[test]
    fn generated_keys_are_64_hex_characters_and_unique() {
        let first = ApiKey::generate();
        let second = ApiKey::generate();
        assert_eq!(first.reveal().len(), 64);
        assert!(first.reveal().chars().all(|c| c.is_ascii_hexdigit()));
        assert_ne!(first.reveal(), second.reveal());
    }

    #[test]
    fn key_matching_accepts_only_the_exact_secret() {
        let key = ApiKey("s3cret-value-1234".to_owned());
        assert!(key.matches("s3cret-value-1234"));
        assert!(!key.matches("s3cret-value-1235"));
        assert!(!key.matches("s3cret-value-123"));
        assert!(!key.matches(""));
    }

    #[test]
    fn debug_never_renders_the_secret() {
        let key = ApiKey("super-secret-value".to_owned());
        assert_eq!(format!("{key:?}"), "ApiKey(redacted)");
    }

    #[test]
    fn first_run_creates_a_private_config_with_a_fresh_key() {
        let dir = std::env::temp_dir().join(format!("twice-cfg-{}", ApiKey::generate().reveal()));
        let path = dir.join("server.toml");
        let (config, created) = ServerConfig::load_or_create(&path).unwrap();
        assert!(created);
        assert_eq!(config.api_key.reveal().len(), 64);

        let mode = fs::metadata(&path).unwrap().permissions().mode();
        assert_eq!(
            mode & 0o777,
            0o600,
            "config must not be group/world readable"
        );

        let (reloaded, created_again) = ServerConfig::load_or_create(&path).unwrap();
        assert!(!created_again);
        assert_eq!(reloaded.api_key.reveal(), config.api_key.reveal());
        fs::remove_dir_all(&dir).unwrap();
    }
}