diff --git a/src/date.rs b/src/date.rs index 782b2e5..e785326 100644 --- a/src/date.rs +++ b/src/date.rs @@ -26,18 +26,39 @@ impl Date { /// Converts an XML plist date string to a `Date`. pub fn from_xml_format(date: &str) -> Result { - let offset: OffsetDateTime = OffsetDateTime::parse(date, &Rfc3339) - .map_err(|_| InvalidXmlDate)? - .to_offset(UtcOffset::UTC); - Ok(Date { - inner: offset.into(), - }) + // RFC 3339 covers years 0000-9999, including UTC offsets and sub-second + // precision. Dates outside that range use ISO-8601 expanded years, which + // `time` cannot parse, so they are handled separately. + if let Ok(offset) = OffsetDateTime::parse(date, &Rfc3339) { + return Ok(Date { + inner: offset.to_offset(UtcOffset::UTC).into(), + }); + } + from_expanded_xml_format(date) } /// Converts the `Date` to an XML plist date string. pub fn to_xml_format(&self) -> String { - let datetime: OffsetDateTime = self.inner.into(); - datetime.format(&Rfc3339).unwrap() + let seconds = unix_seconds(self.inner); + let (year, month, day) = civil_from_days(seconds.div_euclid(SECONDS_PER_DAY)); + if (0..=9999).contains(&year) { + // Within RFC 3339's range: keep the previous formatting exactly (this + // also preserves any sub-second precision). + let datetime: OffsetDateTime = self.inner.into(); + if let Ok(formatted) = datetime.format(&Rfc3339) { + return formatted; + } + } + // Apple's tools write ISO-8601 expanded years (e.g. `11476-…`, `-29719-…`) + // for dates outside 0000-9999, where `time` panics instead. Format these + // directly to match `plutil` byte for byte. + let secs_of_day = seconds.rem_euclid(SECONDS_PER_DAY); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + secs_of_day / 3600, + secs_of_day % 3600 / 60, + secs_of_day % 60, + ) } pub(crate) fn from_seconds_since_plist_epoch( @@ -83,6 +104,106 @@ impl Date { } } +const SECONDS_PER_DAY: i128 = 86_400; + +/// Whole seconds between `time` and the Unix epoch. Sub-second precision is +/// dropped, matching Apple, which truncates XML dates to whole seconds. +fn unix_seconds(time: SystemTime) -> i128 { + match time.duration_since(UNIX_EPOCH) { + Ok(duration) => i128::from(duration.as_secs()), + Err(err) => -i128::from(err.duration().as_secs()), + } +} + +/// Parses an ISO-8601 date with an expanded (and possibly negative) year, as +/// written by Apple's tools for dates outside 0000-9999. +fn from_expanded_xml_format(date: &str) -> Result { + let date = date.strip_suffix('Z').ok_or(InvalidXmlDate)?; + let (date, time) = date.split_once('T').ok_or(InvalidXmlDate)?; + + let (negative, digits) = match date.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, date.strip_prefix('+').unwrap_or(date)), + }; + let mut ymd = digits.splitn(3, '-'); + let year: i64 = next_field(&mut ymd)?; + let month: u8 = next_field(&mut ymd)?; + let day: u8 = next_field(&mut ymd)?; + let year = i128::from(if negative { -year } else { year }); + + // Whole seconds only; ignore any fractional part. + let time = time.split('.').next().unwrap_or(time); + let mut hms = time.split(':'); + let hour: u8 = next_field(&mut hms)?; + let minute: u8 = next_field(&mut hms)?; + let second: u8 = next_field(&mut hms)?; + if hms.next().is_some() || hour > 23 || minute > 59 || second > 60 { + return Err(InvalidXmlDate); + } + + let days = days_from_civil(year, month, day); + // Reject impossible calendar dates (e.g. the 30th of February). + if civil_from_days(days) != (year, month, day) { + return Err(InvalidXmlDate); + } + let seconds = days * SECONDS_PER_DAY + + i128::from(hour) * 3600 + + i128::from(minute) * 60 + + i128::from(second); + date_from_unix_seconds(seconds) +} + +fn next_field<'a, T, I>(fields: &mut I) -> Result +where + T: std::str::FromStr, + I: Iterator, +{ + fields + .next() + .ok_or(InvalidXmlDate)? + .parse() + .map_err(|_| InvalidXmlDate) +} + +fn date_from_unix_seconds(seconds: i128) -> Result { + let inner = if seconds >= 0 { + u64::try_from(seconds) + .ok() + .and_then(|s| UNIX_EPOCH.checked_add(Duration::from_secs(s))) + } else { + u64::try_from(-seconds) + .ok() + .and_then(|s| UNIX_EPOCH.checked_sub(Duration::from_secs(s))) + }; + inner.map(|inner| Date { inner }).ok_or(InvalidXmlDate) +} + +/// Civil date (proleptic Gregorian, astronomical year numbering) from a count of +/// days since 1970-01-01. Howard Hinnant's `civil_from_days`. +fn civil_from_days(z: i128) -> (i128, u8, u8) { + 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 doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u8; + let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8; + let year = yoe + era * 400 + i128::from(month <= 2); + (year, month, day) +} + +/// Inverse of [`civil_from_days`]. Howard Hinnant's `days_from_civil`. +fn days_from_civil(year: i128, month: u8, day: u8) -> i128 { + let month = i128::from(month); + let year = year - i128::from(month <= 2); + let era = if year >= 0 { year } else { year - 399 } / 400; + let yoe = year - era * 400; + let doy = (153 * if month > 2 { month - 3 } else { month + 9 } + 2) / 5 + i128::from(day) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + impl fmt::Debug for Date { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { write!(f, "{}", self.to_xml_format()) @@ -202,4 +323,88 @@ mod testing { let date_str = "1920-01-01T00:00:00Z"; Date::from_xml_format(date_str).expect("should parse"); } + + fn from_unix(seconds: i64) -> Date { + let inner = if seconds >= 0 { + UNIX_EPOCH + Duration::from_secs(seconds as u64) + } else { + UNIX_EPOCH - Duration::from_secs((-seconds) as u64) + }; + Date { inner } + } + + // (seconds since the Unix epoch, XML the value should read/write). The XML is + // what Apple's `plutil` produces for the same binary date. + const EXPANDED: &[(i64, &str)] = &[ + (253_402_300_800, "10000-01-01T00:00:00Z"), + (300_000_000_000, "11476-08-15T05:20:00Z"), + (1_000_000_000_000, "33658-09-27T01:46:40Z"), + (9_000_000_000_000, "287168-08-24T16:00:00Z"), + (-62_170_156_800, "-001-11-28T00:00:00Z"), + (-65_000_000_000, "-090-03-27T04:26:40Z"), + (-1_000_000_000_000, "-29719-04-05T22:13:20Z"), + (-9_000_000_000_000, "-283229-05-10T08:00:00Z"), + ]; + + #[test] + fn writes_expanded_years_like_plutil() { + for &(seconds, expected) in EXPANDED { + assert_eq!( + from_unix(seconds).to_xml_format(), + expected, + "seconds={seconds}" + ); + } + } + + #[test] + fn reads_expanded_years() { + for &(seconds, xml) in EXPANDED { + let parsed = Date::from_xml_format(xml).expect("should parse expanded year"); + assert_eq!(parsed, from_unix(seconds)); + assert_eq!(parsed.to_xml_format(), xml); + } + } + + #[test] + fn year_boundary_does_not_panic() { + // The last second RFC 3339 can represent, and the first it cannot. + assert_eq!( + from_unix(253_402_300_799).to_xml_format(), + "9999-12-31T23:59:59Z" + ); + assert_eq!( + from_unix(253_402_300_800).to_xml_format(), + "10000-01-01T00:00:00Z" + ); + // Year 0001 stays on the RFC 3339 path. + assert_eq!( + from_unix(-62_135_596_800).to_xml_format(), + "0001-01-01T00:00:00Z" + ); + } + + #[test] + fn debug_of_far_date_does_not_panic() { + assert_eq!( + format!("{:?}", from_unix(300_000_000_000)), + "11476-08-15T05:20:00Z" + ); + } + + #[test] + fn rejects_invalid_expanded_dates() { + for s in [ + "11476-02-30T00:00:00Z", // 30th of February + "11476-13-01T00:00:00Z", // month 13 + "11476-08-15T25:00:00Z", // hour 25 + "11476-08-15T05:20:00", // no trailing Z + "11476-08-15", // no time + "abcd-08-15T05:20:00Z", // non-numeric year + "99999999999999999999999-01-01T00:00:00Z", // year too large to parse + "999999999999-01-01T00:00:00Z", // year outside the representable range + ] { + assert!(Date::from_xml_format(s).is_err(), "should reject {s}"); + } + } }