ch-config 0.1.1

Typed configuration loading with validation and secret-file support
Documentation
use super::{ConfigError, Result, variables};

/// Captcha generation and verification policy.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CaptchaConfig {
    /// Allowed challenge characters.
    pub charset: String,
    /// Noise intensity from zero through one.
    pub noise_level: f32,
    /// Challenge lifetime in seconds.
    pub expires_after_secs: u64,
    /// Maximum attempts per minute.
    pub max_attempts_per_minute: u32,
    /// Failed verification attempts allowed before a challenge is invalidated.
    ///
    /// The captcha verifier owns the attempt counter. This value is the
    /// per-challenge ceiling and is independent of the issuance rate limit in
    /// [`Self::max_attempts_per_minute`].
    pub max_failures: u32,
    /// Explicit development-only bypass flag.
    pub bypass: bool,
}

impl CaptchaConfig {
    /// Returns the shared production policy used by the web applications.
    pub fn standard() -> Self {
        Self {
            charset: "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".to_owned(),
            noise_level: 0.35,
            expires_after_secs: 300,
            max_attempts_per_minute: 12,
            max_failures: 3,
            bypass: false,
        }
    }

    pub(crate) fn apply_environment(
        &mut self,
        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
    ) -> Result<()> {
        if let Some(charset) = value("CAPTCHA_CHARSET")? {
            self.charset = charset;
        }
        if let Some(noise_level) = variables::parsed(
            "CAPTCHA_NOISE_LEVEL",
            value("CAPTCHA_NOISE_LEVEL")?,
            "must be a number between zero and one",
        )? {
            self.noise_level = noise_level;
        }
        if let Some(expires_after_secs) = variables::parsed(
            "CAPTCHA_EXPIRES_AFTER_SECS",
            value("CAPTCHA_EXPIRES_AFTER_SECS")?,
            "must be a positive integer number of seconds",
        )? {
            self.expires_after_secs = expires_after_secs;
        }
        if let Some(max_attempts_per_minute) = variables::parsed(
            "CAPTCHA_MAX_ATTEMPTS_PER_MINUTE",
            value("CAPTCHA_MAX_ATTEMPTS_PER_MINUTE")?,
            "must be a positive integer",
        )? {
            self.max_attempts_per_minute = max_attempts_per_minute;
        }
        if let Some(max_failures) = variables::parsed(
            "CAPTCHA_MAX_FAILURES",
            value("CAPTCHA_MAX_FAILURES")?,
            "must be a positive integer",
        )? {
            self.max_failures = max_failures;
        }
        if let Some(bypass) = variables::parsed(
            "CAPTCHA_BYPASS",
            value("CAPTCHA_BYPASS")?,
            "must be either `true` or `false`",
        )? {
            self.bypass = bypass;
        }
        Ok(())
    }

    /// Reports whether a captcha policy is present.
    pub fn is_configured(&self) -> bool {
        !self.charset.is_empty()
            || self.noise_level != 0.0
            || self.expires_after_secs != 0
            || self.max_attempts_per_minute != 0
            || self.max_failures != 0
            || self.bypass
    }

    /// Validates a complete captcha policy.
    pub fn validate(&self) -> Result<()> {
        if !self.is_configured() {
            return Ok(());
        }
        if self.charset.trim().is_empty() {
            return Err(ConfigError::validation(
                "captcha.charset",
                "must not be empty",
            ));
        }
        if !(0.0..=1.0).contains(&self.noise_level) {
            return Err(ConfigError::validation(
                "captcha.noise_level",
                "must be between zero and one",
            ));
        }
        for (field, value) in [
            ("captcha.expires_after_secs", self.expires_after_secs),
            (
                "captcha.max_attempts_per_minute",
                u64::from(self.max_attempts_per_minute),
            ),
            ("captcha.max_failures", u64::from(self.max_failures)),
        ] {
            if value == 0 {
                return Err(ConfigError::validation(field, "must be greater than zero"));
            }
        }
        Ok(())
    }
}

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

    #[test]
    fn standard_policy_is_complete_and_safe() {
        let policy = CaptchaConfig::standard();
        policy.validate().expect("standard policy should validate");
        assert!(!policy.bypass);
        assert!(!policy.charset.contains(['I', 'O', '0', '1']));
    }
}