diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec4df7e..cd96087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,6 +229,23 @@ jobs: run: | cargo bench --manifest-path bench/Cargo.toml -- --test + # Test that we can build the fuzzer targets. + # + # It's not necessary to check the fuzzers on all platforms, as this is pretty + # strictly a testing utility. + test-fuzz: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + - name: Build fuzzer targets + working-directory: ./fuzz + run: cargo build --verbose + # Runs miri on a subset of Jiff's test suite. This doesn't quite cover # everything. In particular, `miri` and `insta` cannot play nice together, # and `insta` is used a lot among Jiff's tests. However, the primary reason diff --git a/.vim/coc-settings.json b/.vim/coc-settings.json index fc1050d..24a7443 100644 --- a/.vim/coc-settings.json +++ b/.vim/coc-settings.json @@ -2,6 +2,7 @@ "rust-analyzer.linkedProjects": [ "bench/Cargo.toml", "crates/jiff-icu/Cargo.toml", + "fuzz/Cargo.toml", "Cargo.toml" ] } diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..f2c0ea0 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,255 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "cc" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a5c3fd7bfa1ce3897a3a3501d362b2d87b7f2583ebcb4a949ec25911025cbc" +dependencies = [ + "jobserver", + "libc", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff" +version = "0.2.16" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys", +] + +[[package]] +name = "jiff-fuzz" +version = "0.0.0" +dependencies = [ + "jiff", + "libfuzzer-sys", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.4" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96cfd5557eb82f2b83fed4955246c988d331975a002961b07c81584d107e7f7" +dependencies = [ + "arbitrary", + "cc", + "once_cell", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..7c2a71f --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "jiff-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[features] +relaxed = [] + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] } + +[dependencies.jiff] +path = ".." + +[workspace] +members = ["."] + +[[bin]] +name = "rfc2822_parse" +path = "fuzz_targets/rfc2822_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "strtime_parse" +path = "fuzz_targets/strtime_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "temporal_parse" +path = "fuzz_targets/temporal_parse.rs" +test = false +doc = false +bench = false + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] } \ No newline at end of file diff --git a/fuzz/fuzz_targets/rfc2822_parse.rs b/fuzz/fuzz_targets/rfc2822_parse.rs new file mode 100644 index 0000000..d26a3b9 --- /dev/null +++ b/fuzz/fuzz_targets/rfc2822_parse.rs @@ -0,0 +1,50 @@ +#![cfg_attr(fuzzing, no_main)] + +use std::borrow::Cow; + +use libfuzzer_sys::fuzz_target; + +use jiff::fmt::rfc2822; + +mod shim; + +fn do_fuzz(data: &[u8]) { + const RFC2822_PARSER: rfc2822::DateTimeParser = + rfc2822::DateTimeParser::new(); + const RFC2822_PRINTER: rfc2822::DateTimePrinter = + rfc2822::DateTimePrinter::new(); + + let Ok(first) = RFC2822_PARSER.parse_zoned(data) else { return }; + let mut unparsed = Vec::with_capacity(data.len()); + RFC2822_PRINTER + .print_zoned(&first, &mut unparsed) + .expect("We parsed it, so we should be able to print it"); + + match RFC2822_PARSER.parse_zoned(&unparsed) { + Ok(second) => { + assert_eq!( + first, second, + "expected the initially parsed value \ + to be equal to the value after printing and re-parsing", + ); + } + Err(e) if cfg!(not(feature = "relaxed")) => { + let unparsed_str = String::from_utf8_lossy(&unparsed); + panic!( + "should be able to parse a printed value; \ + failed with `{e}` at: `{unparsed_str}`{}, \ + corresponding to {first:?}", + if matches!(unparsed_str, Cow::Owned(_)) { + Cow::from(format!(" (lossy; actual bytes: {unparsed:?})")) + } else { + Cow::from("") + } + ); + } + Err(_) => {} + } +} + +fuzz_target!(|data: &[u8]| do_fuzz(data)); + +maybe_define_main!(); diff --git a/fuzz/fuzz_targets/shim.rs b/fuzz/fuzz_targets/shim.rs new file mode 100644 index 0000000..5bb129d --- /dev/null +++ b/fuzz/fuzz_targets/shim.rs @@ -0,0 +1,48 @@ +use std::{ + error::Error, + ffi::c_int, + {env, fs, ptr}, +}; + +extern "C" { + // Initializer provided by libfuzzer-sys for creating an + // appropriate panic hook. + fn LLVMFuzzerInitialize( + argc: *const isize, + argv: *const *const *const u8, + ) -> c_int; + + // This is a magic function defined by libfuzzer-sys; use for replay. + #[allow(improper_ctypes)] + fn rust_fuzzer_test_input(input: &[u8]) -> i32; +} + +#[allow(unused)] +pub fn main() -> Result<(), Box> { + let mut count = 0usize; + unsafe { + let _ = LLVMFuzzerInitialize(ptr::null(), ptr::null()); + } + for testcase in env::args_os().skip(1) { + let content = fs::read(testcase)?; + unsafe { + let _ = rust_fuzzer_test_input(&content); + } + count += 1; + } + println!("Executed {count} testcases successfully!"); + if count == 0 { + println!("Did you mean to specify a testcase?"); + } + Ok(()) +} + +#[macro_export] +macro_rules! maybe_define_main { + () => { + #[cfg(not(fuzzing))] + fn main() { + let _ = $crate::shim::main(); + } + }; +} diff --git a/fuzz/fuzz_targets/strtime_parse.rs b/fuzz/fuzz_targets/strtime_parse.rs new file mode 100644 index 0000000..887f6a0 --- /dev/null +++ b/fuzz/fuzz_targets/strtime_parse.rs @@ -0,0 +1,103 @@ +#![cfg_attr(fuzzing, no_main)] + +use std::borrow::Cow; + +use libfuzzer_sys::{ + arbitrary, + arbitrary::{Arbitrary, Unstructured}, + fuzz_target, +}; + +use jiff::fmt::strtime::parse; + +mod shim; + +#[derive(Debug)] +struct Input<'a> { + format: &'a str, + input: &'a str, +} + +impl<'a> Arbitrary<'a> for Input<'a> { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let fmt_len: u8 = u.arbitrary()?; + let in_len: u16 = u.arbitrary()?; + let format = u.bytes(fmt_len as usize)?; + let format = core::str::from_utf8(format) + .map_err(|_| arbitrary::Error::IncorrectFormat)?; + let input = u.bytes(in_len as usize)?; + let input = core::str::from_utf8(input) + .map_err(|_| arbitrary::Error::IncorrectFormat)?; + Ok(Input { format, input }) + } + + fn arbitrary_take_rest( + mut u: Unstructured<'a>, + ) -> arbitrary::Result { + let len: u8 = u.arbitrary()?; + // ignored in take rest, but keep it consistent + let _in_len: u16 = u.arbitrary()?; + let format = u.bytes(len as usize)?; + let format = core::str::from_utf8(format) + .map_err(|_| arbitrary::Error::IncorrectFormat)?; + let input = u.take_rest(); + let input = core::str::from_utf8(input) + .map_err(|_| arbitrary::Error::IncorrectFormat)?; + Ok(Input { format, input }) + } +} + +fn do_fuzz(src: Input) { + if let Ok(first) = parse(src.format, src.input) { + let mut unparsed = Vec::with_capacity(src.input.len()); + if first.format(src.format, &mut unparsed).is_err() { + // There are a number of reasons why this may fail. the formatter + // is simply not as strong as the parser, so we accept failure + // here. + return; + } + + match parse(src.format, &unparsed) { + Ok(second) => { + // There's not a direct equality here. To get around this, we + // compare unparsed with doubly-unparsed. + + let mut unparsed_again = Vec::with_capacity(unparsed.len()); + second.format(src.format, &mut unparsed_again).expect( + "We parsed it (twice!), so we should be able to print it", + ); + + assert_eq!( + unparsed, + unparsed_again, + "expected the initially parsed value \ + to be equal to the value after \ + printing and re-parsing; \ + found `{}', expected `{}'", + String::from_utf8_lossy(&unparsed_again), + String::from_utf8_lossy(&unparsed), + ); + } + Err(e) if cfg!(not(feature = "relaxed")) => { + let unparsed_str = String::from_utf8_lossy(&unparsed); + panic!( + "should be able to parse a printed value; \ + failed with `{e}` at: `{unparsed_str}`{}, \ + corresponding to {first:?}", + if matches!(unparsed_str, Cow::Owned(_)) { + Cow::from(format!( + " (lossy; actual bytes: {unparsed:?})" + )) + } else { + Cow::from("") + } + ); + } + Err(_) => {} + } + } +} + +fuzz_target!(|data: Input<'_>| do_fuzz(data)); + +maybe_define_main!(); diff --git a/fuzz/fuzz_targets/temporal_parse.rs b/fuzz/fuzz_targets/temporal_parse.rs new file mode 100644 index 0000000..e339a13 --- /dev/null +++ b/fuzz/fuzz_targets/temporal_parse.rs @@ -0,0 +1,51 @@ +#![cfg_attr(fuzzing, no_main)] + +use std::borrow::Cow; + +use libfuzzer_sys::fuzz_target; + +use jiff::fmt::temporal; + +mod shim; + +fn do_fuzz(data: &[u8]) { + const TEMPORAL_PARSER: temporal::SpanParser = temporal::SpanParser::new(); + const TEMPORAL_PRINTER: temporal::SpanPrinter = + temporal::SpanPrinter::new(); + + let Ok(first) = TEMPORAL_PARSER.parse_span(data) else { return }; + // get a good start at least + let mut unparsed = Vec::with_capacity(data.len()); + TEMPORAL_PRINTER + .print_span(&first, &mut unparsed) + .expect("we parsed it, so we should be able to print it"); + + match TEMPORAL_PARSER.parse_span(&unparsed) { + Ok(second) => { + assert_eq!( + first, + second.fieldwise(), + "expected the initially parsed value \ + to be equal to the value after printing and re-parsing", + ); + } + Err(e) if cfg!(not(feature = "relaxed")) => { + let unparsed_str = String::from_utf8_lossy(&unparsed); + panic!( + "should be able to parse a printed value; \ + failed with `{e}` at: `{unparsed_str}`{}, \ + corresponding to {first:?}", + if matches!(unparsed_str, Cow::Owned(_)) { + Cow::from(format!(" (lossy; actual bytes: {unparsed:?})")) + } else { + Cow::from("") + } + ); + } + Err(_) => {} + } +} + +fuzz_target!(|data: &[u8]| do_fuzz(data)); + +maybe_define_main!();