Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preserve -00:00 offset #1042

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/datetime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ fn test_datetime_rfc2822() {
);
assert_eq!(
DateTime::parse_from_rfc2822("Wed, 18 Feb 2015 23:16:09 -0000"),
Ok(FixedOffset::east_opt(0).unwrap().with_ymd_and_hms(2015, 2, 18, 23, 16, 9).unwrap())
Ok(FixedOffset::OFFSET_UNKNOWN.with_ymd_and_hms(2015, 2, 18, 23, 16, 9).unwrap())
pitdicker marked this conversation as resolved.
Show resolved Hide resolved
);
assert_eq!(
ymdhms_micro(&edt, 2015, 2, 18, 23, 59, 59, 1_234_567).to_rfc2822(),
Expand Down
6 changes: 3 additions & 3 deletions src/format/formatting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,13 +425,13 @@ fn format_inner(
#[cfg(any(feature = "alloc", feature = "serde", feature = "rustc-serialize"))]
impl OffsetFormat {
/// Writes an offset from UTC with the format defined by `self`.
fn format(&self, w: &mut impl Write, off: FixedOffset) -> fmt::Result {
let off = off.local_minus_utc();
fn format(&self, w: &mut impl Write, offset: FixedOffset) -> fmt::Result {
let off = offset.local_minus_utc();
if self.allow_zulu && off == 0 {
w.write_char('Z')?;
return Ok(());
}
let (sign, off) = if off < 0 { ('-', -off) } else { ('+', off) };
let (sign, off) = if off < 0 || offset.no_offset_info() { ('-', -off) } else { ('+', off) };

let hours;
let mut mins = 0;
Expand Down
2 changes: 1 addition & 1 deletion src/format/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ pub use formatting::{format, format_item, DelayedFormat};
pub use locales::Locale;
pub(crate) use parse::parse_rfc3339;
pub use parse::{parse, parse_and_remainder};
pub use parsed::Parsed;
pub use parsed::{Parsed, NO_OFFSET_INFO};
pub use strftime::StrftimeItems;

/// An uninhabited type used for `InternalNumeric` and `InternalFixed` below.
Expand Down
58 changes: 40 additions & 18 deletions src/format/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use super::scan;
use super::{Fixed, InternalFixed, InternalInternal, Item, Numeric, Pad, Parsed};
use super::{ParseError, ParseResult};
use super::{BAD_FORMAT, INVALID, OUT_OF_RANGE, TOO_LONG, TOO_SHORT};
use crate::format::parsed::NO_OFFSET_INFO;
use crate::{DateTime, FixedOffset, Weekday};

fn set_weekday_with_num_days_from_sunday(p: &mut Parsed, v: i64) -> ParseResult<()> {
Expand Down Expand Up @@ -142,7 +143,8 @@ fn parse_rfc2822<'a>(parsed: &mut Parsed, mut s: &'a str) -> ParseResult<(&'a st
}

s = scan::space(s)?; // mandatory
parsed.set_offset(i64::from(try_consume!(scan::timezone_offset_2822(s))))?;
let offset = try_consume!(scan::timezone_offset_2822(s));
parsed.set_offset(i64::from(offset.unwrap_or(NO_OFFSET_INFO)))?;

// optional comments
while let Ok((s_out, ())) = scan::comment_2822(s) {
Expand Down Expand Up @@ -213,16 +215,17 @@ pub(crate) fn parse_rfc3339<'a>(parsed: &mut Parsed, mut s: &'a str) -> ParseRes
}

let offset = try_consume!(scan::timezone_offset(s, |s| scan::char(s, b':'), true, false, true));
// This range check is similar to the one in `FixedOffset::east_opt`, so it would be redundant.
// But it is possible to read the offset directly from `Parsed`. We want to only successfully
// populate `Parsed` if the input is fully valid RFC 3339.
// Max for the hours field is `23`, and for the minutes field `59`.
const MAX_RFC3339_OFFSET: i32 = (23 * 60 + 59) * 60;
if !(-MAX_RFC3339_OFFSET..=MAX_RFC3339_OFFSET).contains(&offset) {
return Err(OUT_OF_RANGE);
if let Some(offset) = offset {
// This range check is similar to the one in `FixedOffset::east_opt`, so it would be
// redundant. But it is possible to read the offset directly from `Parsed`. We want to only
// successfully populate `Parsed` if the input is fully valid RFC 3339.
// Max for the hours field is `23`, and for the minutes field `59`.
const MAX_RFC3339_OFFSET: i32 = (23 * 60 + 59) * 60;
if !(-MAX_RFC3339_OFFSET..=MAX_RFC3339_OFFSET).contains(&offset) {
return Err(OUT_OF_RANGE);
}
}
parsed.set_offset(i64::from(offset))?;

parsed.set_offset(i64::from(offset.unwrap_or(NO_OFFSET_INFO)))?;
Ok((s, ()))
}

Expand Down Expand Up @@ -463,7 +466,7 @@ where
false,
true,
));
parsed.set_offset(i64::from(offset))?;
parsed.set_offset(i64::from(offset.unwrap_or(NO_OFFSET_INFO)))?;
}

&TimezoneOffsetColonZ | &TimezoneOffsetZ => {
Expand All @@ -474,7 +477,7 @@ where
false,
true,
));
parsed.set_offset(i64::from(offset))?;
parsed.set_offset(i64::from(offset.unwrap_or(NO_OFFSET_INFO)))?;
}
&Internal(InternalFixed {
val: InternalInternal::TimezoneOffsetPermissive,
Expand All @@ -486,7 +489,7 @@ where
true,
true,
));
parsed.set_offset(i64::from(offset))?;
parsed.set_offset(i64::from(offset.unwrap_or(NO_OFFSET_INFO)))?;
}

