config.rs 490 lignes · 18657 octets
//! Runtime configuration for ActivityPub identity federation (RFC 0001).
//!
//! It loads federation parameters from environment variables at startup and
//! is consumed by the federation router builder to wire
//! the federation router and outbound worker into the Axum application.
//!
//! See [`docs/rfcs/0001-fediverse-config-and-builder.md`](https://github.com/EBO/rustwarden/blob/main/docs/rfcs/0001-fediverse-config-and-builder.md)
//! for the full specification.

use std::time::Duration;
use url::Url;

/// Runtime configuration for ActivityPub identity federation.
///
/// Built from environment variables via [`FediverseConfig::from_env`]. Validated
/// at construction: missing or placeholder domains, invalid URLs, and HTTP base
/// URLs in production are rejected eagerly.
///
/// # Example
///
/// ```rust,ignore
/// use rustwarden_core::config::FediverseConfig;
///
/// // SAFETY: doctest fixture — values are stable test constants and the
/// // doctest binary serialises env access through cargo's test harness.
/// unsafe {
///     std::env::set_var("FEDIVERSE_DOMAIN", "example.org");
/// }
///
/// let config = FediverseConfig::from_env().expect("valid config");
/// assert_eq!(config.domain(), "example.org");
/// assert_eq!(config.base_url().as_str(), "https://example.org/");
/// ```
#[derive(Debug, Clone)]
pub struct FediverseConfig {
    domain: String,
    base_url: Url,
    user_agent: String,
    authorized_fetch: bool,
    outbound_workers: usize,
    outbound_max_attempts: u32,
    outbound_request_timeout: Duration,
    fetch_actor_cache_ttl: Duration,
    auth_challenge_ttl: Duration,
    debug_local_federation: bool,
}

/// Errors that can occur while loading or validating [`FediverseConfig`].
#[derive(Debug, thiserror::Error)]
pub enum FediverseConfigError {
    /// `FEDIVERSE_DOMAIN` environment variable is missing or empty.
    #[error("FEDIVERSE_DOMAIN environment variable is missing or empty")]
    MissingDomain,

    /// `FEDIVERSE_DOMAIN` value is a known placeholder
    /// (e.g. `example.com`, `localhost.localdomain`, `your.tld`).
    /// Refused regardless of debug mode to prevent accidental production use.
    #[error("FEDIVERSE_DOMAIN value `{0}` is a placeholder; set a real domain")]
    PlaceholderDomain(String),

    /// Computed base URL is not HTTPS while `debug_local_federation` is off.
    #[error(
        "base URL `{0}` must use HTTPS in production (set FEDIVERSE_DEBUG_LOCAL_FEDERATION=true for local tests)"
    )]
    InsecureBaseUrl(String),

    /// `FEDIVERSE_DOMAIN` cannot be parsed as a valid URL host.
    #[error("FEDIVERSE_DOMAIN value `{0}` is not a valid host: {1}")]
    InvalidDomain(String, String),

    /// A numeric environment variable could not be parsed.
    #[error("environment variable `{name}` has an invalid value `{value}`: {reason}")]
    InvalidNumeric {
        /// Name of the offending environment variable.
        name: &'static str,
        /// Raw value that failed to parse.
        value: String,
        /// Underlying parse error description.
        reason: String,
    },
}

