From 2f29a4f2498830fb565ad75ed6946d3644400610 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:04:41 +0000 Subject: [PATCH 1/4] Initial plan From 5050fe9ab0584dda5c5cd6ed17f6792b9442a6d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:08:51 +0000 Subject: [PATCH 2/4] Add model-based property tests and cargo-fuzz coverage for unsafe list operations Co-authored-by: BatmanAoD <2313807+BatmanAoD@users.noreply.github.com> --- Cargo.lock | 168 +++++++++++++++++ Cargo.toml | 1 + README.md | 39 ++++ fuzz/.gitignore | 4 + fuzz/Cargo.lock | 112 ++++++++++++ fuzz/Cargo.toml | 21 +++ fuzz/fuzz_targets/linked_hash_map.rs | 15 ++ tests/proptest.rs | 36 ++++ tests/support/mod.rs | 262 +++++++++++++++++++++++++++ 9 files changed, 658 insertions(+) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/linked_hash_map.rs create mode 100644 tests/proptest.rs create mode 100644 tests/support/mod.rs 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..6f4dad4 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,45 @@ 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. + +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. + ## 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..0178d7a --- /dev/null +++ b/tests/proptest.rs @@ -0,0 +1,36 @@ +mod support; + +use proptest::prelude::*; + +proptest! { + #![proptest_config(ProptestConfig { + cases: if cfg!(miri) { 8 } else { 128 }, + ..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) + ); + } +} From 53c9f8865d40254750e8c3f7879375897c2c7d61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:13:09 +0000 Subject: [PATCH 3/4] Keep property tests compatible with Miri isolation Co-authored-by: BatmanAoD <2313807+BatmanAoD@users.noreply.github.com> --- README.md | 1 + tests/proptest.rs | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/README.md b/README.md index 6f4dad4..0ab06a6 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ 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: diff --git a/tests/proptest.rs b/tests/proptest.rs index 0178d7a..ab89de0 100644 --- a/tests/proptest.rs +++ b/tests/proptest.rs @@ -5,6 +5,11 @@ 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() })] From 10a0d6e783f2657489c07400f511a29563092503 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:20:13 +0000 Subject: [PATCH 4/4] Add property-test and fuzz-smoke jobs to inherited upstream CI Co-authored-by: BatmanAoD <2313807+BatmanAoD@users.noreply.github.com> (update to trigger GHA) --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ README.md | 6 ++++++ 2 files changed, 28 insertions(+) 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/README.md b/README.md index 0ab06a6..761b5db 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ 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