Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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");
    }
}