&RFC2822 => try_consume!(parse_rfc2822(parsed, s)),
Expand Down Expand Up @@ -575,11 +578,11 @@ fn parse_rfc3339_relaxed<'a>(parsed: &mut Parsed, mut s: &'a str) -> ParseResult
s = parse_internal(parsed, s, TIME_ITEMS.iter())?;
s = s.trim_start();
let (s, offset) = if s.len() >= 3 && "UTC".as_bytes().eq_ignore_ascii_case(&s.as_bytes()[..3]) {
(&s[3..], 0)
(&s[3..], Some(0))
} else {
scan::timezone_offset(s, scan::colon_or_space, true, false, true)?
};
parsed.set_offset(i64::from(offset))?;
parsed.set_offset(i64::from(offset.unwrap_or(NO_OFFSET_INFO)))?;
Ok((s, ()))
}

Expand Down Expand Up @@ -1041,8 +1044,8 @@ mod tests {
check("+1234:56", &[fixed(TimezoneOffset)], Err(TOO_LONG));
check("+1234:567", &[fixed(TimezoneOffset)], Err(TOO_LONG));
check("+00:00", &[fixed(TimezoneOffset)], parsed!(offset: 0));
check("-00:00", &[fixed(TimezoneOffset)], parsed!(offset: 0));
check("−00:00", &[fixed(TimezoneOffset)], parsed!(offset: 0)); // MINUS SIGN (U+2212)
check("-00:00", &[fixed(TimezoneOffset)], parsed!(offset: NO_OFFSET_INFO));
check("−00:00", &[fixed(TimezoneOffset)], parsed!(offset: NO_OFFSET_INFO)); // MINUS SIGN (U+2212)
check("+00:01", &[fixed(TimezoneOffset)], parsed!(offset: 60));
check("-00:01", &[fixed(TimezoneOffset)], parsed!(offset: -60));
check("+00:30", &[fixed(TimezoneOffset)], parsed!(offset: 1_800));
Expand Down Expand Up @@ -1615,6 +1618,7 @@ mod tests {
("20 Jan 2015 17:35:20 -0800", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, -8))), // no day of week
("20 JAN 2015 17:35:20 -0800", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, -8))), // upper case month
("Tue, 20 Jan 2015 17:35 -0800", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 0, 0, -8))), // no second
("Tue, 20 Jan 2015 17:35 -0000", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 0, 0, 0))), // -0000 offset
("11 Sep 2001 09:45:00 +0000", Ok(ymd_hmsn(2001, 9, 11, 9, 45, 0, 0, 0))),
("11 Sep 2001 09:45:00 EST", Ok(ymd_hmsn(2001, 9, 11, 9, 45, 0, 0, -5))),
("11 Sep 2001 09:45:00 GMT", Ok(ymd_hmsn(2001, 9, 11, 9, 45, 0, 0, 0))),
Expand All @@ -1641,8 +1645,9 @@ mod tests {
("Tue, 20 Jan 2015 17:35:20 PDT", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, -7))),
("Tue, 20 Jan 2015 17:35:20 PST", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, -8))),
("Tue, 20 Jan 2015 17:35:20 pst", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, -8))),
// named single-letter military timezones must fallback to +0000
// Z is the only single-letter military timezones that maps to +0000
("Tue, 20 Jan 2015 17:35:20 Z", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, 0))),
// named single-letter military timezones must fallback to -0000
("Tue, 20 Jan 2015 17:35:20 A", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, 0))),
("Tue, 20 Jan 2015 17:35:20 a", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, 0))),
("Tue, 20 Jan 2015 17:35:20 K", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, 0))),
Expand Down Expand Up @@ -1680,6 +1685,22 @@ mod tests {
}
}

