///|
/// Parsed If-Range field value (RFC 9110 §13.1.5): either an entity-tag or an
/// HTTP-date normalized to Unix seconds (UTC).
pub(all) enum IfRangeValue {
EntityTag(String)
HttpDate(Int64)
} derive(Eq, Debug)
///|
/// Parse an If-Range field value. Values that begin with a DQUOTE or with the
/// case-sensitive `W/` prefix are validated as entity-tags; everything else is
/// parsed as an IMF-fixdate and normalized to Unix seconds.
pub fn parse_if_range(input : String) -> Result[IfRangeValue, RangeError] {
let trimmed = trim_ows(input)
if trimmed.length() == 0 {
return Err(range_error(IfRange, EmptyInput, 0, "If-Range is empty"))
}
if utf8_length(trimmed) > Limits::default().max_input_bytes() {
return Err(
range_error(Limit, LimitExceeded, 0, "If-Range exceeds max_input_bytes"),
)
}
if trimmed[0] == '"'.to_int().to_uint16() || has_prefix(trimmed, "W/") {
match parse_entity_tag(trimmed) {
Ok(tag) => Ok(EntityTag(tag))
Err(error) => Err(error)
}
} else {
match parse_http_date(trimmed) {
Ok(seconds) => Ok(HttpDate(seconds))
Err(error) => Err(error)
}
}
}
///|
/// Strong comparison of two entity-tags (RFC 9110 §8.8.3.2). Weak tags never
/// match, even against an identical weak tag, because weak comparison is not
/// strong comparison. Tags are compared as opaque strings.
pub fn strong_etag_equal(left : String, right : String) -> Bool {
!has_prefix(left, "W/") && !has_prefix(right, "W/") && left == right
}
///|
/// Decide whether an If-Range validator matches the current representation.
/// Entity-tags use strong comparison; dates compare as exact Unix seconds.
pub fn if_range_matches(
value : IfRangeValue,
current_etag : String,
current_last_modified_unix : Int64,
) -> Bool {
match value {
EntityTag(tag) => strong_etag_equal(tag, current_etag)
HttpDate(seconds) => seconds == current_last_modified_unix
}
}
///|
/// Parse an IMF-fixdate (RFC 9110 §5.6.7) into Unix seconds (UTC).
/// The date must have exactly the canonical shape
/// `Sun, 06 Nov 1994 08:49:37 GMT`. The day-of-week is validated against the
/// calendar date, and the calendar date is validated by a days-from-civil
/// round trip, so `32 Jan` and `30 Feb` are rejected. Leap seconds (second
/// value 60) are accepted per RFC 9110.
pub fn parse_http_date(input : String) -> Result[Int64, RangeError] {
let trimmed = trim_ows(input)
if trimmed.length() != 29 {
return Err(
range_error(
HttpDate,
InvalidHttpDate,
0,
"IMF-fixdate must be exactly 29 characters: Sun, 06 Nov 1994 08:49:37 GMT",
),
)
}
if trimmed[3] != ','.to_int().to_uint16() ||
trimmed[4] != ' '.to_int().to_uint16() {
return Err(
range_error(
HttpDate,
InvalidHttpDate,
3,
"expected ',' SP after day-name",
),
)
}
let day_name = trimmed[:3].to_owned()
let day = match parse_two_digits(trimmed, 5, 31L, "day") {
Ok(v) => v
Err(e) => return Err(e)
}
if trimmed[7] != ' '.to_int().to_uint16() {
return Err(
range_error(HttpDate, InvalidHttpDate, 7, "expected SP after day"),
)
}
let month_text = trimmed[8:11].to_owned()
let month = match month_number(month_text) {
Ok(v) => v
Err(e) => return Err(e)
}
if trimmed[11] != ' '.to_int().to_uint16() {
return Err(
range_error(HttpDate, InvalidHttpDate, 11, "expected SP after month"),
)
}
let year = match parse_four_digits(trimmed, 12) {
Ok(v) => v
Err(e) => return Err(e)
}
if trimmed[16] != ' '.to_int().to_uint16() {
return Err(
range_error(HttpDate, InvalidHttpDate, 16, "expected SP after year"),
)
}
let hour = match parse_two_digits(trimmed, 17, 23L, "hour") {
Ok(v) => v
Err(e) => return Err(e)
}
if trimmed[19] != ':'.to_int().to_uint16() {
return Err(
range_error(HttpDate, InvalidHttpDate, 19, "expected ':' after hour"),
)
}
let minute = match parse_two_digits(trimmed, 20, 59L, "minute") {
Ok(v) => v
Err(e) => return Err(e)
}
if trimmed[22] != ':'.to_int().to_uint16() {
return Err(
range_error(HttpDate, InvalidHttpDate, 22, "expected ':' after minute"),
)
}
let second = match parse_two_digits(trimmed, 23, 60L, "second") {
Ok(v) => v
Err(e) => return Err(e)
}
if trimmed[25] != ' '.to_int().to_uint16() {
return Err(
range_error(HttpDate, InvalidHttpDate, 25, "expected SP before GMT"),
)
}
if !ascii_equal(trimmed[26:29].to_owned(), "GMT") {
return Err(
range_error(HttpDate, InvalidHttpDate, 26, "expected literal GMT"),
)
}
let days = days_from_civil(year, month, day)
let (check_year, check_month, check_day) = civil_from_days(days)
if check_year != year || check_month != month || check_day != day {
return Err(
range_error(
HttpDate,
InvalidHttpDate,
5,
"day is not a real calendar date for this month",
),
)
}
if !ascii_equal(day_name, weekday_name(days)) {
return Err(
range_error(
HttpDate,
InvalidHttpDate,
0,
"day-name does not match the calendar date",
),
)
}
Ok(days * 86_400L + hour * 3_600L + minute * 60L + second)
}
///|
/// Format Unix seconds (UTC) as an IMF-fixdate (RFC 9110 §5.6.7).
/// Negative values produce pre-1970 dates. Fails only when the year falls
/// outside the four-digit 0000-9999 range.
pub fn http_date_from_unix(seconds : Int64) -> Result[String, RangeError] {
let mut days = seconds / 86_400L
let mut tod = seconds % 86_400L
if tod < 0L {
days = days - 1L
tod = tod + 86_400L
}
let (year, month, day) = civil_from_days(days)
let hour = tod / 3_600L
let minute = tod % 3_600L / 60L
let second = tod % 60L
match format_year(year) {
Err(error) => Err(error)
Ok(year_text) =>
Ok(
"\{weekday_name(days)}, \{format_two(day)} \{month_name(month)} \{year_text} \{format_two(hour)}:\{format_two(minute)}:\{format_two(second)} GMT",
)
}
}
///|
fn parse_entity_tag(input : String) -> Result[String, RangeError] {
let rest = if has_prefix(input, "W/") { input[2:].to_owned() } else { input }
if rest.length() < 2 ||
rest[0] != '"'.to_int().to_uint16() ||
rest[rest.length() - 1] != '"'.to_int().to_uint16() {
return Err(
range_error(
IfRange,
InvalidEntityTag,
0,
"entity-tag must be a DQUOTE-delimited opaque tag",
),
)
}
for i = 1; i < rest.length() - 1; i = i + 1 {
let code = rest[i].to_int()
if code < 0x21 || code > 0x7E || code == 0x22 {
return Err(
range_error(
IfRange,
InvalidEntityTag,
i,
"entity-tag contains a non-etagc character",
),
)
}
}
Ok(input)
}
///|
fn parse_two_digits(
input : String,
offset : Int,
max : Int64,
label : String,
) -> Result[Int64, RangeError] {
let a = input[offset]
let b = input[offset + 1]
if !is_digit(a.to_int().unsafe_to_char()) ||
!is_digit(b.to_int().unsafe_to_char()) {
return Err(
range_error(HttpDate, InvalidHttpDate, offset, "invalid \{label}"),
)
}
let value = (a.to_int() - 48).to_int64() * 10L + (b.to_int() - 48).to_int64()
if value > max {
return Err(
range_error(HttpDate, InvalidHttpDate, offset, "\{label} out of range"),
)
}
Ok(value)
}
///|
fn parse_four_digits(input : String, offset : Int) -> Result[Int64, RangeError] {
let mut value = 0L
for i = offset; i < offset + 4; i = i + 1 {
if !is_digit(input[i].to_int().unsafe_to_char()) {
return Err(
range_error(HttpDate, InvalidHttpDate, i, "invalid year digit"),
)
}
value = value * 10L + (input[i].to_int() - 48).to_int64()
}
Ok(value)
}
///|
fn month_number(text : String) -> Result[Int64, RangeError] {
let names = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
"Dec",
]
for i = 0; i < names.length(); i = i + 1 {
if ascii_equal(text, names[i]) {
return Ok((i + 1).to_int64())
}
}
Err(range_error(HttpDate, InvalidHttpDate, 8, "unknown month name: \{text}"))
}
///|
fn month_name(month : Int64) -> String {
let names = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
"Dec",
]
names[month.to_int() - 1]
}
///|
fn weekday_name(days : Int64) -> String {
// 1970-01-01 was a Thursday; index 0 is Thursday.
let names = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"]
let index = (days % 7L + 7L) % 7L
names[index.to_int()]
}
///|
/// Days since 1970-01-01 for a proleptic Gregorian date (Hinnant's algorithm).
fn days_from_civil(year : Int64, month : Int64, day : Int64) -> Int64 {
let y = if month <= 2L { year - 1L } else { year }
let era = (if y >= 0L { y } else { y - 399L }) / 400L
let yoe = y - era * 400L
let mp = (month + 9L) % 12L
let doy = (153L * mp + 2L) / 5L + day - 1L
let doe = yoe * 365L + yoe / 4L - yoe / 100L + doy
era * 146_097L + doe - 719_468L
}
///|
/// Inverse of days_from_civil; validates calendar consistency on round trip.
fn civil_from_days(days : Int64) -> (Int64, Int64, Int64) {
let z = days + 719_468L
let era = (if z >= 0L { z } else { z - 146_096L }) / 146_097L
let doe = z - era * 146_097L
let yoe = (doe - doe / 1_460L + doe / 36_524L - doe / 146_096L) / 365L
let y = yoe + era * 400L
let doy = doe - (365L * yoe + yoe / 4L - yoe / 100L)
let mp = (5L * doy + 2L) / 153L
let d = doy - (153L * mp + 2L) / 5L + 1L
let m = if mp < 10L { mp + 3L } else { mp - 9L }
let year = if m <= 2L { y + 1L } else { y }
(year, m, d)
}
///|
fn format_two(value : Int64) -> String {
if value < 10L {
"0\{value}"
} else {
value.to_string()
}
}
///|
fn format_year(year : Int64) -> Result[String, RangeError] {
if year < 0L || year > 9_999L {
return Err(
range_error(
HttpDate,
InvalidHttpDate,
0,
"year outside the four-digit range 0000-9999",
),
)
}
let text = year.to_string()
if year < 1_000L {
Ok("0\{text}")
} else if year < 100L {
Ok("00\{text}")
} else if year < 10L {
Ok("000\{text}")
} else {
Ok(text)
}
}
///|
fn has_prefix(input : String, prefix : String) -> Bool {
if input.length() < prefix.length() {
false
} else {
input[:prefix.length()] == prefix
}
}