Skip to content

Fix marshal recursive reference loading - #8501

Merged
youknowone merged 4 commits into
RustPython:mainfrom
youknowone:fix/marshal-recursive-references
Aug 12, 2026
Merged

Fix marshal recursive reference loading#8501
youknowone merged 4 commits into
RustPython:mainfrom
youknowone:fix/marshal-recursive-references

Conversation

@youknowone

@youknowone youknowone commented Aug 12, 2026

Copy link
Copy Markdown
Member

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_New followed by PyTuple_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 TypeError like CPython instead of being collapsed into a generic ValueError; immutable reference loops continue to raise ValueError. This also removes the remaining test_loads_abnormal_reference_loops expected failure and replaces insertion-path unwrap() 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 --check
  • prek run --all-files
  • cargo run --release -- -m test test_marshal
  • Direct CPython 3.14-compatible exception checks for malformed recursive dict, set, slice, and frozenset streams
  • cargo test -p rustpython-compiler-core -p rustpython-vm
  • cargo clippy -p rustpython-compiler-core -p rustpython-vm --all-targets
  • cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi
  • (cd crates/capi && cargo test)
  • Consumed the patched compiler-core from Pyre via a workspace-wide Cargo source override and ran cargo check --offline -p pyre-interpreter --features dynasm.

Summary by CodeRabbit

  • New Features

    • Marshal deserialization now supports recursive and forward-referenced tuples, lists, sets, and dictionaries.
    • Interned strings are handled during deserialization.
  • Bug Fixes

    • Errors raised while building sets and dictionaries are now preserved correctly.
    • load and loads provide consistent handling for malformed data and unexpected end-of-file conditions.

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
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Marshal deserialization now supports interned strings and recursive container references. PyTuple uses mutable initialization storage for decoding placeholders. VM marshal loading preserves construction exceptions and shares error handling across loads and load.

Changes

Marshal recursive container decoding

Layer / File(s) Summary
Marshal contracts and recursive decoding
crates/compiler-core/src/marshal.rs
MarshalBag now defines interned-string and container-placeholder hooks. Deserialization passes reference slots, registers placeholders, and incrementally populates recursive tuples, lists, sets, and dictionaries.
Tuple placeholder storage
crates/vm/src/builtins/tuple.rs
PyTuple now stores elements through TupleElements and supports marshal-specific placeholder initialization. Tuple access, construction, repetition, concatenation, and traversal use the new storage.
VM marshal integration and error propagation
crates/vm/src/stdlib/marshal.rs
PyMarshalBag implements interned-string and recursive-container handling. Shared deserialization preserves Python construction exceptions and standardizes error mapping for loads and load.

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
Loading

Possibly related PRs

Suggested reviewers: shaharnaveh

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: fixing recursive reference loading during marshal deserialization.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[ ] test: cpython/Lib/test/test_marshal.py (TODO: 8)

dependencies:

dependent tests: (25 tests)

  • marshal: test_bool test_exceptions test_importlib test_inspect test_marshal test_zipimport
    • importlib._bootstrap_external: test_importlib test_unittest
      • modulefinder: test_importlib test_modulefinder
      • py_compile: test_argparse test_cmd_line_script test_compileall test_importlib test_multiprocessing_main_handling test_py_compile test_pydoc test_runpy
      • pydoc: test_enum
    • pkgutil: test_pkgutil test_pyrepl
    • profile: test_profile
    • pstats: test_pstats
    • zipimport: test_importlib test_zipimport_support

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

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
@youknowone
youknowone marked this pull request as ready for review August 12, 2026 07:17

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
crates/compiler-core/src/marshal.rs (3)

962-988: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider 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 value

Consider 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 win

Extract the shared tuple decoding path.

The Type::SmallTuple and Type::Tuple arms are identical except for the length read width. Extract a helper that takes len and 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 win

Replace the panicking index with a checked write.

borrow_vec_mut()[index] = value panics if index is out of range. The current decoder always calls this with index from 0..len, and len matches 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 BadType marshal error, which deserialize_value already maps to ValueError("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

📥 Commits

Reviewing files that changed from the base of the PR and between 81df1ff and 7371547.

⛔ Files ignored due to path filters (1)
  • Lib/test/test_marshal.py is excluded by !Lib/**
📒 Files selected for processing (3)
  • crates/compiler-core/src/marshal.rs
  • crates/vm/src/builtins/tuple.rs
  • crates/vm/src/stdlib/marshal.rs

Comment on lines 920 to +935
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))??
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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 -40

Repository: 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 -180

Repository: 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 -220

Repository: 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 -140

Repository: 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")
PY

Repository: 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:


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.

@youknowone
youknowone merged commit d64cc2c into RustPython:main Aug 12, 2026
28 checks passed
@youknowone
youknowone deleted the fix/marshal-recursive-references branch August 12, 2026 07:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant