Skip to content

Commit 5978af7

Browse files
committed
fix: align generator contracts
1 parent 8e6e326 commit 5978af7

12 files changed

Lines changed: 699 additions & 70 deletions

File tree

README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ A structure-aware test case generator for Python pickle parsers and validators.
2323
- **Comprehensive Opcode Coverage**: Supports all standard pickle opcodes including FRAME, EXT, GLOBAL, etc.
2424
- **Parallel Generation**: Generate multiple pickle files concurrently
2525
- **Configurable Output**: Single file or batch generation modes
26-
- **Deterministic Fuzzing**: Optional seed-based generation for reproducibility
26+
- **Deterministic Fuzzing**: Optional seed-based generation for reproducibility, including seeded batch mode with deterministic per-sample fan-out
2727

2828
## Installation
2929

@@ -134,22 +134,28 @@ Options:
134134
--seed <SEED> Seed for reproducible generation
135135
--min-opcodes <MIN_OPCODES> Minimum opcodes to generate [default: 60]
136136
--max-opcodes <MAX_OPCODES> Maximum opcodes to generate [default: 300]
137-
--mutators <MUTATOR>... Enable mutators (all, bitflip, boundary, offbyone,
137+
--mutators <MUTATOR> Enable mutators (all, bitflip, boundary, offbyone,
138138
stringlen, character, memoindex, typeconfusion)
139139
--mutation-rate <MUTATION_RATE> Mutation probability 0.0-1.0 [default: 0.1]
140140
--unsafe-mutations Allow mutations that may produce invalid pickles
141141
--allow-ext Allow EXT* opcodes (requires extension registry)
142142
--allow-buffer Allow buffer opcodes (requires buffer support)
143+
--allow-persistent-ids Allow PERSID/BINPERSID opcodes (requires persistent_load support)
143144
-h, --help Print help
144145
-V, --version Print version
145146
```
146147
147148
**Special Opcodes:**
148149
- `--allow-ext`: Enables EXT1/EXT2/EXT4 opcodes. Only use if your unpickler has a configured extension registry, otherwise unpickling will fail.
149150
- `--allow-buffer`: Enables NEXT_BUFFER/READONLY_BUFFER opcodes. Only use if your unpickler has out-of-band buffer callbacks configured.
151+
- `--allow-persistent-ids`: Enables PERSID/BINPERSID opcodes. Only use if your unpickler provides a `persistent_load` callback.
150152
151153
By default, these opcodes are disabled to ensure generated pickles work with standard Python's `pickle` module without additional configuration.
152154
155+
Seeded batch mode derives a deterministic per-sample seed from the base `--seed`,
156+
so repeated runs reproduce the same corpus without collapsing every file to the
157+
same bytes.
158+
153159
## Python Bindings
154160
155161
`pickle-fuzzer` provides Python bindings for integration with Python-based fuzzing tools like Atheris.
@@ -173,16 +179,22 @@ gen = Generator(protocol=3)
173179

174180
# Generate a random pickle
175181
pickle_bytes = gen.generate()
182+
pickle_bytes = gen.generate(max_size=4096)
176183

177184
# Generate from fuzzer input (deterministic)
178185
fuzzer_data = b"some_fuzzer_input"
179186
pickle_bytes = gen.generate_from_bytes(fuzzer_data)
187+
pickle_bytes = gen.generate_from_bytes(fuzzer_data, max_size=4096)
180188

181189
# Configure generation
182190
gen.set_opcode_range(10, 50) # Control pickle complexity
183-
gen.reset() # Reset internal state
191+
gen = Generator(protocol=4, allow_persistent_ids=True) # Opt in to persistent IDs
184192
```
185193

194+
`generate()` and `generate_from_bytes()` reset internal generator state before
195+
each run, so repeated calls on the same `Generator` remain deterministic for the
196+
same seed or fuzzer input.
197+
186198
### Integration with Atheris
187199

188200
Use the `PickleMutator` class for structure-aware fuzzing:
@@ -196,7 +208,7 @@ mutator = PickleMutator(protocol=3)
196208

197209
@atheris.instrument_func
198210
def test_one_input(data: bytes):
199-
# Generate valid pickle from fuzzer input
211+
# Generate a valid pickle from fuzzer input within the requested budget
200212
pickle_bytes = mutator.mutate(data, max_size=10000)
201213

202214
try:

python/pickle_fuzzer/_native.pyi

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@
1616
from typing import Optional
1717

1818
class Generator:
19-
def __init__(self, protocol: int = 3, seed: Optional[int] = None) -> None: ...
20-
def generate(self) -> bytes: ...
21-
def generate_from_bytes(self, data: bytes) -> bytes: ...
19+
def __init__(
20+
self,
21+
protocol: int = 3,
22+
seed: Optional[int] = None,
23+
allow_persistent_ids: bool = False,
24+
) -> None: ...
25+
def generate(self, max_size: Optional[int] = None) -> bytes: ...
26+
def generate_from_bytes(self, data: bytes, max_size: Optional[int] = None) -> bytes: ...
2227
def set_opcode_range(self, min: int, max: int) -> None: ...
2328
def reset(self) -> None: ...

python/pickle_fuzzer/fuzzer.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# SPDX-License-Identifier: Apache-2.0
1616

1717
"""Atheris integration utilities for pickle-fuzzer fuzzing."""
18+
import pickle
1819
import sys
1920
from typing import Optional, Callable
2021

@@ -56,14 +57,17 @@ def __init__(self, protocol: int = 3, seed: Optional[int] = None):
5657
seed: optional seed for deterministic generation
5758
"""
5859
self.protocol = protocol
60+
self.seed = seed
5961
self.generator = Generator(protocol=protocol, seed=seed)
62+
self._fallback_pickle = pickle.dumps(None, protocol=protocol)
63+
self._minimum_fallback_pickle = pickle.dumps(None, protocol=0)
6064

6165
def mutate(self, data: bytes, max_size: int) -> bytes:
6266
"""mutate pickle data using structure-aware mutations.
6367
6468
uses the fuzzer-provided data as a seed for generating new pickle
65-
bytecode. the generated pickle will be valid according to the
66-
specified protocol version.
69+
bytecode. when max_size can accommodate a pickle for the configured
70+
protocol, the returned pickle remains structurally valid.
6771
6872
args:
6973
data: fuzzer-provided input data
@@ -73,14 +77,19 @@ def mutate(self, data: bytes, max_size: int) -> bytes:
7377
generated pickle bytecode
7478
"""
7579
try:
76-
# use fuzzer bytes to generate new pickle
77-
result = self.generator.generate_from_bytes(data)
78-
if len(result) <= max_size:
79-
return result
80-
# if too large, truncate to max_size (may be invalid)
81-
return result[:max_size]
82-
except Exception:
83-
# if generation fails, return original data
80+
return self.generator.generate_from_bytes(data, max_size=max_size)
81+
except Exception as exc:
82+
print(
83+
f"PickleMutator generation failed for protocol {self.protocol}: {exc}",
84+
file=sys.stderr,
85+
)
86+
87+
if len(self._fallback_pickle) <= max_size:
88+
return self._fallback_pickle
89+
if len(self._minimum_fallback_pickle) <= max_size:
90+
return self._minimum_fallback_pickle
91+
92+
# No valid pickle fits in the requested budget.
8493
return data[:max_size] if len(data) > max_size else data
8594

8695
def reset(self):

python/tests/test_fuzzer.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright 2025 Cisco Systems, Inc. and its affiliates
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
17+
import pickletools
18+
19+
from pickle_fuzzer.fuzzer import PickleMutator
20+
21+
22+
def assert_whole_pickle_consumed(data: bytes) -> None:
23+
stop_position = None
24+
for _, _, position in pickletools.genops(data):
25+
stop_position = position + 1
26+
assert stop_position is not None
27+
assert stop_position == len(data)
28+
29+
30+
def test_pickle_mutator_respects_max_size():
31+
mutator = PickleMutator(protocol=4, seed=123)
32+
33+
data = mutator.mutate(b"fuzzer_input_bytes", max_size=48)
34+
35+
assert len(data) <= 48
36+
assert_whole_pickle_consumed(data)
37+
38+
39+
def test_pickle_mutator_fallback_stays_valid(monkeypatch):
40+
mutator = PickleMutator(protocol=4, seed=123)
41+
42+
class FailingGenerator:
43+
def generate_from_bytes(self, *args, **kwargs):
44+
raise RuntimeError("boom")
45+
46+
monkeypatch.setattr(mutator, "generator", FailingGenerator())
47+
48+
data = mutator.mutate(b"fuzzer_input_bytes", max_size=32)
49+
50+
assert len(data) <= 32
51+
assert_whole_pickle_consumed(data)

python/tests/test_generation.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def test_basic_generation():
2323
assert len(data) > 0
2424
assert data[-1] == ord(".")
2525

26+
2627
def test_deterministic_generation():
2728
gen1 = pickle_fuzzer.Generator(protocol=3, seed=42)
2829
gen2 = pickle_fuzzer.Generator(protocol=3, seed=42)
@@ -32,13 +33,30 @@ def test_deterministic_generation():
3233

3334
assert data1 == data2
3435

36+
37+
def test_set_opcode_range_preserves_seeded_determinism():
38+
gen1 = pickle_fuzzer.Generator(protocol=3, seed=42)
39+
gen2 = pickle_fuzzer.Generator(protocol=3, seed=42)
40+
41+
gen1.set_opcode_range(50, 10)
42+
gen2.set_opcode_range(50, 10)
43+
44+
assert gen1.generate() == gen2.generate()
45+
46+
3547
def test_generate_from_bytes():
3648
gen = pickle_fuzzer.Generator(protocol=3)
3749
fuzzer_input = b"test_fuzzer_input_bytes"
3850

3951
data1 = gen.generate_from_bytes(fuzzer_input)
40-
gen.reset()
4152
data2 = gen.generate_from_bytes(fuzzer_input)
4253

4354
assert data1 == data2 # same input bytes means same output
4455

56+
57+
def test_generate_from_bytes_respects_max_size():
58+
gen = pickle_fuzzer.Generator(protocol=4, seed=123)
59+
data = gen.generate_from_bytes(b"test_fuzzer_input_bytes", max_size=32)
60+
61+
assert len(data) <= 32
62+
assert data[-1] == ord(".")

src/cli.rs

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
// See the License for the specific language governing permissions and
1515
// limitations under the License.
1616

17-
use std::path::PathBuf;
17+
use std::{ffi::OsString, path::PathBuf};
1818

19-
use clap::Parser;
19+
use clap::{Parser, ValueEnum};
2020

2121
/// Parse and validate a pickle protocol version string.
2222
///
@@ -32,6 +32,50 @@ fn parse_version(s: &str) -> Result<usize, String> {
3232
}
3333
}
3434

35+
fn normalize_mutator_args<I>(args: I) -> Vec<OsString>
36+
where
37+
I: IntoIterator<Item = OsString>,
38+
{
39+
let args: Vec<OsString> = args.into_iter().collect();
40+
let mut normalized = Vec::with_capacity(args.len());
41+
let mut idx = 0;
42+
43+
while idx < args.len() {
44+
let arg = &args[idx];
45+
if arg == "--mutators" {
46+
normalized.push(arg.clone());
47+
idx += 1;
48+
49+
let mut saw_mutator = false;
50+
while idx < args.len() {
51+
let Some(value) = args[idx].to_str() else {
52+
break;
53+
};
54+
if value == "--" || value.starts_with('-') {
55+
break;
56+
}
57+
if crate::mutators::MutatorKind::from_str(value, true).is_err() {
58+
break;
59+
}
60+
61+
if saw_mutator {
62+
normalized.push(OsString::from("--mutators"));
63+
}
64+
normalized.push(args[idx].clone());
65+
saw_mutator = true;
66+
idx += 1;
67+
}
68+
69+
continue;
70+
}
71+
72+
normalized.push(arg.clone());
73+
idx += 1;
74+
}
75+
76+
normalized
77+
}
78+
3579
/// Command-line interface for pickle-fuzzer.
3680
///
3781
/// Supports two modes:
@@ -68,7 +112,7 @@ pub struct Cli {
68112
#[arg(short, long, default_value_t = 10_000, requires = "dir")]
69113
pub samples: usize,
70114

71-
/// seed for random number generator (for reproducible, byte-identical generation)
115+
/// seed for reproducible generation
72116
#[arg(long)]
73117
pub seed: Option<u64>,
74118

@@ -80,8 +124,12 @@ pub struct Cli {
80124
#[arg(long, default_value_t = 300)]
81125
pub max_opcodes: usize,
82126

83-
/// enable specific mutators (can be specified multiple times)
84-
#[arg(long = "mutators", value_name = "MUTATOR", num_args = 1..)]
127+
/// enable specific mutators (repeat the flag or list mutators after one occurrence)
128+
#[arg(
129+
long = "mutators",
130+
value_name = "MUTATOR",
131+
action = clap::ArgAction::Append
132+
)]
85133
pub mutators: Vec<crate::mutators::MutatorKind>,
86134

87135
/// mutation rate (0.0-1.0, probability of applying mutation)
@@ -99,9 +147,17 @@ pub struct Cli {
99147
/// allow NEXT_BUFFER/READONLY_BUFFER opcodes (requires out-of-band buffer support in unpickler)
100148
#[arg(long)]
101149
pub allow_buffer: bool,
150+
151+
/// allow PERSID/BINPERSID opcodes (requires persistent_load support in unpickler)
152+
#[arg(long)]
153+
pub allow_persistent_ids: bool,
102154
}
103155

104156
impl Cli {
157+
pub fn parse_args() -> Self {
158+
Self::parse_from(normalize_mutator_args(std::env::args_os()))
159+
}
160+
105161
/// Check if running in batch mode (generating multiple files).
106162
pub fn is_batch_mode(&self) -> bool {
107163
self.dir.is_some()
@@ -154,6 +210,7 @@ mod tests {
154210
unsafe_mutations: false,
155211
allow_ext: false,
156212
allow_buffer: false,
213+
allow_persistent_ids: false,
157214
};
158215

159216
assert!(cli_single.is_single_file_mode());
@@ -172,9 +229,33 @@ mod tests {
172229
unsafe_mutations: false,
173230
allow_ext: false,
174231
allow_buffer: false,
232+
allow_persistent_ids: false,
175233
};
176234

177235
assert!(!cli_batch.is_single_file_mode());
178236
assert!(cli_batch.is_batch_mode());
179237
}
238+
239+
#[test]
240+
fn test_normalize_mutator_args_keeps_output_path_positional() {
241+
let normalized = normalize_mutator_args([
242+
OsString::from("pickle-fuzzer"),
243+
OsString::from("--mutators"),
244+
OsString::from("bitflip"),
245+
OsString::from("boundary"),
246+
OsString::from("output.pkl"),
247+
]);
248+
249+
assert_eq!(
250+
normalized,
251+
vec![
252+
OsString::from("pickle-fuzzer"),
253+
OsString::from("--mutators"),
254+
OsString::from("bitflip"),
255+
OsString::from("--mutators"),
256+
OsString::from("boundary"),
257+
OsString::from("output.pkl"),
258+
]
259+
);
260+
}
180261
}

0 commit comments

Comments
 (0)