Skip to content

Commit 395a0d5

Browse files
authored
fix: restore strict python validation (#15)
1 parent e8d6752 commit 395a0d5

4 files changed

Lines changed: 65 additions & 26 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ cargo fuzz run validate_with_python
233233
### Available Fuzz Targets
234234

235235
- **`all_protocols`**: Fast fuzzing of all protocols (0-5) with structural validation (~5000-10000 execs/sec)
236-
- **`validate_with_python`**: Comprehensive validation with Python's `pickletools.genops()` (same logic as `scripts/validate-pickles.py`) including mutation testing (~100-500 execs/sec)
236+
- **`validate_with_python`**: Comprehensive validation with Python's `pickletools.dis()` plus a whole-file STOP boundary check (same logic as `scripts/validate-pickles.py`) including mutation testing (~100-500 execs/sec)
237237

238238
### Recommended Workflow
239239

fuzz/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ cargo fuzz run all_protocols -- -max_total_time=3600
3737
- Opcode emission logic
3838

3939
### 2. `validate_with_python` - Thorough Python Validation
40-
**Purpose**: Comprehensive validation with Python's `pickletools.genops()`
41-
**Validation**: Full structural validation via Python interpreter (uses same logic as `scripts/validate-pickles.py`)
40+
**Purpose**: Comprehensive validation with Python's `pickletools.dis()` plus a whole-file STOP boundary check
41+
**Validation**: Full structural validation via Python interpreter with no trailing bytes after STOP (uses same logic as `scripts/validate-pickles.py`)
4242
**Speed**: ~100-500 execs/sec (subprocess overhead)
4343
**Use**: Ensuring generated pickles are structurally valid and parseable by Python
4444

@@ -51,7 +51,7 @@ cargo fuzz run validate_with_python -- -max_total_time=1800
5151
- Opcode range configuration (min/max opcodes)
5252
- Mutation system with all mutators
5353
- Mutation rate configuration
54-
- Python compatibility via `pickletools.genops()` validation
54+
- Python compatibility via strict whole-file `pickletools` validation
5555

5656
**Note**: This target spawns Python subprocesses to validate each generated pickle using the same validation logic as `scripts/validate-pickles.py`.
5757

fuzz/fuzz_targets/validate_with_python.rs

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424
//! - arbitrary data seed for generation
2525
//!
2626
//! the generated pickles are validated using Python's `pickletools.dis()`
27-
//! to ensure they are structurally valid and can be parsed by the reference
28-
//! implementation. this catches bugs in:
27+
//! plus a whole-file STOP boundary check to ensure they are structurally
28+
//! valid, fully consumed, and parseable by the reference implementation.
29+
//! this catches bugs in:
2930
//! - opcode emission logic
3031
//! - stack simulation
3132
//! - protocol version handling
@@ -52,39 +53,54 @@
5253
//! Generated pickles must:
5354
//! 1. be non-empty
5455
//! 2. end with STOP opcode (0x2e / '.')
55-
//! 3. successfully validate with `pickletools.dis()` which checks:
56+
//! 3. successfully validate with Python, which checks:
5657
//! - all opcodes parse correctly
5758
//! - stack has exactly 1 item before STOP
59+
//! - no trailing bytes remain after STOP
5860
//! - no invalid operations (via Python subprocess)
5961
#![no_main]
6062

6163
use libfuzzer_sys::fuzz_target;
62-
use pickle_fuzzer::{Generator, Version};
6364
use pickle_fuzzer::mutators::{
64-
BitFlipMutator, BoundaryMutator, OffByOneMutator,
65-
StringLengthMutator, CharacterMutator,
65+
BitFlipMutator, BoundaryMutator, CharacterMutator, OffByOneMutator, StringLengthMutator,
6666
};
67-
use std::process::{Command, Stdio};
67+
use pickle_fuzzer::{Generator, Version};
6868
use std::io::Write;
69+
use std::process::{Command, Stdio};
70+
71+
const STRICT_PICKLETOOLS_VALIDATOR: &str = r#"import io
72+
import pickletools
73+
import sys
74+
75+
data = sys.stdin.buffer.read()
76+
stop_pos = None
77+
for _opcode, _arg, pos in pickletools.genops(data):
78+
stop_pos = pos
79+
if stop_pos is None:
80+
raise ValueError("pickle exhausted before seeing STOP")
81+
if stop_pos + 1 != len(data):
82+
raise ValueError(f"trailing bytes after STOP: {len(data) - (stop_pos + 1)}")
83+
pickletools.dis(data, out=io.StringIO())
84+
"#;
6985

70-
/// validate pickle using Python's pickletools.dis() which checks stack state after STOP
86+
/// validate pickle using Python's pickletools plus a whole-file STOP check
7187
fn validate_with_python(pickle_bytes: &[u8]) -> bool {
7288
let mut child = match Command::new("python3")
7389
.arg("-c")
74-
.arg("import sys, pickletools, io; pickletools.dis(sys.stdin.buffer.read(), out=io.StringIO())")
90+
.arg(STRICT_PICKLETOOLS_VALIDATOR)
7591
.stdin(Stdio::piped())
7692
.stdout(Stdio::null())
7793
.stderr(Stdio::piped())
7894
.spawn()
7995
{
8096
Ok(child) => child,
81-
Err(_) => return true, // Skip validation if Python unavailable
97+
Err(err) => panic!("validate_with_python fuzz target requires python3 on PATH: {err}"),
8298
};
83-
99+
84100
if let Some(mut stdin) = child.stdin.take() {
85101
let _ = stdin.write_all(pickle_bytes);
86102
}
87-
103+
88104
let output = child.wait_with_output().unwrap();
89105
output.status.success()
90106
}
@@ -98,15 +114,15 @@ fuzz_target!(|data: &[u8]| {
98114
// byte 0: protocol version (0-5)
99115
let protocol = (data[0] % 6) as usize;
100116
let version = Version::try_from(protocol).unwrap();
101-
117+
102118
let gen = Generator::new(version);
103119

104120
// bytes 1-4: opcode range (min/max)
105121
let min_opcodes = u16::from_le_bytes([data[1], data[2]]) as usize;
106122
let max_opcodes = u16::from_le_bytes([data[3], data[4]]) as usize;
107-
123+
108124
// ensure valid range and cap at 1000 opcodes to prevent stack overflow
109-
let mut gen = if min_opcodes > max_opcodes {
125+
let mut gen = if min_opcodes > max_opcodes {
110126
if min_opcodes > 1000 {
111127
return;
112128
}
@@ -148,8 +164,12 @@ fuzz_target!(|data: &[u8]| {
148164
if let Ok(pickle) = gen.generate_from_arbitrary(&data[7..]) {
149165
// basic structural validation
150166
assert!(!pickle.is_empty(), "generated pickle must not be empty");
151-
assert_eq!(pickle[pickle.len() - 1], b'.', "pickle must end with STOP opcode");
152-
167+
assert_eq!(
168+
pickle[pickle.len() - 1],
169+
b'.',
170+
"pickle must end with STOP opcode"
171+
);
172+
153173
// validate with Python's pickletools
154174
assert!(
155175
validate_with_python(&pickle),

scripts/validate-pickles.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,28 @@
3131
DEFAULT_EXTENSIONS: tuple[str, ...] = (".pkl", ".pickle")
3232

3333

34+
def is_confined_to_root(path: Path, root: Path) -> bool:
35+
try:
36+
path.resolve().relative_to(root)
37+
except (OSError, RuntimeError, ValueError):
38+
return False
39+
return True
40+
41+
3442
def iter_pickles(paths: Sequence[str], extensions: Sequence[str]) -> Iterable[Path]:
3543
exts = tuple(ext.lower() for ext in extensions)
3644
for raw in paths:
3745
path = Path(raw)
3846
if not path.exists():
3947
raise FileNotFoundError(f"Path not found: {path}")
4048
if path.is_dir():
49+
root = path.resolve()
4150
yield from (
4251
child
4352
for child in path.rglob("*")
44-
if child.is_file() and child.suffix.lower() in exts
53+
if child.is_file()
54+
and child.suffix.lower() in exts
55+
and is_confined_to_root(child, root)
4556
)
4657
elif path.is_file():
4758
if path.suffix.lower() in exts:
@@ -52,14 +63,21 @@ def iter_pickles(paths: Sequence[str], extensions: Sequence[str]) -> Iterable[Pa
5263

5364
def validate_pickle(data: bytes) -> tuple[bool, str | None]:
5465
try:
55-
for _opcode, _arg, _pos in pickletools.genops(data):
56-
pass
66+
disassemble(data)
5767
except Exception as exc: # pragma: no cover - defensive
5868
return False, str(exc)
5969
return True, None
6070

6171

6272
def disassemble(data: bytes) -> str:
73+
stop_pos = None
74+
for _opcode, _arg, pos in pickletools.genops(data):
75+
stop_pos = pos
76+
if stop_pos is None:
77+
raise ValueError("pickle exhausted before seeing STOP")
78+
if stop_pos + 1 != len(data):
79+
raise ValueError(f"trailing bytes after STOP: {len(data) - (stop_pos + 1)}")
80+
6381
buffer = io.StringIO()
6482
pickletools.dis(data, out=buffer)
6583
return buffer.getvalue()
@@ -79,7 +97,7 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
7997
"--extension",
8098
dest="extensions",
8199
action="append",
82-
default=list(DEFAULT_EXTENSIONS),
100+
default=None,
83101
help="File extension to include (default: .pkl, .pickle). Specify multiple times.",
84102
)
85103
parser.add_argument(
@@ -98,7 +116,8 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace:
98116

99117
def main(argv: Sequence[str] | None = None) -> int:
100118
ns = parse_args(sys.argv[1:] if argv is None else argv)
101-
paths = sorted(set(iter_pickles(ns.paths, ns.extensions)))
119+
extensions = ns.extensions if ns.extensions is not None else DEFAULT_EXTENSIONS
120+
paths = sorted(set(iter_pickles(ns.paths, extensions)))
102121

103122
if not paths:
104123
print("No pickle files matched the provided paths and extensions.", file=sys.stderr)

0 commit comments

Comments
 (0)