impl FediverseConfig {
    /// Load and validate a [`FediverseConfig`] from environment variables.
    ///
    /// # Required variables
    ///
    /// - `FEDIVERSE_DOMAIN` — federated domain, no scheme, no path
    ///   (e.g. `example.org`).
    ///
    /// # Optional variables (defaults shown)
    ///
    /// | Variable | Default |
    /// |---|---|
    /// | `FEDIVERSE_AUTHORIZED_FETCH` | `false` |
    /// | `FEDIVERSE_OUTBOUND_WORKERS` | `4` |
    /// | `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` | `8` |
    /// | `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` | `30` |
    /// | `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` | `86400` (24 h) |
    /// | `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` | `300` (5 min) |
    /// | `FEDIVERSE_DEBUG_LOCAL_FEDERATION` | `false` |
    ///
    /// When `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`, HTTP base URLs are
    /// accepted (for local 2-instance integration tests). In production this
    /// flag must remain `false` and the resulting base URL must be HTTPS.
    ///
    /// # Errors
    ///
    /// See [`FediverseConfigError`] for the full failure taxonomy.
    pub fn from_env() -> Result<Self, FediverseConfigError> {
        let raw_domain = std::env::var("FEDIVERSE_DOMAIN")
            .ok()
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .ok_or(FediverseConfigError::MissingDomain)?;

        if is_placeholder_domain(&raw_domain) {
            return Err(FediverseConfigError::PlaceholderDomain(raw_domain));
        }

        let debug_local_federation = read_bool_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", false)?;

        // Accept either a bare domain ("example.org") or a fully-qualified URL
        // ("http://localhost:3000"). The bare form always becomes HTTPS; the
        // explicit form is only honoured under debug mode.
        let (scheme, host_part) = if let Some(rest) = raw_domain.strip_prefix("https://") {
            ("https", rest.trim_end_matches('/').to_string())
        } else if let Some(rest) = raw_domain.strip_prefix("http://") {
            ("http", rest.trim_end_matches('/').to_string())
        } else {
            ("https", raw_domain.trim_end_matches('/').to_string())
        };

        if scheme == "http" && !debug_local_federation {
            return Err(FediverseConfigError::InsecureBaseUrl(format!(
                "http://{host_part}"
            )));
        }

        let base_url_str = format!("{scheme}://{host_part}/");
        let base_url = Url::parse(&base_url_str)
            .map_err(|e| FediverseConfigError::InvalidDomain(host_part.clone(), e.to_string()))?;

        let authorized_fetch = read_bool_env("FEDIVERSE_AUTHORIZED_FETCH", false)?;
        let outbound_workers = read_numeric_env("FEDIVERSE_OUTBOUND_WORKERS", 4)?;
        let outbound_max_attempts = read_numeric_env("FEDIVERSE_OUTBOUND_MAX_ATTEMPTS", 8)?;
        let outbound_request_timeout = Duration::from_secs(read_numeric_env(
            "FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS",
            30,
        )?);
        let fetch_actor_cache_ttl = Duration::from_secs(read_numeric_env(
            "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS",
            86_400,
        )?);
        let auth_challenge_ttl =
            Duration::from_secs(read_numeric_env("FEDIVERSE_AUTH_CHALLENGE_TTL_SECS", 300)?);

        let user_agent = format!("rustwarden/{} (+{})", env!("CARGO_PKG_VERSION"), base_url);

        Ok(Self {
            domain: host_part,
            base_url,
            user_agent,
            authorized_fetch,
            outbound_workers,
            outbound_max_attempts,
            outbound_request_timeout,
            fetch_actor_cache_ttl,
            auth_challenge_ttl,
            debug_local_federation,
        })
    }

    /// Federated domain (no scheme, no trailing slash). Example: `example.org`.
    pub fn domain(&self) -> &str {
        &self.domain
    }

    /// Canonical base URL with trailing slash. Example: `https://example.org/`.
    pub fn base_url(&self) -> &Url {
        &self.base_url
    }

    /// HTTP `User-Agent` to send on outbound federation requests.
    pub fn user_agent(&self) -> &str {
        &self.user_agent
    }

    /// Whether incoming `GET` requests on actor/object endpoints must be
    /// signed (Mastodon "secure mode" / Authorized Fetch).
    pub fn authorized_fetch(&self) -> bool {
        self.authorized_fetch
    }

    /// Number of concurrent worker tasks delivering outbound activities.
    pub fn outbound_workers(&self) -> usize {
        self.outbound_workers
    }

    /// Maximum delivery attempts before a job is marked `dead`.
    pub fn outbound_max_attempts(&self) -> u32 {
        self.outbound_max_attempts
    }

    /// Per-request HTTP timeout for outbound deliveries.
    pub fn outbound_request_timeout(&self) -> Duration {
        self.outbound_request_timeout
    }

    /// TTL for cached remote actor documents.
    pub fn fetch_actor_cache_ttl(&self) -> Duration {
        self.fetch_actor_cache_ttl
    }

    /// TTL for federated authentication challenges (cf. Phase 2).
    pub fn auth_challenge_ttl(&self) -> Duration {
        self.auth_challenge_ttl
    }

    /// When `true`, HTTP base URLs are allowed (local 2-instance tests).
    pub fn debug_local_federation(&self) -> bool {
        self.debug_local_federation
    }
}

