Skip to main content

kopiur_api/
duration.rs

1//! Go-style duration strings used across the CRDs (`30m`, `1h`, `90s`).
2//!
3//! Lives in `kopiur-api` (not the controller) so the admission validators and
4//! the reconcilers parse the exact same grammar — a value the webhook admits
5//! must never fail to parse at reconcile time.
6
7use std::time::Duration;
8
9/// Parse a Go-style duration string used in the CRDs (`30m`, `1h`, `90s`, or a
10/// bare number of seconds). Returns `None` for unparseable input.
11pub fn parse_go_duration(s: &str) -> Option<Duration> {
12    let s = s.trim();
13    if s.is_empty() {
14        return None;
15    }
16    // Support a single unit suffix (s/m/h) or a bare number of seconds.
17    let (num, mult) = if let Some(stripped) = s.strip_suffix('h') {
18        (stripped, 3600u64)
19    } else if let Some(stripped) = s.strip_suffix('m') {
20        (stripped, 60)
21    } else if let Some(stripped) = s.strip_suffix('s') {
22        (stripped, 1)
23    } else {
24        (s, 1)
25    };
26    // `checked_mul` so an absurd value (`9999999999999999h`) returns `None` — the
27    // webhook then rejects it instead of panicking (debug) or wrapping to a garbage
28    // duration (release) on the unchecked multiply.
29    num.trim()
30        .parse::<u64>()
31        .ok()
32        .and_then(|n| n.checked_mul(mult))
33        .map(Duration::from_secs)
34}
35
36/// Render a [`Duration`] back to a Go-style duration string, using the largest unit that
37/// divides it exactly (`21600s` → `"6h"`, `1200s` → `"20m"`, else `"{n}s"`).
38///
39/// Round-trips through [`parse_go_duration`] by construction — it emits only the
40/// single-unit grammar that function accepts. Two callers need it:
41///
42/// - **kopia argv.** Durations that reach a kopia CLI flag must never be the user's raw
43///   text. kopia's `time.ParseDuration` REJECTS a bare number (`--epoch-min-duration=3600`
44///   → `time: missing unit in duration "3600"`) while `parse_go_duration` happily accepts
45///   one — so passing the string through would admit at the webhook and crash in the mover,
46///   breaking this module's stated contract. Parse, then render, and the mismatch is gone.
47/// - **Status mirrors.** kopia reports durations as `time.Duration` nanoseconds; rendering
48///   them through here is what makes `status` comparable to `spec` and stable across
49///   reconciles (no `"24h"` vs `"24h0m0s"` ambiguity).
50pub fn render_go_duration(d: Duration) -> String {
51    let secs = d.as_secs();
52    if secs != 0 && secs.is_multiple_of(3600) {
53        format!("{}h", secs / 3600)
54    } else if secs != 0 && secs.is_multiple_of(60) {
55        format!("{}m", secs / 60)
56    } else {
57        format!("{secs}s")
58    }
59}
60
61/// Resolve an optional policy timeout string to an effective deadline duration,
62/// with the semantics every `*.timeout` field shares (`spec.preflight.timeout`,
63/// `spec.staging.timeout`): absent ⇒ `default`; parsed-zero (`0`/`0s`) ⇒ `None`
64/// (indefinite — never expires); unparseable ⇒ `default` (defensive only — the
65/// webhook rejects unparseable values at admission).
66pub fn resolve_timeout(spec: Option<&str>, default: Duration) -> Option<Duration> {
67    match spec {
68        None => Some(default),
69        Some(s) => match parse_go_duration(s) {
70            Some(d) if d.is_zero() => None,
71            Some(d) => Some(d),
72            None => Some(default),
73        },
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn resolve_timeout_shared_semantics() {
83        let default = Duration::from_secs(600);
84        assert_eq!(resolve_timeout(None, default), Some(default));
85        assert_eq!(
86            resolve_timeout(Some("30m"), default),
87            Some(Duration::from_secs(1800))
88        );
89        // Zero means indefinite (never expires), not "expire immediately".
90        assert_eq!(resolve_timeout(Some("0"), default), None);
91        assert_eq!(resolve_timeout(Some("0s"), default), None);
92        // Unparseable falls back to the default (webhook rejects it anyway).
93        assert_eq!(resolve_timeout(Some("every-hour"), default), Some(default));
94    }
95
96    #[test]
97    fn parse_go_duration_handles_units() {
98        assert_eq!(parse_go_duration("30m"), Some(Duration::from_secs(1800)));
99        assert_eq!(parse_go_duration("1h"), Some(Duration::from_secs(3600)));
100        assert_eq!(parse_go_duration("45s"), Some(Duration::from_secs(45)));
101        assert_eq!(parse_go_duration("120"), Some(Duration::from_secs(120)));
102        assert_eq!(parse_go_duration(" 5m "), Some(Duration::from_secs(300)));
103        assert_eq!(parse_go_duration(""), None);
104        assert_eq!(parse_go_duration("bogus"), None);
105        assert_eq!(parse_go_duration("-5m"), None);
106        // Overflow on the unit multiply must be rejected, not panic/wrap.
107        assert_eq!(parse_go_duration("9999999999999999h"), None);
108        assert_eq!(parse_go_duration(&format!("{}m", u64::MAX)), None);
109        // A bare (unmultiplied) large second count still parses.
110        assert_eq!(
111            parse_go_duration(&u64::MAX.to_string()),
112            Some(Duration::from_secs(u64::MAX))
113        );
114    }
115
116    #[test]
117    fn render_go_duration_picks_the_largest_exact_unit() {
118        assert_eq!(render_go_duration(Duration::from_secs(21600)), "6h");
119        assert_eq!(render_go_duration(Duration::from_secs(86400)), "24h");
120        assert_eq!(render_go_duration(Duration::from_secs(1200)), "20m");
121        assert_eq!(render_go_duration(Duration::from_secs(45)), "45s");
122        // Not evenly divisible → fall back to seconds rather than lose precision.
123        assert_eq!(render_go_duration(Duration::from_secs(5400)), "90m");
124        assert_eq!(render_go_duration(Duration::from_secs(3661)), "3661s");
125        // Zero must not divide into "0h".
126        assert_eq!(render_go_duration(Duration::ZERO), "0s");
127        // Sub-second precision is not representable in this grammar; truncation is
128        // acceptable because every CRD field using it is coarse (minutes and up).
129        assert_eq!(render_go_duration(Duration::from_millis(1500)), "1s");
130    }
131
132    #[test]
133    fn render_go_duration_round_trips_through_parse() {
134        for secs in [0u64, 1, 45, 59, 60, 90, 1200, 3600, 5400, 21600, 86400] {
135            let d = Duration::from_secs(secs);
136            assert_eq!(
137                parse_go_duration(&render_go_duration(d)),
138                Some(d),
139                "render must emit only what parse accepts ({secs}s)"
140            );
141        }
142    }
143
144    #[test]
145    fn rendering_is_what_makes_a_bare_number_safe_for_kopias_cli() {
146        // kopia's time.ParseDuration REJECTS a bare number: `--epoch-min-duration=3600`
147        // fails with `time: missing unit in duration "3600"`. parse_go_duration accepts
148        // it, so passing user text straight to kopia would admit at the webhook and die
149        // in the mover. Rendering always emits a unit — that is the whole point.
150        let d = parse_go_duration("3600").expect("kopiur accepts a bare second count");
151        let rendered = render_go_duration(d);
152        assert_eq!(rendered, "1h");
153        assert!(
154            rendered.ends_with(['h', 'm', 's']),
155            "a rendered duration must always carry a unit for kopia: {rendered}"
156        );
157    }
158}