crates/nornir-auth-saml/src/sp/time.rs
//! `xs:dateTime` as SAML writes it — `2024-01-01T00:05:00Z`, optionally with
//! fractional seconds, UTC (`Z`) or a `±hh:mm` offset — to Unix seconds and
//! back, without a date-time crate.
use super::SpError;
/// Days since 1970-01-01 for a proleptic Gregorian date (Howard Hinnant's
/// `days_from_civil`).
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
fn leap(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn days_in_month(y: i64, m: i64) -> i64 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ if leap(y) => 29,
_ => 28,
}
}
fn digits(s: &str, what: &str) -> Result<i64, SpError> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(SpError::Malformed(format!(
"{what} is not a number in an xs:dateTime"
)));
}
s.parse::<i64>()
.map_err(|_| SpError::Malformed(format!("{what} is out of range in an xs:dateTime")))
}
/// Parse an `xs:dateTime` to Unix seconds (fractions dropped).
pub fn parse_instant(text: &str) -> Result<i64, SpError> {
let s = text.trim();
let bad = || SpError::Malformed(format!("{s:?} is not an xs:dateTime"));
// Every slice below is by byte: only ASCII keeps them on char boundaries
// (A34-33). An xs:dateTime is ASCII anyway.
if !s.is_ascii() || s.len() < 20 || s.len() > 40 {
return Err(bad());
}
let b = s.as_bytes();
if b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
return Err(bad());
}
let year = digits(&s[0..4], "the year")?;
let month = digits(&s[5..7], "the month")?;
let day = digits(&s[8..10], "the day")?;
let hour = digits(&s[11..13], "the hour")?;
let minute = digits(&s[14..16], "the minute")?;
let second = digits(&s[17..19], "the second")?;
let mut rest = &s[19..];
if let Some(frac) = rest.strip_prefix('.') {
let n = frac.bytes().take_while(u8::is_ascii_digit).count();
if n == 0 {
return Err(bad());
}
rest = &frac[n..];
}
let offset = match rest {
"Z" => 0,
_ if rest.len() == 6
&& (rest.starts_with('+') || rest.starts_with('-'))
&& &rest[3..4] == ":" =>
{
let h = digits(&rest[1..3], "the offset hour")?;
let m = digits(&rest[4..6], "the offset minute")?;
if h > 14 || m > 59 {
return Err(bad());
}
let secs = h * 3600 + m * 60;
if rest.starts_with('+') {
secs
} else {
-secs
}
}
_ => {
return Err(SpError::Malformed(format!(
"{s:?} carries no time zone (SAML times are UTC)"
)))
}
};
if !(1..=12).contains(&month)
|| day < 1
|| day > days_in_month(year, month)
|| hour > 23
|| minute > 59
|| second > 59
{
return Err(bad());
}
let days = days_from_civil(year, month, day);
Ok(days * 86_400 + hour * 3600 + minute * 60 + second - offset)
}
/// Unix seconds as `YYYY-MM-DDThh:mm:ssZ` (an `IssueInstant`).
pub fn format_instant(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let secs = unix.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
secs / 3600,
(secs % 3600) / 60,
secs % 60
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_shapes_saml_uses() {
assert_eq!(parse_instant("1970-01-01T00:00:00Z").unwrap(), 0);
assert_eq!(
parse_instant("2024-01-01T00:05:00Z").unwrap(),
1_704_067_500
);
assert_eq!(
parse_instant("2024-01-01T00:05:00.999Z").unwrap(),
1_704_067_500
);
assert_eq!(
parse_instant("2024-01-01T01:05:00+01:00").unwrap(),
1_704_067_500
);
assert_eq!(
parse_instant("2023-12-31T23:05:00-01:00").unwrap(),
1_704_067_500
);
assert_eq!(
parse_instant("2024-02-29T12:00:00Z").unwrap(),
1_709_208_000
);
}
#[test]
fn refuses_what_is_not_a_utc_instant() {
for bad in [
"2024-01-01T00:05:00",
"2024-01-01 00:05:00Z",
"2023-02-29T00:00:00Z",
"2024-13-01T00:00:00Z",
"2024-01-01T24:00:00Z",
"2024-01-01T00:00:60Z",
"2024-01-01T00:00:00.Z",
"2024-01-01T00:00:00+1:00",
"yesterday",
"",
] {
assert!(parse_instant(bad).is_err(), "{bad:?} must be refused");
}
}
/// A34-33: the parser slices by byte, so a multi-byte character placed where
/// a separator or a digit belongs must be refused, never panic on a char
/// boundary. Each of these is the length and shape the byte checks expect.
#[test]
fn refuses_non_ascii_without_panicking() {
for bad in [
"2024-01-01T00:00:00+0é:0",
"2024-01-01T00:00:00+é:00",
"202é-01-01T00:00:00Z",
"2024-0é-01T00:00:00Z",
"2024-01-01T0é:00:00Z",
"2024-01-01T00:00:0éZ",
"2024-01-01T00:00:00.1é",
"2024-01-01T00:00:00Z",
"2024-01-01T00:00:00Z\u{200b}",
] {
let got = std::panic::catch_unwind(|| parse_instant(bad));
match got {
Ok(r) => assert!(r.is_err(), "{bad:?} must be refused"),
Err(_) => panic!("{bad:?} made parse_instant panic"),
}
}
}
#[test]
fn formats_round_trip() {
for t in [0, 1_704_067_500, 1_709_208_000, 4_102_444_800, 951_782_400] {
assert_eq!(parse_instant(&format_instant(t)).unwrap(), t);
}
assert_eq!(format_instant(1_704_067_500), "2024-01-01T00:05:00Z");
}
}