bindy/bind9/
duration.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Duration parsing for Go-style duration strings.
5//!
6//! Supports parsing duration strings in Go format (e.g., "720h", "30d", "4w") into
7//! Rust `std::time::Duration`. Validates bounds according to RNDC key rotation requirements.
8
9use anyhow::{bail, Context, Result};
10use std::time::Duration;
11
12use crate::constants::{MAX_ROTATION_INTERVAL_HOURS, MIN_ROTATION_INTERVAL_HOURS};
13
14const SECONDS_PER_HOUR: u64 = 3600;
15const SECONDS_PER_DAY: u64 = 86400;
16const SECONDS_PER_WEEK: u64 = 604_800;
17
18/// Parse a Go-style duration string into a Rust `Duration`.
19///
20/// Supported units:
21/// - `h` (hours): "720h" = 30 days
22/// - `d` (days): "30d" = 30 days
23/// - `w` (weeks): "4w" = 28 days
24///
25/// # Constraints
26///
27/// - **Minimum:** 1 hour (`MIN_ROTATION_INTERVAL_HOURS`)
28/// - **Maximum:** 8760 hours / 365 days (`MAX_ROTATION_INTERVAL_HOURS`)
29///
30/// These bounds ensure keys are rotated frequently enough for security compliance
31/// but not so frequently as to cause operational issues.
32///
33/// # Examples
34///
35/// ```
36/// use bindy::bind9::duration::parse_duration;
37/// use std::time::Duration;
38///
39/// // Parse hours
40/// assert_eq!(parse_duration("24h").unwrap(), Duration::from_secs(86400));
41///
42/// // Parse days
43/// assert_eq!(parse_duration("30d").unwrap(), Duration::from_secs(2_592_000));
44///
45/// // Parse weeks
46/// assert_eq!(parse_duration("4w").unwrap(), Duration::from_secs(2_419_200));
47///
48/// // Invalid formats return errors
49/// assert!(parse_duration("").is_err());
50/// assert!(parse_duration("10").is_err());  // Missing unit
51/// assert!(parse_duration("10x").is_err()); // Invalid unit
52/// ```
53///
54/// # Errors
55///
56/// Returns an error if:
57/// - The format is invalid (missing unit, non-numeric value)
58/// - The duration is below the minimum (1h)
59/// - The duration is above the maximum (8760h / 365d / 52w)
60///
61/// # Panics
62///
63/// May panic if the `MIN_ROTATION_INTERVAL_HOURS` or `MAX_ROTATION_INTERVAL_HOURS`
64/// constants overflow when multiplied by `SECONDS_PER_HOUR`. This should never
65/// happen with the current constant values.
66pub fn parse_duration(duration_str: &str) -> Result<Duration> {
67    // Validate non-empty
68    if duration_str.is_empty() {
69        bail!("Duration string cannot be empty");
70    }
71
72    // Find where digits end and unit begins
73    let split_pos = duration_str
74        .chars()
75        .position(|c| !c.is_ascii_digit())
76        .context("Duration must end with a unit (h, d, or w)")?;
77
78    // Split into value and unit
79    let (value_str, unit) = duration_str.split_at(split_pos);
80
81    // Parse numeric value
82    let value: u64 = value_str
83        .parse()
84        .context("Duration value must be a positive integer")?;
85
86    // Convert to seconds based on unit
87    let seconds = match unit {
88        "h" => value
89            .checked_mul(SECONDS_PER_HOUR)
90            .context("Duration value too large (overflow)")?,
91        "d" => value
92            .checked_mul(SECONDS_PER_DAY)
93            .context("Duration value too large (overflow)")?,
94        "w" => value
95            .checked_mul(SECONDS_PER_WEEK)
96            .context("Duration value too large (overflow)")?,
97        _ => {
98            bail!("Unsupported duration unit '{unit}'. Use 'h' (hours), 'd' (days), or 'w' (weeks)")
99        }
100    };
101
102    // Validate bounds
103    let min_seconds = MIN_ROTATION_INTERVAL_HOURS
104        .checked_mul(SECONDS_PER_HOUR)
105        .expect("MIN_ROTATION_INTERVAL_HOURS constant overflow");
106
107    let max_seconds = MAX_ROTATION_INTERVAL_HOURS
108        .checked_mul(SECONDS_PER_HOUR)
109        .expect("MAX_ROTATION_INTERVAL_HOURS constant overflow");
110
111    if seconds < min_seconds {
112        bail!(
113            "Duration '{duration_str}' is below minimum of {MIN_ROTATION_INTERVAL_HOURS}h (1 hour)"
114        );
115    }
116
117    if seconds > max_seconds {
118        bail!(
119            "Duration '{duration_str}' exceeds maximum of {MAX_ROTATION_INTERVAL_HOURS}h (365 days)"
120        );
121    }
122
123    Ok(Duration::from_secs(seconds))
124}
125
126#[cfg(test)]
127#[path = "duration_tests.rs"]
128mod duration_tests;