Fix marshal recursive reference loading - #8501
Conversation
Create reference-tracked containers before reading their children so recursive list, dict, set, and tuple graphs can be unmarshaled. Preserve interned string markers through the runtime bag and add an initialization-only tuple construction path. Assisted-by: Codex:gpt-5
📝 WalkthroughWalkthroughMarshal deserialization now supports interned strings and recursive container references. ChangesMarshal recursive container decoding
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant loads/load
participant deserialize_value
participant PyMarshalBag
participant ReferenceTable
participant PyTuple_or_Container
loads/load->>deserialize_value: decode marshal input
deserialize_value->>PyMarshalBag: create placeholder
PyMarshalBag->>PyTuple_or_Container: allocate recursive container
deserialize_value->>ReferenceTable: register reserved slot
deserialize_value->>deserialize_value: decode child values
deserialize_value->>PyMarshalBag: set or insert child
PyMarshalBag->>PyTuple_or_Container: mutate placeholder
deserialize_value-->>loads/load: value or Python exception
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
📦 Library DependenciesThe following Lib/ modules were modified. Here are their dependencies: [ ] test: cpython/Lib/test/test_marshal.py (TODO: 8) dependencies: dependent tests: (25 tests)
Legend:
|
Keep Python exceptions raised while constructing unmarshaled sets, frozensets, and dictionaries instead of collapsing them into ValueError. This makes abnormal recursive hash-container streams report TypeError like CPython and removes the remaining test_marshal expected failure. Assisted-by: Codex:gpt-5
Assisted-by: Codex:gpt-5
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
crates/compiler-core/src/marshal.rs (3)
962-988: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider unifying the two dict read loops.
Both branches read the terminator byte, the key, and the value with identical code. Only the final action differs. You can read the pairs once and choose the action with a small closure or by collecting into the placeholder when it exists.
This keeps the terminator handling in one place, so a future change to the
b'0'sentinel cannot diverge between the two paths.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/compiler-core/src/marshal.rs` around lines 962 - 988, Unify the duplicated dictionary-deserialization loops in the surrounding function by reading the terminator, key, and value once, then either inserting into the existing placeholder or collecting pairs for bag.make_dict. Preserve placeholder registration in refs and the existing b'0' termination behavior.
859-878: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider collapsing the four string arms.
The four arms differ only in the length width and in the interned flag. You can extract those two values and call one shared path.
The coding guidelines require this pattern: "When branches differ only in a value but share common logic, extract the differing value and call the common logic once."
♻️ Proposed refactor
- Type::Ascii | Type::Unicode => { - let len = rdr.read_u32()?; - let value = rdr.read_wtf8(len)?; - bag.make_str(value) - } - Type::AsciiInterned | Type::Interned => { - let len = rdr.read_u32()?; - let value = rdr.read_wtf8(len)?; - bag.make_interned_str(value) - } - Type::ShortAscii => { - let len = rdr.read_u8()? as u32; - let value = rdr.read_wtf8(len)?; - bag.make_str(value) - } - Type::ShortAsciiInterned => { - let len = rdr.read_u8()? as u32; - let value = rdr.read_wtf8(len)?; - bag.make_interned_str(value) - } + Type::Ascii + | Type::Unicode + | Type::AsciiInterned + | Type::Interned + | Type::ShortAscii + | Type::ShortAsciiInterned => { + let short = matches!(typ, Type::ShortAscii | Type::ShortAsciiInterned); + let interned = matches!( + typ, + Type::AsciiInterned | Type::Interned | Type::ShortAsciiInterned + ); + let len = if short { + rdr.read_u8()? as u32 + } else { + rdr.read_u32()? + }; + let value = rdr.read_wtf8(len)?; + if interned { + bag.make_interned_str(value) + } else { + bag.make_str(value) + } + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/compiler-core/src/marshal.rs` around lines 859 - 878, Refactor the string-handling match arms in the unmarshalling logic to extract the length width and interned flag, then run the shared read_wtf8 and bag construction path once. Preserve u32 lengths for Type::Ascii and Type::Unicode, u8 lengths for Type::ShortAscii variants, and route interned types through bag.make_interned_str while other types use bag.make_str.Source: Coding guidelines
879-894: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the shared tuple decoding path.
The
Type::SmallTupleandType::Tuplearms are identical except for the length read width. Extract a helper that takeslenand performs the placeholder-or-collect logic once.The coding guidelines require this pattern: "When branches differ only in a value but share common logic, extract the differing value and call the common logic once."
♻️ Sketch of the shared helper
fn read_tuple_body<R: Read, Bag: MarshalBag>( rdr: &mut R, bag: Bag, depth: usize, refs: &mut Vec<Option<Bag::Value>>, slot: Option<usize>, len: usize, ) -> Result<Bag::Value> { let d = depth - 1; if let Some(index) = slot && let Some(tuple) = bag.make_tuple_placeholder(len) { refs[index] = Some(tuple.clone()); for item_index in 0..len { let item = deserialize_value_depth(rdr, bag, d, refs)?; bag.set_tuple_item(&tuple, item_index, item)?; } Ok(tuple) } else { let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); itertools::process_results(it, |it| bag.make_tuple(it)) } }Then each arm reads its length and calls the helper.
Also applies to: 903-919
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/compiler-core/src/marshal.rs` around lines 879 - 894, Extract the duplicated tuple decoding logic from the Type::SmallTuple and Type::Tuple arms into a shared read_tuple_body helper that accepts the already-read len and preserves the placeholder, refs, recursive item decoding, and collected tuple paths. Have each arm only read its length using its respective width, then call the helper and return its Result without duplicating the body.Source: Coding guidelines
crates/vm/src/stdlib/marshal.rs (1)
479-490: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winReplace the panicking index with a checked write.
borrow_vec_mut()[index] = valuepanics ifindexis out of range. The current decoder always calls this withindexfrom0..len, andlenmatches the placeholder length, so the panic is unreachable today.This decoder processes untrusted bytes. A panic aborts the interpreter instead of raising a Python exception. A checked write converts any future contract drift into a
BadTypemarshal error, whichdeserialize_valuealready maps toValueError("bad marshal data").🛡️ Proposed change
let list = list .downcast_ref::<PyList>() .ok_or(marshal::MarshalError::BadType)?; - list.borrow_vec_mut()[index] = value; - Ok(()) + *list + .borrow_vec_mut() + .get_mut(index) + .ok_or(marshal::MarshalError::BadType)? = value; + Ok(())🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/vm/src/stdlib/marshal.rs` around lines 479 - 490, Update set_list_item to perform a checked write when assigning the decoded value, returning marshal::MarshalError::BadType if index is outside the list bounds instead of panicking. Preserve the existing PyList downcast validation and successful in-range assignment behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/compiler-core/src/marshal.rs`:
- Around line 920-935: Bound decoded list and tuple lengths in the marshal
deserialization logic before calling make_list_placeholder or allocating tuple
storage. Validate each u32-derived length against the established safe decode
budget, returning Eof for oversized or truncated container payloads while
preserving normal decoding for valid lengths.
---
Nitpick comments:
In `@crates/compiler-core/src/marshal.rs`:
- Around line 962-988: Unify the duplicated dictionary-deserialization loops in
the surrounding function by reading the terminator, key, and value once, then
either inserting into the existing placeholder or collecting pairs for
bag.make_dict. Preserve placeholder registration in refs and the existing b'0'
termination behavior.
- Around line 859-878: Refactor the string-handling match arms in the
unmarshalling logic to extract the length width and interned flag, then run the
shared read_wtf8 and bag construction path once. Preserve u32 lengths for
Type::Ascii and Type::Unicode, u8 lengths for Type::ShortAscii variants, and
route interned types through bag.make_interned_str while other types use
bag.make_str.
- Around line 879-894: Extract the duplicated tuple decoding logic from the
Type::SmallTuple and Type::Tuple arms into a shared read_tuple_body helper that
accepts the already-read len and preserves the placeholder, refs, recursive item
decoding, and collected tuple paths. Have each arm only read its length using
its respective width, then call the helper and return its Result without
duplicating the body.
In `@crates/vm/src/stdlib/marshal.rs`:
- Around line 479-490: Update set_list_item to perform a checked write when
assigning the decoded value, returning marshal::MarshalError::BadType if index
is outside the list bounds instead of panicking. Preserve the existing PyList
downcast validation and successful in-range assignment behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro Plus
Run ID: 3be0b8b5-4ba3-429d-903e-c38707d6c04f
⛔ Files ignored due to path filters (1)
Lib/test/test_marshal.pyis excluded by!Lib/**
📒 Files selected for processing (3)
crates/compiler-core/src/marshal.rscrates/vm/src/builtins/tuple.rscrates/vm/src/stdlib/marshal.rs
| Type::List => { | ||
| let len = rdr.read_u32()?; | ||
| let len = rdr.read_u32()? as usize; | ||
| let d = depth - 1; | ||
| let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); | ||
| itertools::process_results(it, |it| bag.make_list(it))?? | ||
| if let Some(index) = slot | ||
| && let Some(list) = bag.make_list_placeholder(len) | ||
| { | ||
| refs[index] = Some(list.clone()); | ||
| for item_index in 0..len { | ||
| let item = deserialize_value_depth(rdr, bag, d, refs)?; | ||
| bag.set_list_item(&list, item_index, item)?; | ||
| } | ||
| list | ||
| } else { | ||
| let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); | ||
| itertools::process_results(it, |it| bag.make_list(it))?? | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check for existing length guards in the marshal decoder and for documented untrusted-input policy.
rg -n -C4 'read_u32|MAX_MARSHAL|too (large|long)|InvalidBytecode' crates/compiler-core/src/marshal.rs | head -80
# Look for any documented stance on untrusted marshal data.
rg -rn -i -C3 'untrusted|malicious' --glob '*.rs' --glob '*.md' | rg -i marshal | head -40Repository: RustPython/RustPython
Length of output: 3074
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- marshal structure and placeholder implementations ---'
ast-grep outline crates/compiler-core/src/marshal.rs
rg -n -C8 'make_(list|tuple)_placeholder|fn deserialize_value|marshal|loads|MAX_MARSHAL_STACK_DEPTH' crates/compiler-core/src/marshal.rs crates -g '*.rs' | head -240
printf '%s\n' '--- list and tuple construction semantics ---'
rg -n -C6 'make_list_placeholder|make_tuple_placeholder|vec!\[none|with_capacity' crates -g '*.rs' | head -180Repository: RustPython/RustPython
Length of output: 32918
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- decoder branches ---'
sed -n '448,527p' crates/compiler-core/src/marshal.rs
sed -n '736,935p' crates/compiler-core/src/marshal.rs
printf '%s\n' '--- MarshalBag implementations and runtime allocation ---'
rg -n -C10 'impl .*MarshalBag|make_tuple_placeholder|make_list_placeholder|set_tuple_item|set_list_item' crates -g '*.rs'Repository: RustPython/RustPython
Length of output: 21839
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- marshal Python entry points and documentation ---'
rg -n -C8 'deserialize_value|deserialize_code|marshal::|loads|load\(' crates/vm/src/stdlib/marshal.rs crates/vm/src -g '*.rs' | head -260
rg -rn -i -C4 'marshal.*(trusted|untrusted|security|warning|unsafe)|untrusted.*marshal|marshal.*not.*secure|not.*secure.*marshal' . -g '*.rs' -g '*.md' -g '*.rst' | head -160
printf '%s\n' '--- allocation and error behavior relevant to the claimed failure ---'
sed -n '100,190p' crates/compiler-core/src/marshal.rs
sed -n '390,510p' crates/vm/src/stdlib/marshal.rs
rg -n -C5 'new_marshal_placeholder|struct PyList|struct PyTuple|pub fn new_list|pub fn new_tuple' crates/vm/src crates -g '*.rs' | head -220Repository: RustPython/RustPython
Length of output: 37737
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- complete marshal load implementation ---'
rg -n 'fn (load|loads)|deserialize_value|deserialize_code|MarshalError' crates/vm/src/stdlib/marshal.rs
sed -n '550,760p' crates/vm/src/stdlib/marshal.rs
printf '%s\n' '--- placeholder constructors ---'
sed -n '25,75p' crates/vm/src/builtins/tuple.rs
sed -n '330,365p' crates/vm/src/builtins/tuple.rs
rg -n -C5 'pub struct PyList|impl From<Vec|borrow_vec_mut|fn from' crates/vm/src/builtins/list.rs | head -140Repository: RustPython/RustPython
Length of output: 14561
🏁 Script executed:
#!/bin/bash
set -e
python3 - <<'PY'
import pathlib, struct
marshal = pathlib.Path("crates/compiler-core/src/marshal.rs").read_text()
vm_marshal = pathlib.Path("crates/vm/src/stdlib/marshal.rs").read_text()
assert "let len = rdr.read_u32()? as usize;" in marshal
assert "make_list_placeholder(len)" in marshal
assert "make_tuple_placeholder(len)" in marshal
assert "vec![self.vm.ctx.none(); len]" in vm_marshal
assert "fn loads(" in vm_marshal
assert "deserialize_value(&mut &buf[..], vm)" in vm_marshal
payload = b"[" + struct.pack("<I", 0xFFFF_FFFF)
length = struct.unpack_from("<I", payload, 1)[0]
print(f"TYPE_LIST payload length field: {length}")
print("placeholder allocation occurs before child decoding: true")
print("u32 length guard in the inspected decoder: false")
print("marshal.loads reaches the inspected decoder: true")
PYRepository: RustPython/RustPython
Length of output: 359
🌐 Web query:
Python marshal documentation warning untrusted data marshal.loads malicious data
💡 Result:
The Python documentation explicitly warns that the marshal module is not intended to be secure against erroneous or maliciously constructed data [1][2]. You should never use marshal.loads to process data received from an untrusted or unauthenticated source [1][3]. The reasons for this security warning include: 1. Internal Design: The marshal module was not designed as a general-purpose persistence or data interchange format [4][5]. Its primary purpose is to support the reading and writing of pseudo-compiled code for Python modules (.pyc files) [4][5]. 2. Lack of Security Audits: The C code responsible for unmarshalling data has not been thoroughly analyzed or audited for security vulnerabilities such as buffer overflows [4][5]. Because it was not written with security in mind, it may be susceptible to hypothetical exploits similar to those historically found in complex file format parsers [4][5]. 3. Potential for Crashes and Hangs: Maliciously crafted marshal data can trigger significant issues, including segmentation faults (crashes) or denial-of-service conditions (e.g., causing the interpreter to hang or consume excessive resources) [4][5][6]. 4. No Security Promises: Python maintainers explicitly state that they cannot make guarantees about the safety or security of the marshal data format, as it is not fuzz-tested for security and lacks the rigorous hardening expected of tools meant for untrusted input [6]. In summary, because marshal lacks the necessary security safeguards, it should be treated similarly to pickle: it is safe only when used with strictly trusted data [4][6]. If your application must handle external or untrusted data, safer formats such as JSON should be used instead [4][5].
Citations:
- 1: https://docs.python.org/3/library/marshal.html
- 2: https://docs.python.org/3.11/library/marshal.html
- 3: https://github.com/python/cpython/blob/main/Doc/library/marshal.rst
- 4: https://stackoverflow.com/questions/26931919/marshal-unserialization-not-secure
- 5: https://www.arp242.net/pickle-marshal-security.html
- 6: Safer data serialization with marshal module python/cpython#113626
Bound marshal container lengths before placeholder allocation
marshal.loads passes untrusted bytes to this decoder. List and tuple lengths are converted from u32 without a limit, and runtime placeholders allocate all elements before reading children. A truncated payload can therefore trigger excessive allocation or process termination instead of returning Eof. Reject lengths that exceed a safe decode budget before creating list or tuple placeholders.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/compiler-core/src/marshal.rs` around lines 920 - 935, Bound decoded
list and tuple lengths in the marshal deserialization logic before calling
make_list_placeholder or allocating tuple storage. Validate each u32-derived
length against the established safe decode budget, returning Eof for oversized
or truncated container payloads while preserving normal decoding for valid
lengths.
Summary
Create reference-tracked containers before unmarshalling their children, matching CPython's
r_object()construction order. This restores recursive list and dict graphs as well as indirect tuple cycles.The compiler-core decoder exposes optional placeholder/fill hooks, while the VM bag supplies RustPython object construction. Tuple storage gains an initialization-only mutation path corresponding to
PyTuple_Newfollowed byPyTuple_SET_ITEM; tuples remain immutable after decoding. Interned marshal string markers are also preserved through the runtime bag.Python exceptions raised while inserting unmarshaled set and dictionary members are retained in a call-local pending-error slot. Abnormal self-referential hash containers therefore raise
TypeErrorlike CPython instead of being collapsed into a genericValueError; immutable reference loops continue to raiseValueError. This also removes the remainingtest_loads_abnormal_reference_loopsexpected failure and replaces insertion-pathunwrap()calls with ordinary error propagation.AI assistance disclosure: Codex (gpt-5) assisted with implementation, test execution, and drafting this pull request. The changes were exercised with the RustPython interpreter and full project test commands listed below.
Testing
cargo fmt --checkprek run --all-filescargo run --release -- -m test test_marshalcargo test -p rustpython-compiler-core -p rustpython-vmcargo clippy -p rustpython-compiler-core -p rustpython-vm --all-targetscargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi(cd crates/capi && cargo test)cargo check --offline -p pyre-interpreter --features dynasm.Summary by CodeRabbit
New Features
Bug Fixes
loadandloadsprovide consistent handling for malformed data and unexpected end-of-file conditions.