// Date-time and specification-version parsing for TUF metadata.
//
// The TUF specification states that metadata date-times follow ISO 8601 with
// the exact combined form `YYYY-MM-DDTHH:MM:SSZ`, always in UTC. In practice
// repositories and generators also emit a fractional part or a numeric offset,
// and a preflight layer has to say something useful about those documents
// instead of rejecting them as "invalid-expires" with no explanation.
//
// This file therefore separates three questions:
//
// 1. is the string a date-time at all, and is it exactly the relaxed form?
// 2. what instant does it denote, in seconds since the Unix epoch?
// 3. how do two instants compare?
//
// The parsing is deliberately allocation-light and endian-explicit: every
// component is read at a fixed position, so a malformed string can never be
// partially consumed.
///|
/// The relaxed ISO 8601 / RFC 3339 subset this parser accepts.
pub enum DateTimeForm {
/// `YYYY-MM-DDTHH:MM:SSZ`, the form the TUF specification prescribes.
Exact
/// The same, with a fractional second part such as `2030-01-01T00:00:00.5Z`.
Fractional
/// The same, with a numeric offset such as `2030-01-01T00:00:00+08:00`.
Offset
}
///|
/// Whether two forms describe the same shape.
pub fn DateTimeForm::equal(self : DateTimeForm, other : DateTimeForm) -> Bool {
match (self, other) {
(Exact, Exact) | (Fractional, Fractional) | (Offset, Offset) => true
_ => false
}
}
///|
/// A parsed date-time plus the shape it was written in.
pub struct DateTime {
/// Seconds since 1970-01-01T00:00:00Z. Negative for earlier instants.
epoch_seconds : Int64
/// The shape of the source string, so callers can report drift from the
/// canonical TUF form without losing the instant.
form : DateTimeForm
/// The offset in minutes that was applied, `0` for `Z` and for exact strings.
offset_minutes : Int
}
///|
/// Whether two parsed instants are identical.
pub fn DateTime::equal(self : DateTime, other : DateTime) -> Bool {
self.epoch_seconds == other.epoch_seconds &&
self.form.equal(other.form) &&
self.offset_minutes == other.offset_minutes
}
///|
/// A semver version as the three numeric components the TUF specification
/// requires for `spec_version`. A missing component defaults to zero, so `"1"`
/// and `"1.0"` both parse and compare as `1.0.0`; a pre-release or build suffix
/// is recorded instead, because the specification calls for a plain semantic
/// version.
pub struct Semver {
major : Int
minor : Int
patch : Int
/// `""`, or the suffix including its leading `-` or `+`.
suffix : String
}
///|
/// Whether two semantic versions are identical.
pub fn Semver::equal(self : Semver, other : Semver) -> Bool {
self.major == other.major &&
self.minor == other.minor &&
self.patch == other.patch &&
self.suffix == other.suffix
}
///|
/// The instant as seconds since the Unix epoch.
pub fn DateTime::epoch_seconds(self : DateTime) -> Int64 {
self.epoch_seconds
}
///|
/// The shape of the source string.
pub fn DateTime::form(self : DateTime) -> DateTimeForm {
self.form
}
///|
/// The numeric UTC offset in minutes that was applied during parsing.
pub fn DateTime::offset_minutes(self : DateTime) -> Int {
self.offset_minutes
}
///|
/// Whether the string was written in the exact form the specification asks for.
pub fn DateTime::is_canonical(self : DateTime) -> Bool {
self.form is Exact
}
///|
/// A short name for the form, used in diagnostics and tests.
pub fn DateTimeForm::to_string(self : DateTimeForm) -> String {
match self {
Exact => "exact"
Fractional => "fractional-seconds"
Offset => "numeric-offset"
}
}
///|
/// The number of days in `month` of `year`, honouring the Gregorian leap rule.
pub fn days_in_month(year : Int, month : Int) -> Int {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
4 | 6 | 9 | 11 => 30
2 => if is_leap_year(year) { 29 } else { 28 }
_ => 0
}
}
///|
/// Whether `year` is a Gregorian leap year.
pub fn is_leap_year(year : Int) -> Bool {
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}
///|
/// Days from 1970-01-01 to `year-month-day`, by the civil-from-days algorithm of
/// Howard Hinnant. Valid for any proleptic Gregorian date.
///
/// The era is computed with an explicit floor division because MoonBit's `Int`
/// division truncates toward zero, which would place dates before year 1 in the
/// wrong era.
pub fn days_from_civil(year : Int, month : Int, day : Int) -> Int64 {
let adjusted_year = if month <= 2 { year - 1 } else { year }
let era = if adjusted_year >= 0 {
adjusted_year / 400
} else {
(adjusted_year - 399) / 400
}
let year_of_era = adjusted_year - era * 400
let adjusted_month = if month > 2 { month - 3 } else { month + 9 }
let day_of_year = (153 * adjusted_month + 2) / 5 + day - 1
let day_of_era = year_of_era * 365 +
year_of_era / 4 -
year_of_era / 100 +
day_of_year
era.to_int64() * 146097 + day_of_era.to_int64() - 719468
}
///|
/// The two-digit integer at `index` of `chars`, or `-1` when either character is
/// not a decimal digit.
fn two_digits(chars : Array[Char], index : Int) -> Int {
let high = chars[index]
let low = chars[index + 1]
if high < '0' || high > '9' || low < '0' || low > '9' {
return -1
}
(high.to_int() - 48) * 10 + (low.to_int() - 48)
}
///|
/// The four-digit integer at `index` of `chars`, or `-1` when any character is
/// not a decimal digit.
fn four_digits(chars : Array[Char], index : Int) -> Int {
let mut value = 0
for offset in 0..<4 {
let char = chars[index + offset]
if char < '0' || char > '9' {
return -1
}
value = value * 10 + (char.to_int() - 48)
}
value
}
///|
/// Whether the calendar components denote a real instant.
fn valid_calendar(
year : Int,
month : Int,
day : Int,
hour : Int,
minute : Int,
second : Int,
) -> Bool {
if year < 1 || month < 1 || month > 12 || day < 1 {
return false
}
if day > days_in_month(year, month) {
return false
}
// Leap seconds are permitted by ISO 8601 but not by TUF metadata, and a
// second of 60 would make the epoch arithmetic below ambiguous.
hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 && second <= 59
}
///|
/// Parse the fractional part starting at `index` (which must point at the dot),
/// returning the digit count. The fraction is not folded into the instant: TUF
/// metadata has second resolution, so a fraction is reported as a form rather
/// than silently truncated into a comparison.
fn fraction_digits(chars : Array[Char], index : Int) -> Int {
let mut count = 0
let mut cursor = index + 1
while cursor < chars.length() && chars[cursor] >= '0' && chars[cursor] <= '9' {
count += 1
cursor += 1
}
count
}
///|
/// Parse a TUF metadata date-time.
///
/// Accepts the exact `YYYY-MM-DDTHH:MM:SSZ` form as well as the two deviations
/// that appear in real metadata: a fractional second part, and a numeric UTC
/// offset (`+HH:MM` / `-HH:MM`, with `+00:00` and `-00:00` allowed). Returns
/// `None` when the string is not a date-time of any accepted shape, which lets
/// the caller report one clear "not a date-time" finding instead of three
/// unrelated component errors.
pub fn parse_date_time(value : String) -> DateTime? {
let chars = value.iter().to_array()
// The shortest accepted form is 20 characters, `YYYY-MM-DDTHH:MM:SSZ`.
if chars.length() < 20 {
return None
}
if chars[4] != '-' ||
chars[7] != '-' ||
chars[10] != 'T' ||
chars[13] != ':' ||
chars[16] != ':' {
return None
}
let year = four_digits(chars, 0)
let month = two_digits(chars, 5)
let day = two_digits(chars, 8)
let hour = two_digits(chars, 11)
let minute = two_digits(chars, 14)
let second = two_digits(chars, 17)
if year < 0 || month < 0 || day < 0 || hour < 0 || minute < 0 || second < 0 {
return None
}
if !valid_calendar(year, month, day, hour, minute, second) {
return None
}
let mut cursor = 19
let mut form = Exact
if cursor < chars.length() && chars[cursor] == '.' {
let digits = fraction_digits(chars, cursor)
if digits == 0 {
return None
}
form = Fractional
cursor = cursor + 1 + digits
}
let mut offset_minutes = 0
if cursor < chars.length() && chars[cursor] == 'Z' {
cursor += 1
} else if cursor < chars.length() &&
(chars[cursor] == '+' || chars[cursor] == '-') {
let sign = if chars[cursor] == '+' { 1 } else { -1 }
cursor += 1
if cursor + 5 != chars.length() || chars[cursor + 2] != ':' {
return None
}
let offset_hour = two_digits(chars, cursor)
let offset_minute = two_digits(chars, cursor + 3)
if offset_hour < 0 ||
offset_minute < 0 ||
offset_hour > 23 ||
offset_minute > 59 {
return None
}
offset_minutes = sign * (offset_hour * 60 + offset_minute)
// Even `+00:00` is not the exact form the specification prescribes, so the
// offset shape is preserved rather than folded back into `Exact`.
form = Offset
cursor += 5
} else {
return None
}
if cursor != chars.length() {
return None
}
let days = days_from_civil(year, month, day)
let seconds_of_day = hour * 3600 + minute * 60 + second
let utc_seconds = days * 86400 +
seconds_of_day.to_int64() -
(offset_minutes * 60).to_int64()
Some(DateTime::{ epoch_seconds: utc_seconds, form, offset_minutes, })
}
///|
/// Whether `value` parses as a date-time of any accepted shape.
pub fn is_date_time(value : String) -> Bool {
match parse_date_time(value) {
Some(_) => true
None => false
}
}
///|
/// Compare two parsed instants.
pub fn compare_date_time(left : DateTime, right : DateTime) -> Int {
if left.epoch_seconds < right.epoch_seconds {
-1
} else if left.epoch_seconds > right.epoch_seconds {
1
} else {
0
}
}
///|
/// Seconds from `from` to `to`, which is negative when `to` is earlier.
pub fn seconds_between(from : DateTime, to : DateTime) -> Int64 {
to.epoch_seconds - from.epoch_seconds
}
///|
/// The major version number.
pub fn Semver::major(self : Semver) -> Int {
self.major
}
///|
/// The minor version number.
pub fn Semver::minor(self : Semver) -> Int {
self.minor
}
///|
/// The patch version number.
pub fn Semver::patch(self : Semver) -> Int {
self.patch
}
///|
/// The pre-release or build suffix, including its leading `-` or `+`, or `""`.
pub fn Semver::suffix(self : Semver) -> String {
self.suffix
}
///|
/// Whether the suffix is empty, which is what `spec_version` should look like.
pub fn Semver::is_plain(self : Semver) -> Bool {
self.suffix == ""
}
///|
/// The canonical `major.minor.patch` rendering.
pub fn Semver::to_string(self : Semver) -> String {
self.major.to_string() +
"." +
self.minor.to_string() +
"." +
self.patch.to_string() +
self.suffix
}
///|
/// Parse a semantic version.
///
/// Returns `None` when the string is not made of dot-separated decimal numbers
/// with an optional `-`/`+` suffix, or when a numeric component is empty (as in
/// `1..0`) or has a leading zero (as in `01.0.0`), both of which semver forbids.
///
/// The parser is written with local loop state on purpose: MoonBit scopes loop
/// bodies, so mutating an outer `let mut` (or a captured `Ref`) from the body
/// and then leaving the loop can silently discard the assignment. Every value
/// this function needs is therefore derived from the return of a helper.
pub fn parse_semver(value : String) -> Semver? {
let chars = value.iter().to_array()
let length = chars.length()
if length == 0 {
return None
}
let (major, minor, patch, seen) = scan_semver_components(chars, length)
if seen == 0 {
return None
}
let suffix = if seen >= length {
""
} else {
let char = chars[seen]
if char == '-' || char == '+' {
value[seen:].to_owned()
} else {
return None
}
}
Some(Semver::{ major, minor, patch, suffix, })
}
///|
/// Read the leading `major[.minor[.patch]]` part of a semantic version.
///
/// Returns the three components, defaulted to zero, and the index at which the
/// numeric part stopped.
fn scan_semver_components(
chars : Array[Char],
length : Int,
) -> (Int, Int, Int, Int) {
let mut index = 0
let mut seen = 0
let mut major = 0
let mut minor = 0
let mut patch = 0
while index < length {
let char = chars[index]
if char < '0' || char > '9' {
// A dot here would mean an empty component; anything else ends the
// numeric part and is validated by the caller.
return (major, minor, patch, index)
}
let start = index
let mut component = 0
while index < length && chars[index] >= '0' && chars[index] <= '9' {
component = component * 10 + (chars[index].to_int() - 48)
index += 1
}
let digits = index - start
if digits > 1 && chars[start] == '0' {
// Leading zeros are rejected by semver.
return (major, minor, patch, 0)
}
if digits > 9 {
// A component that cannot fit an Int would silently wrap.
return (major, minor, patch, 0)
}
if seen == 0 {
major = component
} else if seen == 1 {
minor = component
} else if seen == 2 {
patch = component
} else {
return (major, minor, patch, 0)
}
seen += 1
if index >= length {
return (major, minor, patch, index)
}
if chars[index] != '.' {
return (major, minor, patch, index)
}
index += 1
if index == length {
// A trailing dot means a missing component.
return (major, minor, patch, 0)
}
}
(major, minor, patch, index)
}
///|
/// Whether `candidate` is compatible with the `expected` specification version
/// under the matching rule the TUF specification leaves to adopters: the major
/// version must be equal, and the candidate must not be newer than expected.
///
/// This is the rule this project documents for `preflight_authorized`; a caller
/// that wants exact matching can compare `to_string()` instead.
pub fn is_compatible_spec_version(
candidate : Semver,
expected : Semver,
) -> Bool {
if candidate.major != expected.major {
return false
}
if candidate.minor != expected.minor {
return candidate.minor < expected.minor
}
candidate.patch <= expected.patch
}