/// Hostnames that almost certainly indicate the operator forgot to override
/// the placeholder (CLI wizard templates, canonical RFC examples). Refused
/// regardless of debug mode.
///
/// `example.org` is intentionally **not** included: it is a real IANA-owned
/// domain commonly used in tests, doctests, and demos for this framework.
const PLACEHOLDER_DOMAINS: &[&str] = &[
    "example.com",
    "example.net",
    "localhost.localdomain",
    "your.tld",
    "your-domain.tld",
    "domain.tld",
    "change-me.tld",
];

fn is_placeholder_domain(raw: &str) -> bool {
    let lowered = raw.to_ascii_lowercase();
    let stripped = lowered
        .trim_start_matches("http://")
        .trim_start_matches("https://")
        .trim_end_matches('/');
    PLACEHOLDER_DOMAINS.contains(&stripped)
}

fn read_bool_env(key: &'static str, default: bool) -> Result<bool, FediverseConfigError> {
    match std::env::var(key) {
        Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
            "" => Ok(default),
            "true" | "1" | "yes" | "on" => Ok(true),
            "false" | "0" | "no" | "off" => Ok(false),
            other => Err(FediverseConfigError::InvalidNumeric {
                name: key,
                value: other.to_string(),
                reason: "expected boolean (true/false/1/0/yes/no/on/off)".into(),
            }),
        },
        Err(_) => Ok(default),
    }
}

