From e3eb63621cf2f79e48a678310950d0fd1bb9c5e8 Mon Sep 17 00:00:00 2001 From: Sunshine Date: Fri, 16 Oct 2026 16:35:22 -0400 Subject: [PATCH] add fuzz targets and randomized round-trip tests --- CHANGELOG.md | 6 ++ README.md | 21 +++++ fuzz/.gitignore | 5 + fuzz/Cargo.toml | 36 ++++++++ fuzz/fuzz_targets/parse.rs | 12 +++ fuzz/fuzz_targets/serialize.rs | 14 +++ src/lib.rs | 4 + tests/common/mod.rs | 1 + tests/common/roundtrip.rs | 98 ++++++++++++++++++++ tests/roundtrip.rs | 161 +++++++++++++++++++++++++++++++++ 10 files changed, 358 insertions(+) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/parse.rs create mode 100644 fuzz/fuzz_targets/serialize.rs create mode 100644 tests/common/roundtrip.rs create mode 100644 tests/roundtrip.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index df7c4b4..d9d6cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ from about 40 MB to 10 MB (just the result), and parsing got about a third faster - The CLI writes its output as it encodes, instead of building it in memory first +### Testing + + - Randomized round-trip tests (`tests/roundtrip.rs`) and [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) + targets (`fuzz/`), which share the same checks: parsing never panics, and anything that parses or gets set + survives being serialized and parsed again, with every combination of encoding and escaping + ## 0.2.1 ### Added diff --git a/README.md b/README.md index cb2a0ab..918a183 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,27 @@ dataurl also generates data URLs (picking the shortest encoding if asked to), ha --------------------------------------------------- +## Testing + +```console +make test +``` + +Besides unit tests, this runs the [Web Platform Tests](https://github.com/web-platform-tests/wpt) suites for data URLs, +base64 and MIME types (in `tests/wpt/`), and randomized round-trip tests (`tests/roundtrip.rs`). +Those run 5000 iterations each by default; set `DATAURL_ROUNDTRIP_ITERATIONS` for more. +The same checks are available as [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) targets, which need a nightly toolchain: + +```console +cargo install cargo-fuzz +cargo +nightly fuzz run parse +cargo +nightly fuzz run serialize +``` + + +--------------------------------------------------- + + ## Installation #### Using [Cargo](https://crates.io/crates/dataurl) diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..5c404b9 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,5 @@ +target +corpus +artifacts +coverage +Cargo.lock diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..df6674d --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,36 @@ +# Fuzz targets for dataurl, run with cargo-fuzz (needs a nightly toolchain): +# cargo install cargo-fuzz +# cargo +nightly fuzz run parse +# cargo +nightly fuzz run serialize +# They use the same checks as tests/roundtrip.rs (in tests/common/roundtrip.rs). + +[package] +name = "dataurl-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +dataurl = { path = "..", default-features = false } +libfuzzer-sys = "0.4" + +# Keeps this crate out of any workspace the parent directory might be in +[workspace] +members = ["."] + +[[bin]] +name = "parse" +path = "fuzz_targets/parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "serialize" +path = "fuzz_targets/serialize.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/parse.rs b/fuzz/fuzz_targets/parse.rs new file mode 100644 index 0000000..d447aa1 --- /dev/null +++ b/fuzz/fuzz_targets/parse.rs @@ -0,0 +1,12 @@ +//! Parses arbitrary input: it must never panic, and whatever parses must round-trip. + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +#[path = "../../tests/common/roundtrip.rs"] +mod roundtrip; + +fuzz_target!(|input: &[u8]| { + roundtrip::check_parse(&String::from_utf8_lossy(input)); +}); diff --git a/fuzz/fuzz_targets/serialize.rs b/fuzz/fuzz_targets/serialize.rs new file mode 100644 index 0000000..96e87a1 --- /dev/null +++ b/fuzz/fuzz_targets/serialize.rs @@ -0,0 +1,14 @@ +//! Serializes arbitrary data with fuzzer-chosen settings: it must parse back to the same data. + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +#[path = "../../tests/common/roundtrip.rs"] +mod roundtrip; + +fuzz_target!(|input: &[u8]| { + // The first bytes pick the settings, the rest is the data + let (choices, data) = input.split_at(input.len().min(16)); + roundtrip::check_serialize(data, choices); +}); diff --git a/src/lib.rs b/src/lib.rs index 6a08f95..ee46a6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -177,6 +177,10 @@ impl DataUrl { /// /// Like URL parsers, this strips leading and trailing spaces and control characters, /// and ignores tabs and newlines anywhere in the input. + /// + /// The decoded data is never larger than the input, since both percent-encoding and base64 + /// take more characters than the bytes they represent, so untrusted input can't make parsing + /// allocate more than a small multiple of its own size. pub fn parse(input: &str) -> Result { let input = input.trim_matches(|c: char| c <= ' '); let input: Cow = if input.contains(['\t', '\n', '\r']) { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 22fdbb3..2bca3d9 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1 +1,2 @@ pub mod json; +pub mod roundtrip; diff --git a/tests/common/roundtrip.rs b/tests/common/roundtrip.rs new file mode 100644 index 0000000..392525e --- /dev/null +++ b/tests/common/roundtrip.rs @@ -0,0 +1,98 @@ +//! Property checks shared by the randomized tests (tests/roundtrip.rs) and the fuzz targets (fuzz/). +//! Each check panics if a property doesn't hold. + +#![allow(dead_code)] + +use dataurl::{DataEncoding, DataUrl, Escaping}; + +pub const DATA_ENCODINGS: [DataEncoding; 3] = [ + DataEncoding::Percent, + DataEncoding::Base64, + DataEncoding::Shortest, +]; +pub const ESCAPINGS: [Escaping; 2] = [Escaping::Full, Escaping::Minimal]; + +/// Parsing must never panic, and whatever parses must survive being serialized and parsed again, +/// with every combination of settings. +pub fn check_parse(input: &str) { + let Ok(data_url) = DataUrl::parse(input) else { + return; + }; + + // Data never gets bigger than the input it was decoded from + assert!(data_url.data().len() <= input.len(), "{input:?}"); + + for data_encoding in DATA_ENCODINGS { + for escaping in ESCAPINGS { + let mut data_url = data_url.clone(); + data_url.set_data_encoding(data_encoding); + data_url.set_escaping(escaping); + let serialized = data_url.to_string(); + + assert!(serialized.is_ascii(), "{input:?} -> {serialized:?}"); + assert_eq!(serialized.len(), data_url.encoded_len(), "{input:?}"); + + let mut reparsed = DataUrl::parse(&serialized) + .unwrap_or_else(|e| panic!("{input:?} -> {serialized:?} doesn't parse: {e}")); + assert_eq!( + reparsed.data(), + data_url.data(), + "{input:?} -> {serialized:?}" + ); + assert_eq!( + reparsed.mime_type(), + data_url.mime_type(), + "{input:?} -> {serialized:?}" + ); + + // A second round must give exactly the same result + reparsed.set_data_encoding(data_encoding); + reparsed.set_escaping(escaping); + assert_eq!(reparsed.to_string(), serialized, "{input:?}"); + } + } +} + +/// Anything that can be set must survive being serialized and parsed. +/// `choices` drives the settings, so that fuzzers can explore them. +pub fn check_serialize(data: &[u8], choices: &[u8]) { + const MEDIA_TYPES: [Option<&str>; 5] = [ + None, + Some("text/plain"), + Some("image/svg+xml"), + Some("application/octet-stream"), + Some("x/base64"), + ]; + let choice = |i: usize| choices.get(i).copied().unwrap_or(0) as usize; + + let mut data_url = DataUrl::new(); + data_url + .set_media_type(MEDIA_TYPES[choice(0) % MEDIA_TYPES.len()]) + .unwrap(); + data_url.set_data_encoding(DATA_ENCODINGS[choice(1) % DATA_ENCODINGS.len()]); + data_url.set_escaping(ESCAPINGS[choice(2) % ESCAPINGS.len()]); + + // Parameter values: any printable ASCII except "," and "#", which can't be represented + let value: String = choices + .iter() + .skip(3) + .take(choice(3) % 8) + .map(|&b| (b' ' + b % 95) as char) + .filter(|&c| c != ',' && c != '#') + .collect(); + if choice(4) % 2 == 1 { + data_url.set_parameter("p", Some(&value)).unwrap(); + } + if choice(5) % 3 == 1 { + data_url.set_fragment(Some(&String::from_utf8_lossy(&data[..data.len().min(16)]))); + } + data_url.set_data(data); + + let serialized = data_url.to_string(); + assert_eq!(serialized.len(), data_url.encoded_len()); + + let reparsed = + DataUrl::parse(&serialized).unwrap_or_else(|e| panic!("{serialized:?} doesn't parse: {e}")); + assert_eq!(reparsed.data(), data, "{serialized:?}"); + assert_eq!(reparsed.mime_type(), data_url.mime_type(), "{serialized:?}"); +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs new file mode 100644 index 0000000..7689ecc --- /dev/null +++ b/tests/roundtrip.rs @@ -0,0 +1,161 @@ +//! Randomized round-trip tests, using the same checks as the fuzz targets (fuzz/). +//! They're deterministic: every run uses the same inputs, so failures can be reproduced. +//! Set DATAURL_ROUNDTRIP_ITERATIONS to run more of them. + +mod common; + +use common::roundtrip::{check_parse, check_serialize}; + +fn iterations() -> u64 { + std::env::var("DATAURL_ROUNDTRIP_ITERATIONS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5_000) +} + +/// Deterministic pseudo-random numbers (xorshift) +struct Random(u64); + +impl Random { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + + fn bytes(&mut self, max_length: usize) -> Vec { + let length = self.below(max_length + 1); + (0..length).map(|_| self.next() as u8).collect() + } +} + +/// Pieces that data URLs are made of, so that random inputs are likely to hit interesting cases +const PIECES: &[&str] = &[ + "data:", + "DATA:", + ",", + ";", + "base64", + "BASE64", + ";base64", + "; base64", + "charset=", + "=", + "\"", + "\\", + "%", + "%2", + "%2C", + "%25", + "%3B", + "%ZZ", + "#", + "?", + "/", + "//", + "//host/", + "/../", + "/./", + "text/plain", + "text/html", + "image/svg+xml", + "x/x", + " ", + " ", + "\t", + "\n", + "\r", + "\u{c}", + "\0", + "\u{7f}", + "é", + "†", + "💩", + "a", + "Z", + "0", + "+", + "=", + "==", + "QQ", + "QUJD", + "WA", + "SGVsbG8", + "", + "&", + "'", + "(", + ")", + "[::1]", + ":80", + "@", + "utf-8", + "UTF-8", + "gbk", +]; + +fn random_data_url(random: &mut Random) -> String { + let mut input = String::new(); + if random.below(10) != 0 { + input.push_str("data:"); + } + for _ in 0..random.below(12) { + input.push_str(PIECES[random.below(PIECES.len())]); + } + input +} + +#[test] +fn random_inputs_parse_and_round_trip() { + let mut random = Random(0x5EED_DA7A_0001); + for _ in 0..iterations() { + check_parse(&random_data_url(&mut random)); + } +} + +#[test] +fn random_bytes_parse_without_panicking() { + let mut random = Random(0x5EED_DA7A_0002); + for _ in 0..iterations() { + let mut bytes = b"data:".to_vec(); + bytes.extend(random.bytes(64)); + check_parse(&String::from_utf8_lossy(&bytes)); + } +} + +#[test] +fn random_data_serializes_and_round_trips() { + let mut random = Random(0x5EED_DA7A_0003); + for _ in 0..iterations() { + let data = random.bytes(200); + let choices = random.bytes(16); + check_serialize(&data, &choices); + } +} + +#[test] +fn known_tricky_inputs() { + for input in [ + "data:", + "data:,", + "data:;base64,", + "data:;base64;base64,WA", + "data:x/x;base64;charset=x;base64,WA", + "data:text/plain;a=\";base64\",x", + "data:;charset=\"\\\"\",x", + "data:,a%", + "data:,%%%", + "data:, # ", + "data://test/a/../,X", + "data://[::1]:80/,X", + "data:/x/./,X", + "data:\u{0}\t\n,x", + ] { + check_parse(input); + } +}