Skip to content

One MIME-tree traversal instead of two (#237) - #259

Open
kurok wants to merge 1 commit into
feat/239-lazy-borrowfrom
feat/237-one-tree-traversal
Open

kurok wants to merge 1 commit into
feat/239-lazy-borrowfrom
feat/237-one-tree-traversal

Conversation

@kurok

@kurok kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Closes #237.

Stacked on #258 (feat/239-lazy-borrow), so the diff here is only this change. #237's own note says "#239build_node gains a base parameter there; do this collapse first or together"; #239 went first, and its Retain enum turns out to be exactly the leaf policy this issue asks the traversal to be parameterised on. Merge #258 first and this retargets to master on its own.

The duplication

MimePart::build (full mode, #99) and build_node (the deferred modes, #202) were the same recursive walk with a different leaf arm — same depth cap, same multipart/* recursion, a verbatim copy of the message/rfc822 decode → repair → re-parse block, and the same six-field node literal. Every per-node rule had to be edited twice, and a fuzz invariant existed to notice when the copies drifted.

One of those copies was of the depth cap and the re-parse of an attacker-supplied embedded message. That is the part worth not having two of.

The shape

pub struct Node<B> { content_type, headers, filename, content_id, disposition, is_message, body: B, children: Vec<Node<B>> }
pub type MimePart = Node<Option<Vec<u8>>>;
pub type TreeNode = Node<NodeBody>;

trait LeafPolicy: Copy {
    type Body;
    fn inside_embedded(self) -> Self;   // the subtree's bytes are in no caller buffer
    fn container(self) -> Self::Body;
    fn embedded(self, part: &ParsedMail<'_>, raw: Vec<u8>) -> Self::Body;
    fn leaf(self, part: &ParsedMail<'_>) -> Result<Self::Body, MailParseError>;
}

Two implementations, so the linker sees the two instantiations it saw before: Full, and Retain from #239. inside_embedded is a hook rather than a constant precisely because of #239 — a mode that retains offsets has to stop retaining them inside an embedded message, whose bytes are a decode of the enclosing body.

NodeBody stays, and so does its reason: MimePart's None means container, and a mode where None could also mean "not decoded yet" would make the two indistinguishable. Generic over the body satisfies that without a second walk.

Acceptance

grep -c 'if depth >= MAX_MIME_DEPTH' in the core 3 → 2 (traversal + flat extract_mail_parts)
message/rfc822 decode→repair→re-parse block once
MimePart / TreeNode names, parse_email_tree(payload), parse_tree_deferred(payload, bool) unchanged¹
src/fast_mail_parser.rs 1 line (part.contentpart.body)
__init__.pyi, tests/test_contract.py, docs/, vendor/ untouched

¹ parse_tree_deferred returns DeferredTree rather than TreeNode, which is #258's change, not this one.

The false comment is fixed too: build_node claimed to be #[inline(never)] "like MimePart::build", which had no such attribute.

Measurements

Local interleaved A/B on an M4 (Apple M4, 10 vCPU), 3 rounds, against this PR's base:

Benchmark base (#258) this PR
parse_tree 0.156 ms 0.159 ms −2.0%
parse_tree_metadata 0.030 ms 0.030 ms −0.3%
parse_tree_lazy_untouched 0.030 ms 0.030 ms −0.3%
parse_message 0.156 ms 0.158 ms −1.0%
parse_metadata 0.029 ms 0.029 ms −0.9%
parse_lazy_untouched 0.030 ms 0.031 ms −1.6%
parse_many 1.319 ms 1.322 ms −0.2%

Noise floor from the pure-Python controls: 1.8%. Worst real movement −2.0% (parse_tree), whose per-round values are 0.156 / 0.156 / 0.155 against 0.156 / 0.159 / 0.162 — identical in round 1 and drifting upward after, which is thermal, not codegen. Flat, as a maintenance change should be; the x86 gate is the verdict.

ab_median.py also flags attachment_reread at "+97.6%" in this PR's favour. Ignore it: that benchmark is 42–83 ns and both sides sampled the same two timer ticks (base 83/42/83, this PR 83/42/42). It is quantisation, not a measurement.

Verification: 864 Python tests including all of test_mime_tree.py and test_tree_modes.py with no DIVERGENCES edits; 11 core Rust tests; cargo clippy --all-targets -D warnings -W clippy::cast_possible_truncation on all three crates; cargo fmt; mypy --strict; ruff; the vendored mailparse suite (82 tests, external --target-dir); check_vendored_mailparse.sh and check_bench_lockfile.py. 338K cargo fuzz run -a parse_agreement executions, zero crashes — that target's invariant 8 is the shape oracle this change has to satisfy, and it now also has an always-run twin in cargo test (every_mode_builds_the_same_tree_around_an_embedded_message).

@kurok

kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

CI status, and the gate's scatter on the small benchmarks

11 of 11 registered checks pass. The four that did not register are CodeQL's (Analyze (actions|python|rust) and CodeQL): this repo uses GitHub's default setup for code scanning (dynamic/github-code-scanning/codeql), which only runs on pull requests targeting the default branch. They will run on their own once #258 merges and this PR retargets to master. Nothing to do about it here, but worth saying out loud rather than letting "11 green" read as "fully gated".

The benchmark gate failed once and passed on re-run, on the same five benchmarks that did this on #258. There are now five measurements of this code against master, on four different CPU models:

runner control floor parse_metadata / _str / parse_many_metadata / parse_tree_metadata verdict
#258 run 1 Xeon 8370C 3.9% +11.2 / +11.2 / +12.7 / +16.3% fail
#258 run 2 Xeon 8370C 0.8% flat pass
#258 run 3 EPYC 7763 0.3% +0.3 / +0.5 / +0.1 / −0.2% pass
#259 run 1 EPYC 9V45 2.2% +10.0 / +9.8 / +9.9 / +11.8% fail
#259 run 2 EPYC 9V74 6.5% +0.8 / +0.8 / +0.0 / +2.2% pass

Two things to read off it.

These four benchmarks carry roughly ±10% run-to-run scatter that the build does not explain. parse_metadata and parse_metadata_str go through parse_email_metadatametadata_from_payload, which neither this PR nor #258 modifies, and parse_many_metadata is parse_many_as(..., parse_email_metadata). They are also the smallest gated benchmarks — 0.028–0.051 ms depending on runner. The verdict flips on the same binaries, and it does not track the control noise floor either (run 5 has the highest floor of the five and is flat). This is the scatter #240 exists to measure, and the gate's 7% threshold sits inside it.

What is consistent is the thing that is real. parse_lazy_untouched and parse_tree_lazy_untouched improve in every one of the five runs, on every CPU model: −19.1%, −23.0%, −29.7%, −32.1%, −32.9% and −20.9%, −18.7%, −31.4%, −32.9%, −31.4%. A code-layout artefact does not reproduce five times across four CPU models; a removed copy does. (That improvement is #258's, not this PR's — the gate resolves a stacked PR's base to origin/master, so these runs measure #239 and #237 together.)

For this PR's own effect, the comparison that isolates it is the local interleaved A/B against its actual base in the PR body: worst real movement −2.0% (parse_tree) against a 1.8% control floor, on an M4. Flat, which is what a collapse of two cold recursions into one generic should be.

MimePart::build (full mode, #99) and build_node (the deferred modes, #202) were
the same recursive walk with a different leaf arm. Both checked the depth cap,
both read ctype.mimetype and the disposition, both recursed over subparts for
multipart/*, both carried a verbatim copy of the message/rfc822 decode ->
repair_missing_separator -> parse_mail -> recurse block, and both ended in the
same six-field node literal. The only thing that differed was what a leaf's body
became.

So every per-node rule the tree enforces -- the depth cap, the embedded-message
repair, how Content-ID is normalised, how the disposition token is derived -- had
to be edited in two places, and the project paid for a fuzz invariant whose only
job was to notice when the copies drifted. One of those copies was of the depth
cap and of the re-parse of an attacker-supplied embedded message, which is not a
block to maintain two of.

The node is now generic over its body:

    pub struct Node<B> { ... body: B, children: Vec<Node<B>> }
    pub type MimePart = Node<Option<Vec<u8>>>;
    pub type TreeNode = Node<NodeBody>;

and one traversal is driven by a LeafPolicy, which says what a body is and
nothing else. There are two implementations, so the linker sees the two
instantiations it saw before: Full, and the Retain enum #239 already used to say
what a leaf keeps of itself -- Retain IS the deferred leaf policy, which is why
these two issues wanted doing together.

NodeBody stays, and so does the reason for it: MimePart's None *means* container,
and a mode where None could also mean "not decoded yet" would make the two
indistinguishable. Generic over the body satisfies that without a second walk.

grep -c 'if depth >= MAX_MIME_DEPTH' in the core goes 3 -> 2 (the traversal and
the flat extract_mail_parts). The embedded-message block appears once. The
inline(never) comment now describes the function it is attached to, which it did
not before.

Nothing public moves. MimePart.content is renamed to .body inside the core; the
binding reads it at one line and the fuzz target at three. __init__.pyi,
tests/test_contract.py, docs/ and vendor/ are untouched.

Measured flat, which is the claim a cold-code change has to make here: local
interleaved A/B on an M4, 3 rounds, worst real movement -2.0% against a 1.8%
control noise floor. 864 Python tests, 11 core tests (one new: the always-run
twin of fuzz invariant 8, asserting the three modes build identical non-body node
fields around a nested message/rfc822), 338K fuzz executions with debug
assertions live, clippy, fmt, mypy --strict, ruff, the vendored mailparse suite
and both repo invariant scripts.
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