Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,15 @@ jobs:
- name: Install `cargo-fuzz`
run: cargo install cargo-fuzz --locked
- uses: Swatinem/rust-cache@v2
- name: Run from_str for XX seconds
- name: Run fuzz_parse_datetime for XX seconds
shell: bash
run: |
## Run it
cd fuzz
cargo fuzz run fuzz_parse_datetime -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0
- name: Run fuzz_large_year for XX seconds
shell: bash
run: |
## Run it
cd fuzz
cargo fuzz run fuzz_large_year -- -max_total_time=${{ env.RUN_FOR }} -detect_leaks=0
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ match dt.unwrap() {
}
```

For years beyond jiff's representable range (e.g., year 10000+), the result is an `ExtendedDateTime`:

```rs
use parse_datetime::{parse_datetime, ParsedDateTime};

let dt = parse_datetime("12000-01-01").unwrap();
Comment thread
sylvestre marked this conversation as resolved.
match dt {
ParsedDateTime::Extended(ext) => {
assert_eq!(ext.year, 12000);
assert_eq!(ext.month, 1);
assert_eq!(ext.day, 1);
}
ParsedDateTime::InRange(_) => unreachable!("year 12000 is out of jiff range"),
}
```

### Supported Formats

The `parse_datetime` and `parse_datetime_at_date` functions support absolute datetime and the following relative times:
Expand Down
16 changes: 16 additions & 0 deletions fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4.7"
arbitrary = { version = "1", features = ["derive"] }
jiff = "0.2"

[dependencies.parse_datetime]
path = "../"
Expand All @@ -17,3 +19,9 @@ name = "fuzz_parse_datetime"
path = "fuzz_targets/parse_datetime.rs"
test = false
doc = false

[[bin]]
name = "fuzz_large_year"
path = "fuzz_targets/large_year.rs"
test = false
doc = false
64 changes: 64 additions & 0 deletions fuzz/fuzz_targets/large_year.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#![no_main]

use arbitrary::Arbitrary;
use jiff::{civil::DateTime, tz::TimeZone};
use libfuzzer_sys::fuzz_target;

#[derive(Arbitrary, Debug)]
struct Input {
/// Year for the base date (biased toward boundary years).
base_year_selector: u8,
/// Year to embed in a constructed large-year input string.
input_year: u32,
month: u8,
day: u8,
/// Suffix appended after the constructed date (e.g. relative items).
suffix: String,
/// Whether to also call parse_datetime (no base).
try_no_base: bool,
}

fn base_year(selector: u8) -> i16 {
match selector % 6 {
0 => 2024,
1 => 9998,
2 => 9999,
3 => 1,
4 => 100,
_ => (selector as i16) * 40,
}
}

fn clamp_year(y: u32) -> u32 {
// Focus on the interesting range: 9990..=100_000 and 0..=20_000
match y % 4 {
0 => 9990 + (y % 20), // near boundary
1 => 10000 + (y % 90_000), // large years
2 => y % 20_000, // general range
_ => 2_147_485_540 + (y % 10), // near GNU_MAX_YEAR
}
}

fuzz_target!(|input: Input| {
let year = clamp_year(input.input_year);
let month = (input.month % 12) + 1;
let day = (input.day % 28) + 1;
let date_str = format!("{year:04}-{month:02}-{day:02} {}", input.suffix);

// Test parse_datetime (uses current time as base).
if input.try_no_base {
let _ = parse_datetime::parse_datetime(&date_str);
}

// Test parse_datetime_at_date with a controlled base.
let by = base_year(input.base_year_selector);
if let Ok(base) = DateTime::new(by, 1, 1, 0, 0, 0, 0) {
if let Ok(base) = base.to_zoned(TimeZone::UTC) {
let _ = parse_datetime::parse_datetime_at_date(base, &date_str);
}
}

// Also try a bare large year as a pure number.
let bare = format!("{year}");
let _ = parse_datetime::parse_datetime(&bare);
});
33 changes: 33 additions & 0 deletions src/extended.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.

use std::fmt;

use crate::GNU_MAX_YEAR;

const SECONDS_PER_DAY: i64 = 86_400;
Expand Down Expand Up @@ -289,6 +291,37 @@ impl ExtendedDateTime {
}
}

impl fmt::Display for ExtendedDateTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let sign = if self.offset_seconds < 0 { '-' } else { '+' };
let abs = self.offset_seconds.unsigned_abs();
let oh = abs / 3600;
let om = (abs % 3600) / 60;
if self.nanosecond != 0 {
write!(
f,
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:09}{}{:02}:{:02}",
self.year,
self.month,
self.day,
self.hour,
self.minute,
self.second,
self.nanosecond,
sign,
oh,
om,
)
} else {
write!(
f,
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}{}{:02}:{:02}",
self.year, self.month, self.day, self.hour, self.minute, self.second, sign, oh, om,
)
}
}
}

pub fn is_leap_year(year: u32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
Expand Down
89 changes: 54 additions & 35 deletions src/items/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,8 +520,6 @@ impl TryFrom<Vec<Item>> for DateTimeBuilder {

for item in items {
builder = match item {
#[cfg(test)]
Item::Timestamp(ts) => builder.set_timestamp(ts)?,
Item::DateTime(dt) => builder.set_date(dt.date)?.set_time(dt.time)?,
Item::Date(d) => builder.set_date(d)?,
Item::Time(t) => builder.set_time(t)?,
Expand All @@ -540,7 +538,7 @@ impl TryFrom<Vec<Item>> for DateTimeBuilder {
#[cfg(test)]
mod tests {
use super::*;
use jiff::{civil::DateTime, tz::TimeZone, Zoned};
use jiff::{civil::DateTime, tz::TimeZone};

fn timestamp() -> epoch::Timestamp {
let mut input = "@1234567890";
Expand Down Expand Up @@ -632,24 +630,13 @@ mod tests {
}
}

fn expect_in_range_datetime(parsed: ParsedDateTime) -> Zoned {
match parsed {
ParsedDateTime::InRange(z) => z,
ParsedDateTime::Extended(dt) => panic!("expected in-range datetime, got {dt:?}"),
}
}

#[test]
fn duplicate_items_error() {
let test_cases = vec![
(
vec![Item::TimeZone(timezone()), Item::TimeZone(timezone())],
"timezone rule cannot appear more than once",
),
(
vec![Item::Timestamp(timestamp()), Item::Timestamp(timestamp())],
"timestamp cannot appear more than once",
),
(
vec![Item::Date(date()), Item::Date(date())],
"date cannot appear more than once",
Expand All @@ -674,29 +661,61 @@ mod tests {
}
}

#[test]
fn duplicate_timestamp_error() {
let builder = DateTimeBuilder::new().set_timestamp(timestamp()).unwrap();
assert_eq!(
builder.set_timestamp(timestamp()).unwrap_err(),
"timestamp cannot appear more than once"
);
}

#[test]
fn timestamp_cannot_be_combined_with_other_items() {
let test_cases = vec![
vec![Item::Date(date()), Item::Timestamp(timestamp())],
vec![Item::Time(time()), Item::Timestamp(timestamp())],
vec![Item::Weekday(weekday()), Item::Timestamp(timestamp())],
vec![Item::Offset(offset()), Item::Timestamp(timestamp())],
vec![Item::Relative(relative_day()), Item::Timestamp(timestamp())],
vec![Item::Timestamp(timestamp()), Item::Date(date())],
vec![Item::Timestamp(timestamp()), Item::Time(time())],
vec![Item::Timestamp(timestamp()), Item::Weekday(weekday())],
vec![Item::Timestamp(timestamp()), Item::Relative(relative_day())],
vec![Item::Timestamp(timestamp()), Item::Offset(offset())],
vec![Item::Timestamp(timestamp()), Item::Pure("2023".to_string())],
// Setting a timestamp after other items have been set.
let builders_with_items: Vec<DateTimeBuilder> = vec![
DateTimeBuilder::new().set_date(date()).unwrap(),
DateTimeBuilder::new().set_time(time()).unwrap(),
DateTimeBuilder::new().set_weekday(weekday()).unwrap(),
DateTimeBuilder::new().set_offset(offset()).unwrap(),
DateTimeBuilder::new()
.push_relative(relative_day())
.unwrap(),
];

for items in test_cases {
let result = DateTimeBuilder::try_from(items);
for builder in builders_with_items {
assert_eq!(
result.unwrap_err(),
builder.set_timestamp(timestamp()).unwrap_err(),
"timestamp cannot be combined with other date/time items"
);
}

// Setting other items after a timestamp has been set.
let ts_builder = || DateTimeBuilder::new().set_timestamp(timestamp()).unwrap();
assert_eq!(
ts_builder().set_date(date()).unwrap_err(),
"timestamp cannot be combined with other date/time items"
);
assert_eq!(
ts_builder().set_time(time()).unwrap_err(),
"timestamp cannot be combined with other date/time items"
);
assert_eq!(
ts_builder().set_weekday(weekday()).unwrap_err(),
"timestamp cannot be combined with other date/time items"
);
assert_eq!(
ts_builder().push_relative(relative_day()).unwrap_err(),
"timestamp cannot be combined with other date/time items"
);
assert_eq!(
ts_builder().set_offset(offset()).unwrap_err(),
"timestamp cannot be combined with other date/time items"
);
assert_eq!(
ts_builder().set_pure("2023".to_string()).unwrap_err(),
"timestamp cannot be combined with other date/time items"
);
}

#[test]
Expand Down Expand Up @@ -893,7 +912,7 @@ mod tests {
Item::Relative(relative_day()),
])
.unwrap();
let z = expect_in_range_datetime(builder.set_base(base).build().unwrap());
let z = builder.set_base(base).build().unwrap().expect_in_range();
assert_eq!(z.strftime("%Y-%m-%d").to_string(), "9999-02-01");
}

Expand Down Expand Up @@ -927,7 +946,7 @@ mod tests {
Item::Relative(relative_month()),
])
.unwrap();
let z = expect_in_range_datetime(builder.set_base(base).build().unwrap());
let z = builder.set_base(base).build().unwrap().expect_in_range();
assert_eq!(z.strftime("%Y-%m-%d").to_string(), "2021-03-03");
}

Expand Down Expand Up @@ -969,7 +988,7 @@ mod tests {
.unwrap();
let mut builder = DateTimeBuilder::new();
builder.base = Some(base);
let z = expect_in_range_datetime(builder.build_extended().unwrap());
let z = builder.build_extended().unwrap().expect_in_range();
assert_eq!(
z.strftime("%Y-%m-%d %H:%M:%S%.9f").to_string(),
"2000-01-01 10:11:12.123456789"
Expand All @@ -993,12 +1012,12 @@ mod tests {
}

#[test]
#[should_panic(expected = "expected in-range datetime")]
fn expect_in_range_datetime_panics_for_extended_input() {
#[should_panic(expected = "ParsedDateTime is not representable as jiff::Zoned")]
fn expect_in_range_panics_for_extended_input() {
let parsed = DateTimeBuilder::try_from(vec![Item::Date(date_large("10000-01-01"))])
.unwrap()
.build()
.unwrap();
let _ = expect_in_range_datetime(parsed);
let _ = parsed.expect_in_range();
}
}
Loading
Loading