Skip to content

Commit 8251264

Browse files
authored
Fix two bugs in RFC-3339 date formatting (#489)
- Fix year boundaries to strictly adhere to RFC-3339 - Fix nanosecond precision issue
1 parent a47ada5 commit 8251264

File tree

2 files changed

+65
-63
lines changed

2 files changed

+65
-63
lines changed

rust-runtime/smithy-types/src/instant/format.rs

Lines changed: 39 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ pub mod http_date {
241241
mod test_http_date {
242242
use proptest::prelude::*;
243243

244-
use crate::instant::format::{http_date, iso_8601, DateParseError};
244+
use crate::instant::format::{http_date, rfc3339, DateParseError};
245245
use crate::Instant;
246246

247247
#[test]
@@ -353,21 +353,21 @@ mod test_http_date {
353353
fn valid_iso_date() {
354354
let date = "1985-04-12T23:20:50.52Z";
355355
let expected = Instant::from_secs_and_nanos(482196050, 520000000);
356-
assert_eq!(iso_8601::parse(date), Ok(expected));
356+
assert_eq!(rfc3339::parse(date), Ok(expected));
357357
}
358358

359359
#[test]
360360
fn iso_date_no_fractional() {
361361
let date = "1985-04-12T23:20:50Z";
362362
let expected = Instant::from_secs_and_nanos(482196050, 0);
363-
assert_eq!(iso_8601::parse(date), Ok(expected));
363+
assert_eq!(rfc3339::parse(date), Ok(expected));
364364
}
365365

366366
#[test]
367367
fn read_iso_date_comma_split() {
368368
let date = "1985-04-12T23:20:50Z,1985-04-12T23:20:51Z";
369-
let (e1, date) = iso_8601::read(date).expect("should succeed");
370-
let (e2, date2) = iso_8601::read(&date[1..]).expect("should succeed");
369+
let (e1, date) = rfc3339::read(date).expect("should succeed");
370+
let (e2, date2) = rfc3339::read(&date[1..]).expect("should succeed");
371371
assert_eq!(date2, "");
372372
assert_eq!(date, ",1985-04-12T23:20:51Z");
373373
let expected = Instant::from_secs_and_nanos(482196050, 0);
@@ -386,7 +386,7 @@ mod test_http_date {
386386
}
387387
}
388388

389-
pub mod iso_8601 {
389+
pub mod rfc3339 {
390390
use chrono::format;
391391

392392
use crate::instant::format::DateParseError;
@@ -403,7 +403,7 @@ pub mod iso_8601 {
403403
let format = format::StrftimeItems::new("%Y-%m-%dT%H:%M:%S%.fZ");
404404
// TODO: it may be helpful for debugging to keep these errors around
405405
chrono::format::parse(&mut date, s, format)
406-
.map_err(|_| DateParseError::Invalid("invalid iso8601 date"))?;
406+
.map_err(|_| DateParseError::Invalid("invalid rfc3339 date"))?;
407407
let utc_date = date
408408
.to_naive_datetime_with_offset(0)
409409
.map_err(|_| DateParseError::Invalid("invalid date"))?;
@@ -413,14 +413,14 @@ pub mod iso_8601 {
413413
))
414414
}
415415

416-
/// Read 1 ISO8601 date from &str and return the remaining str
416+
/// Read 1 RFC-3339 date from &str and return the remaining str
417417
pub fn read(s: &str) -> Result<(Instant, &str), DateParseError> {
418418
let delim = s.find('Z').map(|idx| idx + 1).unwrap_or_else(|| s.len());
419419
let (head, rest) = s.split_at(delim);
420420
Ok((parse(head)?, &rest))
421421
}
422422

423-
/// Format an [Instant] in the ISO-8601 date format
423+
/// Format an [Instant] in the RFC-3339 date format
424424
pub fn format(instant: &Instant) -> String {
425425
use std::fmt::Write;
426426
let (year, month, day, hour, minute, second, nanos) = {
@@ -436,79 +436,48 @@ pub mod iso_8601 {
436436
)
437437
};
438438

439+
// This is stated in the assumptions for RFC-3339. ISO-8601 allows for years
440+
// between -99,999 and 99,999 inclusive, but RFC-3339 is bound between 0 and 9,999.
439441
assert!(
440-
year.abs() <= 99_999,
441-
"years greater than 5 digits are not supported by ISO-8601"
442+
(0..=9_999).contains(&year),
443+
"years must be between 0 and 9,999 in RFC-3339"
442444
);
443445

444446
let mut out = String::with_capacity(33);
445-
if (0..=9999).contains(&year) {
446-
write!(out, "{:04}", year).unwrap();
447-
} else if year < 0 {
448-
write!(out, "{:05}", year).unwrap();
449-
} else {
450-
write!(out, "+{:05}", year).unwrap();
451-
}
452447
write!(
453448
out,
454-
"-{:02}-{:02}T{:02}:{:02}:{:02}",
455-
month, day, hour, minute, second
449+
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
450+
year, month, day, hour, minute, second
456451
)
457452
.unwrap();
458453
format_nanos(&mut out, nanos);
459454
out.push('Z');
460455
out
461456
}
462457

458+
/// Formats sub-second nanos for RFC-3339 (including the '.').
459+
/// Expects to be called with a number of `nanos` between 0 and 999_999_999 inclusive.
463460
fn format_nanos(into: &mut String, nanos: u32) {
461+
debug_assert!(nanos < 1_000_000_000);
464462
if nanos > 0 {
465463
into.push('.');
466-
let mut place = 100_000_000;
467-
let mut pushed_non_zero = false;
468-
while place > 0 {
469-
let digit = (nanos / place) % 10;
470-
if pushed_non_zero && digit == 0 {
471-
return;
472-
}
473-
pushed_non_zero = digit > 0;
464+
let (mut remaining, mut place) = (nanos, 100_000_000);
465+
while remaining > 0 {
466+
let digit = (remaining / place) % 10;
474467
into.push(char::from(b'0' + (digit as u8)));
468+
remaining -= digit * place;
475469
place /= 10;
476470
}
477471
}
478472
}
479473
}
480474

481475
#[cfg(test)]
482-
mod test_iso_8601 {
483-
use super::iso_8601::format;
476+
mod test {
477+
use super::rfc3339::format;
484478
use crate::Instant;
485-
use chrono::SecondsFormat;
486479
use proptest::proptest;
487480

488-
#[test]
489-
fn year_edge_cases() {
490-
assert_eq!(
491-
"-0001-12-31T18:22:50Z",
492-
format(&Instant::from_epoch_seconds(-62167239430))
493-
);
494-
assert_eq!(
495-
"0001-05-06T02:15:00Z",
496-
format(&Instant::from_epoch_seconds(-62124788700))
497-
);
498-
assert_eq!(
499-
"+33658-09-27T01:46:40Z",
500-
format(&Instant::from_epoch_seconds(1_000_000_000_000))
501-
);
502-
assert_eq!(
503-
"-29719-04-05T22:13:20Z",
504-
format(&Instant::from_epoch_seconds(-1_000_000_000_000))
505-
);
506-
assert_eq!(
507-
"-1199-02-15T14:13:20Z",
508-
format(&Instant::from_epoch_seconds(-100_000_000_000))
509-
);
510-
}
511-
512481
#[test]
513482
fn no_nanos() {
514483
assert_eq!(
@@ -555,16 +524,26 @@ mod test_iso_8601 {
555524
"1970-01-01T00:00:00.000000001Z",
556525
format(&Instant::from_secs_and_nanos(0, 000_000_001))
557526
);
527+
assert_eq!(
528+
"1970-01-01T00:00:00.101Z",
529+
format(&Instant::from_secs_and_nanos(0, 101_000_000))
530+
);
558531
}
559532

560533
proptest! {
561-
// Sanity test against chrono (excluding nanos, which format differently)
534+
// Sanity test against chrono
562535
#[test]
563-
fn proptest_iso_8601(seconds in -1_000_000_000_000..1_000_000_000_000i64) {
564-
let instant = Instant::from_epoch_seconds(seconds);
565-
let chrono_formatted = instant.to_chrono_internal().to_rfc3339_opts(SecondsFormat::AutoSi, true);
536+
#[cfg(feature = "chrono-conversions")]
537+
fn proptest_rfc3339(
538+
seconds in 0..253_402_300_799i64, // 0 to 9999-12-31T23:59:59
539+
nanos in 0..1_000_000_000u32
540+
) {
541+
use chrono::DateTime;
542+
543+
let instant = Instant::from_secs_and_nanos(seconds, nanos);
566544
let formatted = format(&instant);
567-
assert_eq!(chrono_formatted, formatted);
545+
let parsed: Instant = DateTime::parse_from_rfc3339(&formatted).unwrap().into();
546+
assert_eq!(instant, parsed);
568547
}
569548
}
570549
}

rust-runtime/smithy-types/src/instant/mod.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ impl Instant {
6161

6262
pub fn from_str(s: &str, format: Format) -> Result<Self, DateParseError> {
6363
match format {
64-
Format::DateTime => format::iso_8601::parse(s),
64+
Format::DateTime => format::rfc3339::parse(s),
6565
Format::HttpDate => format::http_date::parse(s),
6666
Format::EpochSeconds => <f64>::from_str(s)
6767
// TODO: Parse base & fraction separately to achieve higher precision
@@ -75,7 +75,7 @@ impl Instant {
7575
/// Enable parsing multiple dates from the same string
7676
pub fn read(s: &str, format: Format, delim: char) -> Result<(Self, &str), DateParseError> {
7777
let (inst, next) = match format {
78-
Format::DateTime => format::iso_8601::read(s)?,
78+
Format::DateTime => format::rfc3339::read(s)?,
7979
Format::HttpDate => format::http_date::read(s)?,
8080
Format::EpochSeconds => {
8181
let split_point = s.find(delim).unwrap_or_else(|| s.len());
@@ -134,7 +134,7 @@ impl Instant {
134134

135135
pub fn fmt(&self, format: Format) -> String {
136136
match format {
137-
Format::DateTime => format::iso_8601::format(&self),
137+
Format::DateTime => format::rfc3339::format(&self),
138138
Format::EpochSeconds => {
139139
if self.subsecond_nanos == 0 {
140140
format!("{}", self.seconds)
@@ -148,6 +148,20 @@ impl Instant {
148148
}
149149
}
150150

151+
#[cfg(feature = "chrono-conversions")]
152+
impl From<DateTime<Utc>> for Instant {
153+
fn from(value: DateTime<Utc>) -> Instant {
154+
Instant::from_secs_and_nanos(value.timestamp(), value.timestamp_subsec_nanos())
155+
}
156+
}
157+
158+
#[cfg(feature = "chrono-conversions")]
159+
impl From<DateTime<chrono::FixedOffset>> for Instant {
160+
fn from(value: DateTime<chrono::FixedOffset>) -> Instant {
161+
value.with_timezone(&Utc).into()
162+
}
163+
}
164+
151165
#[derive(Clone, Copy, Eq, PartialEq)]
152166
pub enum Format {
153167
DateTime,
@@ -217,4 +231,13 @@ mod test {
217231
let (_, next) = Instant::read(s, Format::HttpDate, ',').expect("valid");
218232
assert_eq!(next, "Tue, 17 Dec 2019 23:48:18 GMT");
219233
}
234+
235+
#[test]
236+
#[cfg(feature = "chrono-conversions")]
237+
fn chrono_conversions_round_trip() {
238+
let instant = Instant::from_secs_and_nanos(1234, 56789);
239+
let chrono = instant.to_chrono();
240+
let instant_again: Instant = chrono.into();
241+
assert_eq!(instant, instant_again);
242+
}
220243
}

0 commit comments

Comments
 (0)