Skip to main content

rustlavel_http/
date.rs

1//! HTTP dates: `Date`, `Last-Modified`, `If-Modified-Since`, `Expires`.
2//!
3//! RFC 9110 §5.6.7 has one format for sending — IMF-fixdate,
4//! `Sun, 06 Nov 1994 08:49:37 GMT` — and two obsolete ones a recipient must
5//! still accept: RFC 850's `Sunday, 06-Nov-94 08:49:37 GMT` and C's `asctime`,
6//! `Sun Nov  6 08:49:37 1994`. Nothing has sent the old two in decades, but a
7//! validator that rejects them makes a conditional request unconditional, and
8//! the client pays for a body it already had.
9
10/// Format a unix timestamp as an HTTP date (RFC 7231 IMF-fixdate).
11pub fn http_date(unix: i64) -> String {
12    const DAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
13    const MONTHS: [&str; 12] = [
14        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
15    ];
16
17    let days_since_epoch = unix.div_euclid(86_400);
18    let seconds_of_day = unix.rem_euclid(86_400);
19    let (year, month, day) = civil_from_days(days_since_epoch);
20
21    format!(
22        "{}, {:02} {} {} {:02}:{:02}:{:02} GMT",
23        DAYS[(days_since_epoch.rem_euclid(7)) as usize],
24        day,
25        MONTHS[(month - 1) as usize],
26        year,
27        seconds_of_day / 3600,
28        (seconds_of_day % 3600) / 60,
29        seconds_of_day % 60,
30    )
31}
32
33/// Days since the unix epoch to a civil (year, month, day).
34///
35/// Howard Hinnant's `civil_from_days`, shifted to a March-based year so leap
36/// days land at the end and need no special case.
37fn civil_from_days(days: i64) -> (i64, u32, u32) {
38    let z = days + 719_468;
39    let era = z.div_euclid(146_097);
40    let day_of_era = z.rem_euclid(146_097);
41    let year_of_era =
42        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
43    let year = year_of_era + era * 400;
44    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
45    let mp = (5 * day_of_year + 2) / 153;
46    let day = (day_of_year - (153 * mp + 2) / 5 + 1) as u32;
47    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
48    (year + i64::from(month <= 2), month, day)
49}
50
51
52/// Parse any of the three HTTP date formats to a unix timestamp.
53///
54/// Returns `None` for anything malformed — a conditional header that cannot be
55/// read is treated as absent, which is what RFC 9110 §13.1.3 asks for.
56pub fn parse_http_date(text: &str) -> Option<i64> {
57    let text = text.trim();
58    let parts: Vec<&str> = text.split_whitespace().collect();
59
60    let (day, month, year, clock) = match parts.as_slice() {
61        // IMF-fixdate: Sun, 06 Nov 1994 08:49:37 GMT
62        [weekday, day, month, year, clock, "GMT"] if weekday.ends_with(',') => {
63            (day.parse::<u32>().ok()?, month_number(month)?, year.parse::<i64>().ok()?, *clock)
64        }
65        // RFC 850: Sunday, 06-Nov-94 08:49:37 GMT
66        [weekday, date, clock, "GMT"] if weekday.ends_with(',') => {
67            let mut pieces = date.split('-');
68            let day = pieces.next()?.parse::<u32>().ok()?;
69            let month = month_number(pieces.next()?)?;
70            let year = pieces.next()?.parse::<i64>().ok()?;
71            if pieces.next().is_some() {
72                return None;
73            }
74            // Two-digit years: RFC 9110 §5.6.7 says to read them as the most
75            // recent year in the past with that ending, and this is close enough
76            // for a format that was obsolete before most of today's web existed.
77            let year = if year < 100 { if year < 70 { 2000 + year } else { 1900 + year } } else { year };
78            (day, month, year, *clock)
79        }
80        // asctime: Sun Nov  6 08:49:37 1994
81        [_weekday, month, day, clock, year] => {
82            (day.parse::<u32>().ok()?, month_number(month)?, year.parse::<i64>().ok()?, *clock)
83        }
84        _ => return None,
85    };
86
87    let mut clock = clock.split(':');
88    let hour: i64 = clock.next()?.parse().ok()?;
89    let minute: i64 = clock.next()?.parse().ok()?;
90    let second: i64 = clock.next()?.parse().ok()?;
91    if clock.next().is_some() || hour > 23 || minute > 59 || second > 60 {
92        return None;
93    }
94    if day == 0 || day > 31 {
95        return None;
96    }
97
98    let days = days_from_civil(year, month, day);
99    Some(days * 86_400 + hour * 3600 + minute * 60 + second)
100}
101
102/// `YYYY-MM-DD` at midnight UTC, as a unix timestamp.
103///
104/// For dates written in configuration and code — a sunset, a deprecation —
105/// where the ISO form is what a person types.
106pub fn parse_ymd(text: &str) -> Option<i64> {
107    let mut parts = text.trim().split('-');
108    let year: i64 = parts.next()?.parse().ok()?;
109    let month: u32 = parts.next()?.parse().ok()?;
110    let day: u32 = parts.next()?.parse().ok()?;
111    if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
112        return None;
113    }
114    Some(days_from_civil(year, month, day) * 86_400)
115}
116
117fn month_number(name: &str) -> Option<u32> {
118    const MONTHS: [&str; 12] = [
119        "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
120    ];
121    let lower = name.to_ascii_lowercase();
122    MONTHS.iter().position(|m| *m == lower).map(|i| i as u32 + 1)
123}
124
125/// The inverse of [`civil_from_days`], from the same source.
126fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
127    let year = if month <= 2 { year - 1 } else { year };
128    let era = year.div_euclid(400);
129    let year_of_era = year.rem_euclid(400);
130    let month = i64::from(month);
131    let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + i64::from(day) - 1;
132    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
133    era * 146_097 + day_of_era - 719_468
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn formats_known_http_dates() {
142        assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
143        assert_eq!(http_date(1_000_000_000), "Sun, 09 Sep 2001 01:46:40 GMT");
144        assert_eq!(http_date(784_111_777), "Sun, 06 Nov 1994 08:49:37 GMT");
145    }
146
147    #[test]
148    fn parses_all_three_formats_to_the_same_instant() {
149        // The three examples RFC 9110 §5.6.7 gives, which all name one moment.
150        assert_eq!(parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT"), Some(784_111_777));
151        assert_eq!(parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT"), Some(784_111_777));
152        assert_eq!(parse_http_date("Sun Nov  6 08:49:37 1994"), Some(784_111_777));
153    }
154
155    #[test]
156    fn formatting_and_parsing_round_trip() {
157        for unix in [0, 1, 86_399, 951_782_400, 1_000_000_000, 1_709_164_800, 4_102_444_800] {
158            assert_eq!(parse_http_date(&http_date(unix)), Some(unix), "{unix}");
159        }
160    }
161
162    #[test]
163    fn reads_iso_dates_at_midnight() {
164        assert_eq!(parse_ymd("1970-01-01"), Some(0));
165        assert_eq!(parse_ymd("1994-11-06"), Some(784_111_777 - (8 * 3600 + 49 * 60 + 37)));
166        assert_eq!(parse_ymd("2027-01-01"), Some(1_798_761_600));
167        assert_eq!(parse_ymd("2027-13-01"), None);
168        assert_eq!(parse_ymd("2027-01"), None);
169        assert_eq!(parse_ymd("tomorrow"), None);
170    }
171
172    #[test]
173    fn rejects_what_it_cannot_read() {
174        assert_eq!(parse_http_date(""), None);
175        assert_eq!(parse_http_date("yesterday"), None);
176        assert_eq!(parse_http_date("Sun, 06 Nov 1994 08:49:37 PST"), None);
177        assert_eq!(parse_http_date("Sun, 06 Nov 1994 25:00:00 GMT"), None);
178        assert_eq!(parse_http_date("Sun, 32 Nov 1994 08:49:37 GMT"), None);
179        assert_eq!(parse_http_date("Sun, 06 Foo 1994 08:49:37 GMT"), None);
180    }
181}