ch-config 0.1.1

Typed configuration loading with validation and secret-file support
Documentation
use std::str::FromStr;

use super::network::parse_network_url;
use super::validation::optional_text;
use super::{ConfigError, Result, variables};

/// Durable media storage backend.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MediaLibraryBackend {
    /// Store objects on the local filesystem.
    #[default]
    Local,
    /// Store objects in an S3-compatible service.
    S3,
}

impl FromStr for MediaLibraryBackend {
    type Err = ();

    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
        match value.to_ascii_lowercase().as_str() {
            "local" => Ok(Self::Local),
            "s3" => Ok(Self::S3),
            _ => Err(()),
        }
    }
}

impl MediaLibraryBackend {
    /// Stable configuration spelling.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Local => "local",
            Self::S3 => "s3",
        }
    }
}

/// Durable media object storage settings.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MediaLibraryConfig {
    /// Selected storage backend.
    pub backend: MediaLibraryBackend,
    /// Filesystem-relative object root for local storage.
    pub object_root: String,
    /// S3-specific settings.
    pub s3: MediaLibraryS3Config,
}

/// S3-compatible object storage settings.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MediaLibraryS3Config {
    /// Optional custom endpoint.
    pub endpoint: Option<String>,
    /// Bucket name.
    pub bucket: String,
    /// Optional provider region.
    pub region: Option<String>,
    /// Whether plain HTTP is explicitly permitted.
    pub allow_http: bool,
    /// Whether virtual-hosted-style requests are enabled.
    pub virtual_hosted_style_request: bool,
}

impl MediaLibraryConfig {
    /// Creates local durable object-storage defaults.
    pub fn local(object_root: impl Into<String>) -> Self {
        Self {
            backend: MediaLibraryBackend::Local,
            object_root: object_root.into(),
            s3: MediaLibraryS3Config::default(),
        }
    }

    pub(crate) fn apply_environment(
        &mut self,
        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
    ) -> Result<()> {
        if let Some(backend) = variables::parsed(
            "MEDIA_LIBRARY_BACKEND",
            value("MEDIA_LIBRARY_BACKEND")?,
            "must be either `local` or `s3`",
        )? {
            self.backend = backend;
        }
        if let Some(object_root) = value("MEDIA_LIBRARY_OBJECT_ROOT")? {
            self.object_root = object_root;
        }
        if let Some(endpoint) = value("MEDIA_LIBRARY_S3_ENDPOINT")? {
            self.s3.endpoint = Some(endpoint);
        }
        if let Some(bucket) = value("MEDIA_LIBRARY_S3_BUCKET")? {
            self.s3.bucket = bucket;
        }
        if let Some(region) = value("MEDIA_LIBRARY_S3_REGION")? {
            self.s3.region = Some(region);
        }
        if let Some(allow_http) = variables::parsed(
            "MEDIA_LIBRARY_S3_ALLOW_HTTP",
            value("MEDIA_LIBRARY_S3_ALLOW_HTTP")?,
            "must be either `true` or `false`",
        )? {
            self.s3.allow_http = allow_http;
        }
        if let Some(virtual_hosted_style_request) = variables::parsed(
            "MEDIA_LIBRARY_S3_VIRTUAL_HOSTED_STYLE_REQUEST",
            value("MEDIA_LIBRARY_S3_VIRTUAL_HOSTED_STYLE_REQUEST")?,
            "must be either `true` or `false`",
        )? {
            self.s3.virtual_hosted_style_request = virtual_hosted_style_request;
        }
        Ok(())
    }

    /// Reports whether media storage settings are present.
    pub fn is_configured(&self) -> bool {
        !self.object_root.is_empty()
            || self.backend == MediaLibraryBackend::S3
            || self.s3 != MediaLibraryS3Config::default()
    }

