Skip to content
Open
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 proptest/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
## Unreleased

### New Features

- Added the ability to provide a full hex-encoded seed via PROPTEST_RNG_SEED,
allowing users to roundtrip a seed persisted after a failed run via the
command line.

## 1.6.0

### New Features
Expand Down
91 changes: 85 additions & 6 deletions proptest/src/test_runner/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,45 @@ pub fn contextualize_config(mut result: Config) -> Config {
RNG_ALGORITHM,
);
} else if var == RNG_SEED {
// this is a hacky workaround to deal with the fact that the
// entire code path surrounding parsing and contextualizing
// the RngSeed is only fallible within the parse function, however
// RngSeed, specifically the hex-encoded version, needs to ensure
// that the hex-encoded string matches the length of the seed that
// the configured `RngAlgorithm` expects.
//
// to work around this, we'll stash the existing seed, attempt to parse
// then attempt to validate, and if there is a validation failure,
// reset the config value back to the existing seed
let existing_seed = result.rng_seed;

parse_or_warn(
&value,
&mut result.rng_seed,
"u64",
"RngSeed",
RNG_SEED,
);

if let RngSeed::FullHexEncodedSeed(seed) = &result.rng_seed {
match result.rng_algorithm {
RngAlgorithm::XorShift => {
// 16-byte seed, hex-encoded with 2 chars per byte
if seed.len() != 16 {
eprintln!("proptest: Invalid FullHexEncodedSeed length. Expected a 16-byte seed but got: {:?}, len={}", seed, seed.len());
result.rng_seed = existing_seed;
}
}
RngAlgorithm::ChaCha => {
// 32-byte seed, hex-encoded with 2 chars per byte
if seed.len() != 32 {
eprintln!("proptest: Invalid FullHexEncodedSeed length. Expected a 32-byte seed but got: {:?}, len={}", seed, seed.len());
result.rng_seed = existing_seed;
}
}
_ => {}
}
}

} else if var == DISABLE_FAILURE_PERSISTENCE {
result.failure_persistence = None;
} else if var.starts_with("PROPTEST_") {
Expand Down Expand Up @@ -196,27 +229,68 @@ lazy_static! {
};
}

/// The seed for the RNG, can either be random or specified as a u64.
/// The seed for the RNG. Can either be random, specified as a u64, or specified
/// as a hex-encoded string.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RngSeed {
/// Default case, use a random value
Random,
/// Use a specific value to generate a seed
Fixed(u64)
/// Use a u64 to generate a seed
///
/// NB [03-30-25] Before `FullSeed`, this was the only way to provide a seed.
/// A u64 isn't sufficient to represent all posible seeds though, with most
/// seeds being a 32-byte buffer. This name must stay as `Fixed` since this
/// is part of the public API but a more appropriate name would be
/// `AbbreviatedNumericSeed`
Fixed(u64),
/// Use the provided hex-encoded string as the seed. This must be exactly the
/// size expected by the configured rng algorithm.
///
/// The seed written to persistence files is a hex-encoded string, meaning you
/// can pass a seed from those files to a TestRunner with this variant.
FullHexEncodedSeed(&'static [u8]),
}

impl str::FromStr for RngSeed {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<u64>().map(RngSeed::Fixed).map_err(|_| ())
let mut split = s.split("-");

match split.next() {
// input of the form `hex-{s}` is a full hex-encoded seed
Some("hex") => {
let seed_bytes = match split.next() {
Some(s) => {
let mut buf = vec![0_u8; s.len() / 2];
crate::test_runner::rng::from_base16(&mut buf[0..], &s);
buf
}
None => return Err(()),
};

if split.next().is_some() {
return Err(());
}

Ok(RngSeed::FullHexEncodedSeed(seed_bytes.leak()))
}
// any other input should be a u64 that a seed will be generated from
Some(_) => s.parse::<u64>().map(RngSeed::Fixed).map_err(|_| ()),
None => unreachable!("its not possible to ever return None on the first invocation of `next`. empty strings still return an empty string"),
}
}
}

