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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target
corpus
artifacts
coverage
Cargo.lock
36 changes: 36 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions fuzz/fuzz_targets/parse.rs
Original file line number Diff line number Diff line change
@@ -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));
});
14 changes: 14 additions & 0 deletions fuzz/fuzz_targets/serialize.rs
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DataUrl, Error> {
let input = input.trim_matches(|c: char| c <= ' ');
let input: Cow<str> = if input.contains(['\t', '\n', '\r']) {
Expand Down
1 change: 1 addition & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod json;
pub mod roundtrip;
98 changes: 98 additions & 0 deletions tests/common/roundtrip.rs
Original file line number Diff line number Diff line change
@@ -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:?}");
}
161 changes: 161 additions & 0 deletions tests/roundtrip.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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",
"<svg>",
"&amp;",
"'",
"(",
")",
"[::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);
}
}
Loading