fn read_numeric_env<T>(key: &'static str, default: T) -> Result<T, FediverseConfigError>
where
    T: std::str::FromStr,
    T::Err: std::fmt::Display,
{
    match std::env::var(key) {
        Ok(value) => {
            let trimmed = value.trim();
            if trimmed.is_empty() {
                return Ok(default);
            }
            trimmed
                .parse::<T>()
                .map_err(|e| FediverseConfigError::InvalidNumeric {
                    name: key,
                    value: trimmed.to_string(),
                    reason: e.to_string(),
                })
        }
        Err(_) => Ok(default),
    }
}

#[cfg(test)]
impl FediverseConfig {
    /// Build a deterministic config for unit tests, bypassing env-var loading.
    /// Always HTTPS (`https://{domain}/`), all numeric defaults applied,
    /// `debug_local_federation = false`.
    pub(crate) fn for_tests(domain: &str) -> Self {
        let base_url = Url::parse(&format!("https://{domain}/"))
            .expect("test domain must produce a valid URL");
        Self {
            domain: domain.to_string(),
            user_agent: format!("rustwarden/test (+{base_url})"),
            base_url,
            authorized_fetch: false,
            outbound_workers: 4,
            outbound_max_attempts: 8,
            outbound_request_timeout: Duration::from_secs(30),
            fetch_actor_cache_ttl: Duration::from_secs(86_400),
            auth_challenge_ttl: Duration::from_secs(300),
            debug_local_federation: false,
        }
    }
}

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

    /// Serialises env-var mutations across tests in this module. Each test
    /// holds the lock for its full duration and clears the FEDIVERSE_* keyspace
    /// before setting fixtures, making tests order-independent.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    /// Reset every FEDIVERSE_* environment variable so the next test starts
    /// from a known empty state.
    fn clear_fediverse_env() {
        for key in [
            "FEDIVERSE_DOMAIN",
            "FEDIVERSE_AUTHORIZED_FETCH",
            "FEDIVERSE_OUTBOUND_WORKERS",
            "FEDIVERSE_OUTBOUND_MAX_ATTEMPTS",
            "FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS",
            "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS",
            "FEDIVERSE_AUTH_CHALLENGE_TTL_SECS",
            "FEDIVERSE_DEBUG_LOCAL_FEDERATION",
        ] {
            // SAFETY: tests in this module are serialised through ENV_LOCK,
            // and the helper runs before any code that reads these vars in the
            // same test. No other thread observes the partial state.
            unsafe {
                std::env::remove_var(key);
            }
        }
    }

    /// Set an environment variable for the duration of the surrounding test.
    fn set_env(key: &str, value: &str) {
        // SAFETY: same rationale as `clear_fediverse_env` — serialised via
        // ENV_LOCK in the calling test.
        unsafe {
            std::env::set_var(key, value);
        }
    }

    // ─── Test 1 — Happy path ────────────────────────────────────────────

    #[test]
    fn from_env_returns_valid_config_with_required_var_only() {
        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
        clear_fediverse_env();
        set_env("FEDIVERSE_DOMAIN", "example.org");

        let config = FediverseConfig::from_env().expect("valid config");
        assert_eq!(config.domain(), "example.org");
        assert_eq!(config.base_url().as_str(), "https://example.org/");
        assert!(!config.authorized_fetch());
        assert!(!config.debug_local_federation());
    }

    // ─── Test 2 — Missing domain ────────────────────────────────────────

    #[test]
    fn from_env_returns_missing_domain_when_var_unset() {
        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
        clear_fediverse_env();

        let err = FediverseConfig::from_env().expect_err("must fail");
        assert!(
            matches!(err, FediverseConfigError::MissingDomain),
            "got {err:?}"
        );
    }

    // ─── Test 3 — Placeholder domains ───────────────────────────────────

    #[test]
    fn from_env_returns_placeholder_error_for_known_placeholders() {
        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
        for placeholder in ["example.com", "localhost.localdomain", "your.tld"] {
            clear_fediverse_env();
            set_env("FEDIVERSE_DOMAIN", placeholder);

            let err = FediverseConfig::from_env().expect_err("must fail");
            assert!(
                matches!(err, FediverseConfigError::PlaceholderDomain(ref d) if d == placeholder),
                "domain={placeholder} got {err:?}"
            );
        }
    }

    // ─── Test 4 — Insecure base URL in production ───────────────────────

    #[test]
    fn from_env_rejects_http_base_url_in_production() {
        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
        clear_fediverse_env();
        // Domain that yields http:// only in non-debug mode is conceptual;
        // we trigger the path by feeding a hostname that the parser maps
        // to HTTPS but with debug=false (default). To force HTTP, the
        // implementation interprets a leading `http://` scheme in the env
        // value as opt-in to plain HTTP — only valid under debug mode.
        set_env("FEDIVERSE_DOMAIN", "http://example.org");

        let err = FediverseConfig::from_env().expect_err("must fail");
        assert!(
            matches!(err, FediverseConfigError::InsecureBaseUrl(_)),
            "got {err:?}"
        );
    }

    // ─── Test 5 — Debug mode allows HTTP ────────────────────────────────

    #[test]
    fn from_env_allows_http_when_debug_local_federation() {
        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
        clear_fediverse_env();
        set_env("FEDIVERSE_DOMAIN", "http://localhost:3000");
        set_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "true");

        let config = FediverseConfig::from_env().expect("debug HTTP allowed");
        assert!(config.debug_local_federation());
        assert_eq!(config.base_url().scheme(), "http");
        assert_eq!(config.domain(), "localhost:3000");
    }

    // ─── Test 6 — Numeric var invalid ───────────────────────────────────

    #[test]
    fn from_env_returns_invalid_numeric_for_bad_workers_value() {
        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
        clear_fediverse_env();
        set_env("FEDIVERSE_DOMAIN", "example.org");
        set_env("FEDIVERSE_OUTBOUND_WORKERS", "not-a-number");

        let err = FediverseConfig::from_env().expect_err("must fail");
        assert!(
            matches!(
                err,
                FediverseConfigError::InvalidNumeric { name, .. }
                    if name == "FEDIVERSE_OUTBOUND_WORKERS"
            ),
            "got {err:?}"
        );
    }

    // ─── Test 7 — Defaults ──────────────────────────────────────────────

    #[test]
    fn defaults_match_documented_values() {
        let _guard = ENV_LOCK.lock().expect("env lock poisoned");
        clear_fediverse_env();
        set_env("FEDIVERSE_DOMAIN", "example.org");

        let config = FediverseConfig::from_env().expect("valid config");
        assert_eq!(config.outbound_workers(), 4);
        assert_eq!(config.outbound_max_attempts(), 8);
        assert_eq!(
            config.outbound_request_timeout(),
            Duration::from_secs(30),
            "outbound_request_timeout default"
        );
        assert_eq!(
            config.fetch_actor_cache_ttl(),
            Duration::from_secs(86_400),
            "fetch_actor_cache_ttl default (24 h)"
        );
        assert_eq!(
            config.auth_challenge_ttl(),
            Duration::from_secs(300),
            "auth_challenge_ttl default (5 min)"
        );
        assert!(!config.authorized_fetch());
        assert!(!config.debug_local_federation());
        assert!(
            config.user_agent().contains("rustwarden")
                || config.user_agent().contains("Rustwarden"),
            "user_agent={}",
            config.user_agent()
        );
    }
}