impl fmt::Display for RngSeed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RngSeed::Random => write!(f, "random"),
RngSeed::Fixed(n) => write!(f, "{}", n),
RngSeed::Fixed(n) => write!(f, "u64-{}", n),
RngSeed::FullHexEncodedSeed(n) => {
let mut s = std::string::String::new();
crate::test_runner::to_base16(&mut s, n);
write!(f, "hex-{}", s)
}
}
}
}
Expand Down Expand Up @@ -434,6 +508,11 @@ pub struct Config {

/// Seed used for the RNG. Set by using the PROPTEST_RNG_SEED environment variable
/// If the environment variable is undefined, a random seed is generated (this is the default option).
///
/// PROPTEST_RNG_SEED supports two formats:
/// - `hex-{s}` where the string {s} is a hex-encoded seed, matching the expected length of a
/// seed for the configured rng algorithm.
/// - `{n}` where the u64 number {n} is used to create a seed for the configured run algorithm
pub rng_seed: RngSeed,

// Needs to be public so FRU syntax can be used.
Expand Down
47 changes: 25 additions & 22 deletions proptest/src/test_runner/rng.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,22 +268,6 @@ impl Seed {
}

pub(crate) fn from_persistence(string: &str) -> Option<Seed> {
fn from_base16(dst: &mut [u8], src: &str) -> Option<()> {
if dst.len() * 2 != src.len() {
return None;
}

for (dst_byte, src_pair) in
dst.into_iter().zip(src.as_bytes().chunks(2))
{
*dst_byte =
u8::from_str_radix(str::from_utf8(src_pair).ok()?, 16)
.ok()?;
}

Some(())
}

let parts =
string.trim().split(char::is_whitespace).collect::<Vec<_>>();
RngAlgorithm::from_persistence_key(&parts[0]).and_then(
Expand Down Expand Up @@ -347,12 +331,6 @@ impl Seed {
}

pub(crate) fn to_persistence(&self) -> String {
fn to_base16(dst: &mut String, src: &[u8]) {
for byte in src {
dst.push_str(&format!("{:02x}", byte));
}
}

match *self {
Seed::XorShift(ref seed) => {
let dwords = [
Expand Down Expand Up @@ -437,13 +415,15 @@ impl TestRng {
let rng = match seed {
RngSeed::Random => XorShiftRng::from_entropy(),
RngSeed::Fixed(seed) => XorShiftRng::seed_from_u64(seed),
RngSeed::FullHexEncodedSeed(seed) => XorShiftRng::from_seed(seed.try_into().expect("Invalid seed length provided. XorShiftRng uses a 16-byte seed")),
};
TestRngImpl::XorShift(rng)
}
RngAlgorithm::ChaCha => {
let rng = match seed {
RngSeed::Random => ChaChaRng::from_entropy(),
RngSeed::Fixed(seed) => ChaChaRng::seed_from_u64(seed),
RngSeed::FullHexEncodedSeed(seed) => ChaChaRng::from_seed(seed.try_into().expect("Invalid seed length provided. ChaChaRng uses a 16-byte seed")),
};
TestRngImpl::ChaCha(rng)
}
Expand All @@ -454,6 +434,7 @@ impl TestRng {
let rng = match seed {
RngSeed::Random => ChaChaRng::from_entropy(),
RngSeed::Fixed(seed) => ChaChaRng::seed_from_u64(seed),
RngSeed::FullHexEncodedSeed(seed) => ChaChaRng::from_seed(seed.try_into().expect("Invalid seed length provided. ChaChaRng uses a 16-byte seed")),
};
TestRngImpl::Recorder {rng, record: Vec::new()}
},
Expand Down Expand Up @@ -654,6 +635,28 @@ impl TestRng {
}
}

pub(crate) fn to_base16(dst: &mut String, src: &[u8]) {
for byte in src {
dst.push_str(&format!("{:02x}", byte));
}
}

pub(crate) fn from_base16(dst: &mut [u8], src: &str) -> Option<()> {
if dst.len() * 2 != src.len() {
return None;
}

for (dst_byte, src_pair) in
dst.into_iter().zip(src.as_bytes().chunks(2))
{
*dst_byte =
u8::from_str_radix(str::from_utf8(src_pair).ok()?, 16)
.ok()?;
}

Some(())
}

#[cfg(test)]
mod test {
use crate::std_facade::Vec;
Expand Down
Loading