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}