#[test]
fn test_rfc2822_no_offset_info() {
fn rfc2822_to_offset(date: &str) -> FixedOffset {
let mut parsed = Parsed::new();
parse(&mut parsed, date, [Item::Fixed(Fixed::RFC2822)].iter()).unwrap();
parsed.to_fixed_offset().unwrap()
}
assert_eq!(
rfc2822_to_offset("Tue, 20 Jan 2015 17:35:20 -0000"),
FixedOffset::OFFSET_UNKNOWN
);
assert_eq!(rfc2822_to_offset("Tue, 20 Jan 2015 17:35:20 A"), FixedOffset::OFFSET_UNKNOWN);
assert_eq!(rfc2822_to_offset("Tue, 20 Jan 2015 17:35:20 a"), FixedOffset::OFFSET_UNKNOWN);
assert_eq!(rfc2822_to_offset("Tue, 20 Jan 2015 17:35:20 K"), FixedOffset::OFFSET_UNKNOWN);
}

#[test]
fn parse_rfc850() {
static RFC850_FMT: &str = "%A, %d-%b-%y %T GMT";
Expand Down Expand Up @@ -1771,6 +1792,7 @@ mod tests {
("2015-01-20T17:35:20.000031-08:00", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 31_000, -8))),
("2015-01-20T17:35:20.000000004-08:00", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 4, -8))),
("2015-01-20T17:35:20.000000004−08:00", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 4, -8))), // with MINUS SIGN (U+2212)
("2015-01-20T17:35:20-00:00", Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, 0))), // -0000 offset
(
"2015-01-20T17:35:20.000000000452-08:00",
Ok(ymd_hmsn(2015, 1, 20, 17, 35, 20, 0, -8)),
Expand Down
22 changes: 18 additions & 4 deletions src/format/parsed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,20 @@
pub timestamp: Option<i64>,

/// Offset from the local time to UTC, in seconds.
///
/// Use the constant [`NO_OFFSET_INFO`] to indicate there is an offset of `00:00`, but that its
/// relation to local time is unknown (`-00:00` in RFC 3339 and 2822).
pub offset: Option<i32>,

/// A dummy field to make this type not fully destructible (required for API stability).
// TODO: Change this to `#[non_exhaustive]` (on the enum) with the next breaking release.
_dummy: (),
}

/// Constant to be used with [`Parsed::set_offset`] to indicate there is an offset of `00:00`, but
/// that its relation to local time is unknown (`-00:00` in RFC 3339 and 2822).
pub const NO_OFFSET_INFO: i32 = i32::MIN;

/// Checks if `old` is either empty or has the same value as `new` (i.e. "consistent"),
/// and if it is empty, set `old` to `new` as well.
#[inline]
Expand Down Expand Up @@ -930,7 +937,10 @@
/// - `OUT_OF_RANGE` if the offset is out of range for a `FixedOffset`.
/// - `NOT_ENOUGH` if the offset field is not set.
pub fn to_fixed_offset(&self) -> ParseResult<FixedOffset> {
FixedOffset::east_opt(self.offset.ok_or(NOT_ENOUGH)?).ok_or(OUT_OF_RANGE)
match self.offset.ok_or(NOT_ENOUGH)? {
NO_OFFSET_INFO => Ok(FixedOffset::OFFSET_UNKNOWN),
offset => FixedOffset::east_opt(offset).ok_or(OUT_OF_RANGE),

Check warning on line 942 in src/format/parsed.rs

View check run for this annotation

Codecov / codecov/patch

src/format/parsed.rs#L942

Added line #L942 was not covered by tests
}
}

/// Returns a parsed timezone-aware date and time out of given fields.
Expand Down Expand Up @@ -958,10 +968,14 @@
(None, Some(_)) => 0, // UNIX timestamp may assume 0 offset
(None, None) => return Err(NOT_ENOUGH),
};
let datetime = self.to_naive_datetime_with_offset(offset)?;
let offset = FixedOffset::east_opt(offset).ok_or(OUT_OF_RANGE)?;
let (offset_i32, fixed_offset) = if offset == NO_OFFSET_INFO {
(0, FixedOffset::OFFSET_UNKNOWN)
} else {
(offset, FixedOffset::east_opt(offset).ok_or(OUT_OF_RANGE)?)
};
let datetime = self.to_naive_datetime_with_offset(offset_i32)?;