    /// Validates the selected storage backend.
    pub fn validate(&self) -> Result<()> {
        if !self.is_configured() {
            return Ok(());
        }

        if !self.object_root.is_empty() {
            validate_object_root(&self.object_root)?;
        }
        optional_text("media_library.s3.endpoint", self.s3.endpoint.as_deref())?;
        optional_text("media_library.s3.region", self.s3.region.as_deref())?;
        if let Some(endpoint) = &self.s3.endpoint {
            let schemes: &[&str] = if self.s3.allow_http {
                &["http", "https"]
            } else {
                &["https"]
            };
            let message = if self.s3.allow_http {
                "must be an HTTP or HTTPS URL with a host and no embedded credentials"
            } else {
                "must be an HTTPS URL with a host and no embedded credentials"
            };
            parse_network_url(
                "media_library.s3.endpoint",
                endpoint,
                schemes,
                false,
                message,
            )?;
        }

        match self.backend {
            MediaLibraryBackend::Local if self.object_root.trim().is_empty() => Err(
                ConfigError::validation("media_library.object_root", "must not be empty"),
            ),
            MediaLibraryBackend::Local => Ok(()),
            MediaLibraryBackend::S3 => {
                if self.s3.bucket.trim().is_empty() {
                    return Err(ConfigError::validation(
                        "media_library.s3.bucket",
                        "must not be empty for the S3 backend",
                    ));
                }
                Ok(())
            }
        }
    }
}

fn validate_object_root(value: &str) -> Result<()> {
    let root = value.trim();
    let has_windows_prefix = root.as_bytes().get(1) == Some(&b':')
        && root.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
    let invalid_component = root
        .split('/')
        .any(|component| component.is_empty() || component == "." || component == "..");

    if root.is_empty()
        || root.starts_with(['/', '\\'])
        || has_windows_prefix
        || root.contains('\\')
        || root.contains('\0')
        || invalid_component
    {
        return Err(ConfigError::validation(
            "media_library.object_root",
            "must be a safe relative logical prefix using forward-slash separators",
        ));
    }

    Ok(())
}

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

    fn local_config(object_root: &str) -> MediaLibraryConfig {
        MediaLibraryConfig {
            backend: MediaLibraryBackend::Local,
            object_root: object_root.to_owned(),
            s3: MediaLibraryS3Config::default(),
        }
    }

    #[test]
    fn object_root_accepts_portable_relative_prefixes() {
        for valid in ["objects", "objects/custom", "tenant 1/assets"] {
            local_config(valid)
                .validate()
                .unwrap_or_else(|error| panic!("{valid} should be valid: {error}"));
        }
    }

    #[test]
    fn object_root_rejects_unix_absolute_and_parent_forms() {
        for invalid in [
            "/var/lib/objects",
            "../objects",
            "objects/../secret",
            "./objects",
            "objects//nested",
        ] {
            assert!(
                local_config(invalid).validate().is_err(),
                "{invalid} should be rejected"
            );
        }
    }

    #[test]
    fn object_root_rejects_windows_absolute_prefix_and_parent_forms() {
        for invalid in [
            r"C:\objects",
            "C:/objects",
            r"\\server\share\objects",
            r"\objects",
            r"objects\..\secret",
            r"objects\nested",
        ] {
            assert!(
                local_config(invalid).validate().is_err(),
                "{invalid} should be rejected"
            );
        }
    }

    #[test]
    fn s3_endpoint_enforces_transport_host_and_credentials() {
        let mut config = MediaLibraryConfig {
            backend: MediaLibraryBackend::S3,
            object_root: String::new(),
            s3: MediaLibraryS3Config {
                bucket: "assets".to_owned(),
                ..MediaLibraryS3Config::default()
            },
        };

        for invalid in [
            "http://objects.example.invalid",
            "ftp://objects.example.invalid",
            "https:///missing-host",
            "https://user:secret@objects.example.invalid",
        ] {
            config.s3.endpoint = Some(invalid.to_owned());
            assert!(config.validate().is_err(), "{invalid} should be rejected");
        }

        config.s3.endpoint = Some("https://objects.example.invalid".to_owned());
        config
            .validate()
            .expect("HTTPS endpoint should be accepted");

        config.s3.allow_http = true;
        config.s3.endpoint = Some("http://127.0.0.1:9000".to_owned());
        config
            .validate()
            .expect("explicitly permitted local HTTP endpoint should be accepted");
    }
}