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}