match offset.from_local_datetime(&datetime) {
match fixed_offset.from_local_datetime(&datetime) {
LocalResult::None => Err(IMPOSSIBLE),
LocalResult::Single(t) => Ok(t),
LocalResult::Ambiguous(..) => Err(NOT_ENOUGH),
Expand Down
28 changes: 17 additions & 11 deletions src/format/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,20 +198,22 @@ pub(crate) fn colon_or_space(s: &str) -> ParseResult<&str> {
/// ASCII-compatible `-` HYPHEN-MINUS (U+2D).
/// This is part of [RFC 3339 & ISO 8601].
///
/// May return `None` which indicates no offset data is available (i.e. `-0000`).
///
/// [RFC 3339 & ISO 8601]: https://en.wikipedia.org/w/index.php?title=ISO_8601&oldid=1114309368#Time_offsets_from_UTC
pub(crate) fn timezone_offset<F>(
mut s: &str,
mut consume_colon: F,
allow_zulu: bool,
allow_missing_minutes: bool,
allow_tz_minus_sign: bool,
) -> ParseResult<(&str, i32)>
) -> ParseResult<(&str, Option<i32>)>
where
F: FnMut(&str) -> ParseResult<&str>,
{
if allow_zulu {
if let Some(&b'Z' | &b'z') = s.as_bytes().first() {
return Ok((&s[1..], 0));
return Ok((&s[1..], Some(0)));
}
}

Expand Down Expand Up @@ -279,21 +281,25 @@ where
};

let seconds = hours * 3600 + minutes * 60;
Ok((s, if negative { -seconds } else { seconds }))

if seconds == 0 && negative {
return Ok((s, None));
}
Ok((s, Some(if negative { -seconds } else { seconds })))
}

/// Same as `timezone_offset` but also allows for RFC 2822 legacy timezones.
/// May return `None` which indicates an insufficient offset data (i.e. `-0000`).
/// May return `None` which indicates no offset data is available (i.e. `-0000`).
/// See [RFC 2822 Section 4.3].
///
/// [RFC 2822 Section 4.3]: https://tools.ietf.org/html/rfc2822#section-4.3
pub(super) fn timezone_offset_2822(s: &str) -> ParseResult<(&str, i32)> {
pub(super) fn timezone_offset_2822(s: &str) -> ParseResult<(&str, Option<i32>)> {
// tries to parse legacy time zone names
let upto = s.as_bytes().iter().position(|&c| !c.is_ascii_alphabetic()).unwrap_or(s.len());
if upto > 0 {
let name = &s.as_bytes()[..upto];
let s = &s[upto..];
let offset_hours = |o| Ok((s, o * 3600));
let offset_hours = |o| Ok((s, Some(o * 3600)));
// RFC 2822 requires support for some named North America timezones, a small subset of all
// named timezones.
if name.eq_ignore_ascii_case(b"gmt")
Expand All @@ -314,7 +320,7 @@ pub(super) fn timezone_offset_2822(s: &str) -> ParseResult<(&str, i32)> {
} else if name.len() == 1 {
if let b'a'..=b'i' | b'k'..=b'y' | b'A'..=b'I' | b'K'..=b'Y' = name[0] {
// recommended by RFC 2822: consume but treat it as -0000
return Ok((s, 0));
return Ok((s, None));
}
}
Err(INVALID)
Expand Down Expand Up @@ -398,10 +404,10 @@ mod tests {

#[test]
fn test_timezone_offset_2822() {
assert_eq!(timezone_offset_2822("cSt").unwrap(), ("", -21600));
assert_eq!(timezone_offset_2822("pSt").unwrap(), ("", -28800));
assert_eq!(timezone_offset_2822("mSt").unwrap(), ("", -25200));
assert_eq!(timezone_offset_2822("-1551").unwrap(), ("", -57060));
assert_eq!(timezone_offset_2822("cSt").unwrap(), ("", Some(-21600)));
assert_eq!(timezone_offset_2822("pSt").unwrap(), ("", Some(-28800)));
assert_eq!(timezone_offset_2822("mSt").unwrap(), ("", Some(-25200)));
assert_eq!(timezone_offset_2822("-1551").unwrap(), ("", Some(-57060)));
assert_eq!(timezone_offset_2822("Gp"), Err(INVALID));
}

Expand Down