fail2ban-rs 1.2.1

A pure-Rust fail2ban replacement. Single static binary, fast two-phase matching, nftables/iptables firewall backends.
Documentation
//! Human-friendly duration parsing for configuration values.
//!
//! Accepts both raw integers (seconds) and duration strings like
//! `"10m"`, `"1h"`, `"1d"`, `"1w"`.

use crate::error::{Error, Result};

/// Parse a duration string into seconds.
///
/// Supported suffixes: `s` (seconds), `m` (minutes), `h` (hours),
/// `d` (days), `w` (weeks). Plain integers are treated as seconds.
pub fn parse_duration(s: &str) -> Result<i64> {
    let s = s.trim();
    if s.is_empty() {
        return Err(Error::config("empty duration string"));
    }

    // Try plain integer first.
    if let Ok(n) = s.parse::<i64>() {
        return Ok(n);
    }

    let (digits, suffix) = s.split_at(s.len() - 1);
    let value: i64 = digits
        .trim()
        .parse()
        .map_err(|_| Error::config(format!("invalid duration: {s}")))?;

    let multiplier: i64 = match suffix {
        "s" => 1,
        "m" => 60,
        "h" => 3600,
        "d" => 86400,
        "w" => 604_800,
        _ => return Err(Error::config(format!("unknown duration suffix: {suffix}"))),
    };

    value
        .checked_mul(multiplier)
        .ok_or_else(|| Error::config(format!("duration overflow: {s}")))
}

/// Serde deserializer that accepts both integers and duration strings.
pub fn deserialize_duration<'de, D>(deserializer: D) -> std::result::Result<i64, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de;

    struct DurationVisitor;

    impl de::Visitor<'_> for DurationVisitor {
        type Value = i64;

        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
            f.write_str("an integer (seconds) or a duration string like \"10m\", \"1h\"")
        }

        fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<i64, E> {
            Ok(v)
        }

        fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<i64, E> {
            i64::try_from(v).map_err(|_| E::custom("duration too large"))
        }

        fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<i64, E> {
            parse_duration(v).map_err(|e| E::custom(e.to_string()))
        }
    }

    deserializer.deserialize_any(DurationVisitor)
}

#[cfg(test)]
mod tests {
    use crate::duration::parse_duration;

    #[test]
    fn plain_integers() {
        assert_eq!(parse_duration("60").unwrap(), 60);
        assert_eq!(parse_duration("3600").unwrap(), 3600);
        assert_eq!(parse_duration("-1").unwrap(), -1);
        assert_eq!(parse_duration("0").unwrap(), 0);
    }

    #[test]
    fn seconds_suffix() {
        assert_eq!(parse_duration("30s").unwrap(), 30);
        assert_eq!(parse_duration("1s").unwrap(), 1);
    }

    #[test]
    fn minutes_suffix() {
        assert_eq!(parse_duration("10m").unwrap(), 600);
        assert_eq!(parse_duration("1m").unwrap(), 60);
        assert_eq!(parse_duration("30m").unwrap(), 1800);
    }

    #[test]
    fn hours_suffix() {
        assert_eq!(parse_duration("1h").unwrap(), 3600);
        assert_eq!(parse_duration("24h").unwrap(), 86400);
    }

    #[test]
    fn days_suffix() {
        assert_eq!(parse_duration("1d").unwrap(), 86400);
        assert_eq!(parse_duration("7d").unwrap(), 604_800);
    }

    #[test]
    fn weeks_suffix() {
        assert_eq!(parse_duration("1w").unwrap(), 604_800);
        assert_eq!(parse_duration("2w").unwrap(), 1_209_600);
    }

    #[test]
    fn whitespace_trimmed() {
        assert_eq!(parse_duration("  60  ").unwrap(), 60);
        assert_eq!(parse_duration(" 10m ").unwrap(), 600);
    }

    #[test]
    fn empty_string_error() {
        assert!(parse_duration("").is_err());
        assert!(parse_duration("  ").is_err());
    }

    #[test]
    fn invalid_suffix_error() {
        assert!(parse_duration("10x").is_err());
        assert!(parse_duration("5y").is_err());
    }

    #[test]
    fn invalid_number_error() {
        assert!(parse_duration("abcm").is_err());
        assert!(parse_duration("m").is_err());
    }
}