dwara_core/config/versioning.rs
1//! API-versioning grammar (DW-048): the two shared vocabularies the
2//! versioning aids need beyond serde shapes — HTTP-dates (RFC 9110
3//! IMF-fixdate) and media types (RFC 9110 `type/subtype` tokens).
4//!
5//! Both are config-contract grammar in the same sense as `net.rs` (the
6//! IP/CIDR vocabulary) and `normalize_origin` (the CORS origin
7//! vocabulary): validation (`snapshot::validate`) and the runtime
8//! (`dataplane::versioning`) must agree on ONE parsing of these strings,
9//! so it lives in `config`, the lowest consuming domain, and everything
10//! above imports it from here.
11//!
12//! ## Why IMF-fixdate only
13//!
14//! RFC 9110 defines three HTTP-date formats (IMF-fixdate, RFC 850,
15//! asctime) and requires RECIPIENTS to accept all three — but generators
16//! "MUST send the IMF-fixdate form". This module is not a recipient of
17//! arbitrary network dates; it parses OPERATOR-AUTHORED config strings
18//! that this gateway will echo verbatim into the `Sunset` response
19//! header. Accepting only the one form generators must produce keeps the
20//! emitted header canonical and the validation message short; the
21//! obsolete forms are rejected with an example of the expected shape.
22//!
23//! ## Why bare media types
24//!
25//! The `match.accept` criterion matches a media TYPE (`type/subtype`),
26//! ignoring parameters and q-values on the request side — the
27//! versioned-media-type convention embeds the version in the subtype
28//! (`application/vnd.acme.v2+json`). Parameter-form versioning
29//! (`application/vnd.acme+json;version=2`) is deliberately unsupported:
30//! Accept entries mix media-type parameters with accept-extensions
31//! (RFC 9110 section 12.5.1), and separating them reliably is parser
32//! work out of proportion to an S-sized aid. Wildcards are rejected as
33//! CONFIG values because they can never match (the criterion requires
34//! the client to NAME the version explicitly — see
35//! `dataplane::versioning`).
36
37/// A parsed IMF-fixdate HTTP-date (DW-048). The original config string is
38/// what gets echoed into the `Sunset` header (validated verbatim); this
39/// type carries the derived Unix seconds the RFC 9745 `Deprecation`
40/// structured date (`@<seconds>`) needs.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct HttpDate {
43 unix_seconds: i64,
44}
45
46impl HttpDate {
47 /// Seconds since the Unix epoch (UTC by definition of the format —
48 /// the only allowed zone is the literal `GMT`).
49 pub fn unix_seconds(self) -> i64 {
50 self.unix_seconds
51 }
52}
53
54/// Day names in IMF-fixdate order (index 0 = Sunday), for the
55/// day-of-week consistency check.
56const DAY_NAMES: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
57
58/// Month names in IMF-fixdate order, paired with their day counts
59/// (February's placeholder is replaced by the leap-year rule).
60const MONTHS: [(&str, u32); 12] = [
61 ("Jan", 31),
62 ("Feb", 28),
63 ("Mar", 31),
64 ("Apr", 30),
65 ("May", 31),
66 ("Jun", 30),
67 ("Jul", 31),
68 ("Aug", 31),
69 ("Sep", 30),
70 ("Oct", 31),
71 ("Nov", 30),
72 ("Dec", 31),
73];
74
75/// Parse an IMF-fixdate HTTP-date (DW-048):
76/// `day-name "," SP 2DIGIT SP month SP 4DIGIT SP time SP "GMT"`, e.g.
77/// `Sun, 06 Nov 1994 08:49:37 GMT`. Returns `None` for anything else —
78/// including the obsolete RFC 850 / asctime forms (see the module docs
79/// for why) and a day-name that disagrees with the date it names (a
80/// typo'd weekday is a typo'd date; `date -u` output always agrees).
81pub fn parse_http_date(value: &str) -> Option<HttpDate> {
82 // Single spaces only: splitting "Sun, 06 Nov..." (double space)
83 // yields an empty part and fails the exact-count check below.
84 let parts: Vec<&str> = value.split(' ').collect();
85 if parts.len() != 6 {
86 return None;
87 }
88 let day_name = parts[0].strip_suffix(',')?;
89 let day: u32 = two_digits(parts[1])?;
90 let month = MONTHS.iter().find(|(name, _)| *name == parts[2])?;
91 let year: i64 = four_digits(parts[3])?;
92 let (hour, minute, second) = parse_time(parts[4])?;
93 if parts[5] != "GMT" {
94 return None;
95 }
96
97 // Day-of-month bound (leap-year rule: divisible by 4, except
98 // centuries, except quadricenturies).
99 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
100 let max_day = if month.0 == "Feb" && leap {
101 29
102 } else {
103 month.1
104 };
105 if !(1..=max_day).contains(&day) {
106 return None;
107 }
108
109 let days = days_from_civil(year, month_index(parts[2])?, day);
110 // Weekday consistency: Unix day 0 (1970-01-01) was a Thursday.
111 let weekday = ((days % 7) + 7 + 4) % 7;
112 if DAY_NAMES[weekday as usize] != day_name {
113 return None;
114 }
115
116 Some(HttpDate {
117 unix_seconds: days * 86_400
118 + i64::from(hour) * 3_600
119 + i64::from(minute) * 60
120 + i64::from(second),
121 })
122}
123
124/// Parse an HTTP `Retry-After` header value (DW-089) into a duration
125/// from NOW. Per RFC 9110 the value is either a non-negative integer
126/// (seconds to wait) or an IMF-fixdate (the time at which to retry).
127/// Returns `None` for anything else (the caller treats a missing/invalid
128/// header as no backoff signal). For the HTTP-date form the duration is
129/// `max(0, date - now)`; a date in the past yields a zero duration (the
130/// advertised window has already elapsed).
131pub fn parse_retry_after(value: &str, now_unix_seconds: i64) -> Option<std::time::Duration> {
132 let trimmed = value.trim();
133 // Integer seconds form: a non-negative decimal integer.
134 if let Ok(secs) = trimmed.parse::<u64>() {
135 return Some(std::time::Duration::from_secs(secs));
136 }
137 // HTTP-date form: parse the IMF-fixdate and compute the remaining
138 // window from the caller's "now".
139 let date = parse_http_date(trimmed)?;
140 let remaining = date.unix_seconds.saturating_sub(now_unix_seconds);
141 if remaining <= 0 {
142 return Some(std::time::Duration::ZERO);
143 }
144 Some(std::time::Duration::from_secs(remaining as u64))
145}
146
147/// Normalize a configured media type (DW-048): trim, lowercase, and
148/// require a bare `type/subtype` — both halves non-empty RFC 9110
149/// tokens, no parameters (`;...`), no whitespace, no wildcards. The
150/// lowercased form is the comparison key; `None` marks an authoring
151/// error validation reports.
152pub fn normalize_media_type(value: &str) -> Option<String> {
153 let lower = value.trim().to_ascii_lowercase();
154 let (ty, subtype) = lower.split_once('/')?;
155 if ty.is_empty() || subtype.is_empty() || ty == "*" || subtype == "*" {
156 return None;
157 }
158 if !ty.bytes().chain(subtype.bytes()).all(is_tchar) {
159 return None;
160 }
161 Some(format!("{ty}/{subtype}"))
162}
163
164/// RFC 9110 `tchar` (token character): the entire grammar of a media
165/// type's type and subtype names.
166fn is_tchar(b: u8) -> bool {
167 b.is_ascii_alphanumeric()
168 || matches!(
169 b,
170 b'!' | b'#'
171 | b'$'
172 | b'%'
173 | b'&'
174 | b'\''
175 | b'*'
176 | b'+'
177 | b'-'
178 | b'.'
179 | b'^'
180 | b'_'
181 | b'`'
182 | b'|'
183 | b'~'
184 )
185}
186
187fn two_digits(s: &str) -> Option<u32> {
188 let b = s.as_bytes();
189 if b.len() == 2 && b[0].is_ascii_digit() && b[1].is_ascii_digit() {
190 Some(u32::from((b[0] - b'0') * 10 + (b[1] - b'0')))
191 } else {
192 None
193 }
194}
195
196fn four_digits(s: &str) -> Option<i64> {
197 let b = s.as_bytes();
198 if b.len() == 4 && b.iter().all(u8::is_ascii_digit) {
199 Some(b.iter().fold(0i64, |acc, d| acc * 10 + i64::from(d - b'0')))
200 } else {
201 None
202 }
203}
204
205/// `HH:MM:SS`, each a two-digit field with the clock bounds enforced.
206fn parse_time(s: &str) -> Option<(u32, u32, u32)> {
207 let parts: Vec<&str> = s.split(':').collect();
208 if parts.len() != 3 {
209 return None;
210 }
211 let hour = two_digits(parts[0])?;
212 let minute = two_digits(parts[1])?;
213 let second = two_digits(parts[2])?;
214 if hour > 23 || minute > 59 || second > 59 {
215 return None;
216 }
217 Some((hour, minute, second))
218}
219
220fn month_index(name: &str) -> Option<u32> {
221 // 1..=12 (March = 3), matching days_from_civil's convention.
222 Some(match name {
223 "Jan" => 1,
224 "Feb" => 2,
225 "Mar" => 3,
226 "Apr" => 4,
227 "May" => 5,
228 "Jun" => 6,
229 "Jul" => 7,
230 "Aug" => 8,
231 "Sep" => 9,
232 "Oct" => 10,
233 "Nov" => 11,
234 "Dec" => 12,
235 _ => return None,
236 })
237}
238
239/// Days from 1970-01-01 for a civil (proleptic Gregorian) date — Howard
240/// Hinnant's `days_from_civil`, the standard integer-form conversion.
241fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
242 let y = if m <= 2 { y - 1 } else { y };
243 let era = if y >= 0 { y } else { y - 399 } / 400;
244 let yoe = y - era * 400; // [0, 399]
245 let mp = (i64::from(m) + 9) % 12; // [0, 11], March = 0
246 let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
247 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
248 era * 146_097 + doe - 719_468
249}