diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc601af..e06028f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,10 @@ on: push: branches: ['main'] pull_request: + workflow_dispatch: + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} @@ -30,6 +34,24 @@ jobs: toolchain: ${{ matrix.rust }} - run: cargo test --locked --all-features + proptest: + name: Property tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --locked --all-features --test proptest + + fuzz: + name: Fuzz smoke test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@nightly + - run: cargo install cargo-fuzz --version 0.13.2 --locked + - run: cargo fuzz run linked_hash_map -- -max_len=1024 -max_total_time=30 + msrv: runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index fabc3de..a5ba2a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,42 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -22,11 +52,42 @@ name = "hashlink" version = "0.12.2" dependencies = [ "hashbrown", + "proptest", "rustc-hash", "serde", "serde_test", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -36,6 +97,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb0be07becd10686a0bb407298fb425360a5c44a663774406340c59a22de4ce" +dependencies = [ + "bitflags", + "lazy_static", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "quote" version = "1.0.45" @@ -45,6 +122,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -100,8 +227,49 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index a1c0c48..ae15acd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,4 @@ serde = { version = "1.0", default-features = false, optional = true } [dev-dependencies] serde_test = "1.0" rustc-hash = "2" +proptest = { version = "~1.8.0", default-features = false, features = ["std"] } diff --git a/README.md b/README.md index bd1475c..761b5db 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,52 @@ linked list, and the unsafe code has diverged quite a lot from the original sanitizers, but it should probably still receive more review and testing, and check for test code coverage. +## Testing unsafe code + +The integration test `tests/proptest.rs` uses proptest to compare random operation +sequences against a linear, ordered reference model. It checks contents, lookups, +length, and forward/backward iteration after each operation, using both the default +hasher and a deliberately colliding hasher. Heap-owned keys and values exercise +destruction as well as node reuse, cursor insertion, reordering, retention, +resizing, mutable iteration, and partially consumed drains and owning iterators. +The map contains the shared unsafe linked-list implementation used by the set and +LRU cache. + +Run the property tests with: + +```sh +cargo test --locked --test proptest +PROPTEST_CASES=10000 cargo test --locked --test proptest +cargo +nightly miri test --locked --test proptest +``` + +These tests also run in the existing Miri and sanitizer CI jobs, with smaller +default case counts and sequences under Miri. Proptest shrinks failing sequences +and saves regression seeds; keep those seeds when adding a regression test. +Seed persistence is disabled under Miri to avoid filesystem access in isolation. + +The `fuzz/` package uses cargo-fuzz/libFuzzer and the same reference-model runner: + +```sh +cargo install cargo-fuzz --locked +cargo +nightly fuzz run linked_hash_map -- -max_len=1024 +# Bounded smoke test: +cargo +nightly fuzz run linked_hash_map -- -max_len=1024 -max_total_time=30 +# Replay a saved failure: +cargo +nightly fuzz run linked_hash_map fuzz/artifacts/linked_hash_map/ +``` + +Each four input bytes encode an operation, a key, and a little-endian `i16` +value. Inputs are limited to 256 operations and 32 distinct keys to favor +replacement and node reuse while bounding resource consumption. Generated +corpora, crash artifacts, coverage output, and fuzz build output are ignored. + +The GitHub Actions CI workflow preserves upstream's checks and also runs dedicated +property-test and 30-second fuzz-smoke jobs on pull requests and pushes to `main`. +It can also be started manually using **Run workflow** in the Actions tab. +On a new fork, enable repository workflows in the Actions tab if GitHub prompts +for it; workflow files alone cannot enable repository-level Actions settings. + ## Credit There is a huge amount of code in this crate that is copied verbatim from 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..81ec263 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,112 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hashlink-fuzz" +version = "0.0.0" +dependencies = [ + "hashlink", + "libfuzzer-sys", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..96a097d --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hashlink-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4.13" + +[dependencies.hashlink] +path = ".." + +[[bin]] +name = "linked_hash_map" +path = "fuzz_targets/linked_hash_map.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/linked_hash_map.rs b/fuzz/fuzz_targets/linked_hash_map.rs new file mode 100644 index 0000000..2b157d7 --- /dev/null +++ b/fuzz/fuzz_targets/linked_hash_map.rs @@ -0,0 +1,15 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +#[path = "../../tests/support/mod.rs"] +mod support; + +fuzz_target!(|data: &[u8]| { + let operations: Vec<_> = data + .chunks_exact(4) + .take(256) + .map(|bytes| (bytes[0], bytes[1], i16::from_le_bytes([bytes[2], bytes[3]]))) + .collect(); + support::check_operations(&operations); +}); diff --git a/tests/proptest.rs b/tests/proptest.rs new file mode 100644 index 0000000..ab89de0 --- /dev/null +++ b/tests/proptest.rs @@ -0,0 +1,41 @@ +mod support; + +use proptest::prelude::*; + +proptest! { + #![proptest_config(ProptestConfig { + cases: if cfg!(miri) { 8 } else { 128 }, + failure_persistence: if cfg!(miri) { + None + } else { + ProptestConfig::default().failure_persistence + }, + ..ProptestConfig::default() + })] + + #[test] + fn linked_list_operations_match_model( + operations in prop::collection::vec( + (any::(), any::(), any::()), + 0..if cfg!(miri) { 32 } else { 256 }, + ) + ) { + support::check_operations(&operations); + } +} + +#[test] +fn node_reuse_and_iterator_drop() { + let mut operations = Vec::new(); + for round in 0..3 { + for key in 0..32 { + operations.push((0, key, round)); + } + // Exercise every operation between growth cycles. + for operation in 0..24 { + operations.push((operation, operation, round)); + } + operations.push((16, 0, 0)); + } + support::check_operations(&operations); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..0019a1f --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,262 @@ +use std::hash::{BuildHasher, BuildHasherDefault, Hasher}; + +use hashlink::{DefaultHashBuilder, LinkedHashMap, linked_hash_map::RawEntryMut}; + +#[derive(Default)] +struct CollidingHasher; + +impl Hasher for CollidingHasher { + fn write(&mut self, _: &[u8]) {} + + fn finish(&self) -> u64 { + 0 + } +} + +pub fn check_operations(operations: &[(u8, u8, i16)]) { + check_with_hasher::(operations); + check_with_hasher::>(operations); +} + +fn check_with_hasher(operations: &[(u8, u8, i16)]) { + let mut map = LinkedHashMap::with_hasher(S::default()); + // A linear model avoids relying on hashlink's table or linked-list implementation. + let mut model: Vec<(String, String)> = Vec::new(); + + for &(operation, key, value) in operations { + let key = (key % 32).to_string(); + let value = value.to_string(); + let position = model.iter().position(|(k, _)| k == &key); + + match operation % 24 { + 0 | 1 => { + let old = position.map(|i| model.remove(i).1); + let actual = if operation % 24 == 0 { + model.push((key.clone(), value.clone())); + map.insert(key.clone(), value) + } else { + model.insert(0, (key.clone(), value.clone())); + map.insert_front(key.clone(), value) + }; + assert_eq!(actual, old); + } + 2 => { + let old = if let Some(i) = position { + Some(std::mem::replace(&mut model[i].1, value.clone())) + } else { + model.push((key.clone(), value.clone())); + None + }; + assert_eq!(map.replace(key.clone(), value), old); + } + 3 => { + assert_eq!( + map.remove_entry(key.as_str()), + position.map(|i| model.remove(i)) + ); + } + 4 => { + let expected = if model.is_empty() { + None + } else { + Some(model.remove(0)) + }; + assert_eq!(map.pop_front(), expected); + } + 5 => assert_eq!(map.pop_back(), model.pop()), + 6 | 7 => { + let expected = position.map(|i| { + let entry = model.remove(i); + let value = entry.1.clone(); + if operation % 24 == 6 { + model.insert(0, entry); + } else { + model.push(entry); + } + value + }); + let actual = if operation % 24 == 6 { + map.to_front(key.as_str()) + } else { + map.to_back(key.as_str()) + }; + assert_eq!(actual.cloned(), expected); + } + 8 => { + let expected = if let Some(i) = position { + let entry = model.remove(i); + let old = entry.1.clone(); + model.push(entry); + old + } else { + model.push((key.clone(), value.clone())); + value.clone() + }; + assert_eq!(*map.entry(key.clone()).or_insert(value), expected); + } + 9 => match map.raw_entry_mut().from_key(key.as_str()) { + RawEntryMut::Occupied(entry) => { + assert_eq!( + Some(entry.remove_entry()), + position.map(|i| model.remove(i)) + ); + } + RawEntryMut::Vacant(entry) => { + assert!(position.is_none()); + entry.insert(key.clone(), value.clone()); + model.push((key.clone(), value)); + } + }, + 10 | 11 => { + let keep = |k: &String, v: &mut String| { + v.push('r'); + k.as_bytes()[0] % 2 == key.as_bytes()[0] % 2 + }; + if operation % 24 == 10 { + map.retain(keep); + } else { + map.retain_with_order(keep); + } + model.retain_mut(|(k, v)| keep(k, v)); + } + 12 => { + // Keep all mutable references alive together to expose iterator aliasing in Miri. + let mut iter = map.iter_mut(); + let mut entries = Vec::new(); + for i in 0..model.len() { + entries.push(if i % 2 == 0 { + iter.next().unwrap() + } else { + iter.next_back().unwrap() + }); + } + assert!(iter.next().is_none()); + assert!(iter.next_back().is_none()); + for (_, v) in entries { + v.push('m'); + } + for (_, v) in &mut model { + v.push('m'); + } + } + 13 => { + // Dropping a partially consumed drain must empty the map and leave it reusable. + let mut drain = map.drain(); + if !model.is_empty() { + assert_eq!(drain.next(), Some(model.remove(0))); + } + assert_eq!(drain.next_back(), model.pop()); + drop(drain); + model.clear(); + } + 14 => { + map.clear(); + model.clear(); + } + 15 => map.reserve(usize::from(operation % 32)), + 16 => map.shrink_to_fit(), + 17 => { + let cloned = map.clone(); + assert!(cloned.iter().eq(model.iter().map(|(k, v)| (k, v)))); + let mut iter = cloned.into_iter(); + let mut expected = model.clone(); + if !expected.is_empty() { + assert_eq!(iter.next(), Some(expected.remove(0))); + } + assert_eq!(iter.next_back(), expected.pop()); + // Leave the rest to IntoIter::drop. + } + 18 | 19 => { + // Include the ghost position and insertion next to an existing key (including itself). + let index = usize::from(operation) % (model.len() + 1); + let before = if operation % 24 == 18 { + model.get(index) + } else if index == model.len() { + model.first() + } else { + model.get(index + 1) + } + .map(|(k, _)| k.clone()); + let mut cursor = map.cursor_front_mut(); + for _ in 0..index { + cursor.move_next(); + } + let old = if operation % 24 == 18 { + cursor.insert_before(key.clone(), value.clone()) + } else { + cursor.insert_after(key.clone(), value.clone()) + }; + let expected = position.map(|i| model.remove(i).1); + let index = before + .as_ref() + .and_then(|k| model.iter().position(|(other, _)| other == k)) + .unwrap_or(model.len()); + // Inserting immediately before itself only replaces the value. + let index = if before.as_ref() == Some(&key) { + position.unwrap() + } else { + index + }; + model.insert(index, (key.clone(), value)); + assert_eq!(old, expected); + } + 20 => { + if let Some(v) = map.get_mut(key.as_str()) { + *v = value.clone(); + } + if let Some(i) = position { + model[i].1 = value; + } + } + 21 => { + let mut cursor = map.cursor_back_mut(); + for (k, v) in model.iter().rev() { + assert_eq!(cursor.current().map(|(k, v)| (k, &*v)), Some((k, v))); + cursor.move_prev(); + } + assert!(cursor.current().is_none()); + } + 22 => { + let expected = if model.is_empty() { + None + } else { + Some(model.remove(0)) + }; + assert_eq!( + map.front_entry().map(|entry| entry.remove_entry()), + expected + ); + } + 23 => { + assert_eq!( + map.back_entry().map(|entry| entry.remove_entry()), + model.pop() + ); + } + _ => unreachable!(), + } + + assert_eq!(map.len(), model.len()); + assert_eq!(map.is_empty(), model.is_empty()); + assert_eq!(map.front(), model.first().map(|(k, v)| (k, v))); + assert_eq!(map.back(), model.last().map(|(k, v)| (k, v))); + assert!( + map.iter() + .take(model.len() + 1) + .eq(model.iter().map(|(k, v)| (k, v))) + ); + assert!( + map.iter() + .rev() + .take(model.len() + 1) + .eq(model.iter().rev().map(|(k, v)| (k, v))) + ); + for (k, v) in &model { + assert_eq!(map.get(k.as_str()), Some(v)); + } + assert_eq!( + map.get(key.as_str()), + model.iter().find(|(k, _)| k == &key).map(|(_, v)| v) + ); + } +}