Skip to content

Decode quoted-printable bodies a run at a time (#229) - #245

Merged
kurok merged 1 commit into
masterfrom
feat/229-qp-decoder
Sep 17, 2026
Merged

kurok merged 1 commit into
masterfrom
feat/229-qp-decoder

Conversation

@kurok

@kurok kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Closes #229.

Branched from master (abb31b8). Independent of #244, but both touch vendor/mailparse/PATCH.md and the fuzz target list, so whichever lands second will want a small rebase — noted at the bottom.

What changed

Quoted-printable is the transfer encoding of most HTML newsletters and transactional mail, and it was the last transfer decoder here still running byte-at-a-time. decode_quoted_printable handed the whole body to the quoted_printable crate, which makes three passes over it: a char-by-char copy into a String through a filter_map, a second walk with lines() + trim_end(), then a decode loop with one bounds-checked push per byte into a Vec that was never given a capacity.

Bodies now go through vendor/mailparse/src/qp.rs: one allocation sized to the input, memchr to find line breaks and escapes, extend_from_slice for everything between them — so a line with no = in it is a single copy. Plain std plus the memchr already vendored here. No unsafe, no new dependency, Cargo.lock unchanged. The RFC 2047 encoded-word path in header.rs still uses the crate: header-sized inputs, different semantics.

The benchmark had to come first

Nothing in the suite could see this path. Every fixture in tests/benchmark/ is base64 or 8bit — which is also why the profile everyone quotes (53% base64 decode, 27% whitespace strip) describes base64 mail only. A quoted-printable message goes through none of those fast paths, and the gate would not have noticed this getting slower.

So per the issue's step 1, I added the benchmark and measured on master before writing a line of the decoder: 218 µs, against 22 µs for metadata mode on the same message. ~90% of that parse was the QP decode. That is what justified touching the vendored surface at all.

Numbers

Apple M4 (10 vCPU), rustc 1.98.0, CPython 3.12, 5 interleaved rounds (ab_median.py). Pure-Python controls within 3.2%.

Benchmark master this branch
parse_qp_message (new) 0.219 ms 0.134 ms −39%
parse_qp_message_metadata (new control) 0.022 ms 0.022 ms flat

The control is the point: same fixture, same headers, same structure, no transfer decode. It does not move, so the gain is the decode and not the parse around it. Every other benchmark landed inside the noise floor.

One honest caveat on the size of the win. The issue estimated the decoder itself at 4–8× and end-to-end at 1.5–2.5×. End-to-end came in at 1.64×, inside that range, but the decode component went 197 µs → 112 µs, i.e. ~1.76× rather than 4–8×. The likely reason is named in the issue's own Out of scope: quoted_printable_invalid_escape in src/mail_parser.rs still makes a full per-byte pass over every QP body, and it is now the largest remaining one. That is a separate issue, and this PR does not touch it.

Full A/B report

Measured on Apple M4, 10 vCPU.

Median of 5 interleaved rounds per side; each value is a benchmark's minimum. Positive delta = master is slower.

Benchmark qp master Delta
test__fast_mail_parser___attachment_reread 0.000 ms 0.000 ms +0.0%
test__fast_mail_parser___full_read 0.242 ms 0.241 ms -0.5%
test__fast_mail_parser___parse_lazy_all_attachments 0.254 ms 0.251 ms -1.5%
test__fast_mail_parser___parse_lazy_untouched 0.043 ms 0.043 ms +0.4%
test__fast_mail_parser___parse_many 1.875 ms 1.873 ms -0.1%
test__fast_mail_parser___parse_many_metadata 0.243 ms 0.239 ms -1.8%
test__fast_mail_parser___parse_message 0.229 ms 0.231 ms +1.2%
test__fast_mail_parser___parse_message_strict 0.231 ms 0.233 ms +0.8%
test__fast_mail_parser___parse_metadata 0.030 ms 0.030 ms +0.0%
test__fast_mail_parser___parse_metadata_str 0.030 ms 0.030 ms +0.3%
test__fast_mail_parser___parse_qp_message 0.134 ms 0.219 ms +63.0%
test__fast_mail_parser___parse_qp_message_metadata 0.022 ms 0.022 ms +0.2%
test__fast_mail_parser___parse_tree 0.234 ms 0.231 ms -1.5%
test__fast_mail_parser___parse_tree_lazy_untouched 0.042 ms 0.042 ms +0.1%
test__fast_mail_parser___parse_tree_metadata 0.031 ms 0.031 ms +0.3%
test__mail_parser___parse_message 5.592 ms 5.414 ms -3.2% control
test__mailparser_lib___full_read 5.935 ms 5.875 ms -1.0% control
test__stdlib_email___full_read 7.561 ms 7.563 ms +0.0% control
test__threaded___parse_many 0.993 ms 0.955 ms -3.8% informational
test__threaded___parse_many_metadata_small 2.546 ms 2.574 ms +1.1% informational
test__threaded___parse_many_small 2.771 ms 2.806 ms +1.3% informational
test__threaded___threadpool_parse_email 0.860 ms 0.861 ms +0.1% informational
test__threaded___threadpool_parse_email_small 14.126 ms 14.210 ms +0.6% informational

Byte-identical, asserted three ways

The entire safety argument is that output does not change, so it is tested rather than claimed:

  1. In-crate differential test (qp.rs), against quoted_printable::decode(.., Robust) as oracle: a generated corpus at every length to 96 over an alphabet that reaches every rule; every (x, y) pair after = in the four positions where the crate's escape handling branches (=x, =x\n, a=x, =xy, =xy\n — 65,536 pairs); hand-written cases for each of the six rules (soft break, literal =X at end of line, non-hex octet resuming after Y, the trimmed set, bare CR, dropped bytes, empty lines, trailing newline); and the real fixture.
  2. qp_agreement fuzz target, same equality on arbitrary input through the public entry point: 22,934,494 executions in 601 s, zero findings. Canary included and verified to fire.
  3. The vendored suite's existing test_quoted_printable_content_encoding, and the Python test_parse_warnings.py / test_stdlib_parity.py contracts, all unchanged and green.

Why quoted_printable is now pinned to =0.5.1

Writing that differential test is what found this, and it is a latent bug independent of this PR.

0.5.1 and 0.5.2 do not agree. For a body whose last line ends in a soft break, b"abc=\n" decodes to b"abc\r\n" on 0.5.1 and b"abc" on 0.5.2 — if filtered.ends_with('\n') gained && add_line_break == Some(true). The requirement here was 0.5.0, a caret, so:

  • cargo update could have changed body and encoded-word decoding, with nothing in the suite to catch it; and
  • this crate's own cargo test resolves its own lockfile, independent of the root one — so it was pulling 0.5.2 while the extension links 0.5.1. My first test run failed for exactly that reason, and a differential test that compares against a version you do not ship is worse than no test.

The pin closes both. Cargo.lock is unchanged: the root already resolved to 0.5.1. src/qp.rs reproduces 0.5.1, because that is what this library ships.

Checks run locally

pytest tests --ignore=tests/benchmark (808 passed, 3 skipped), the vendored suite (55 + 25 passed) via --target-dir outside the tree, cargo fmt --all --check and cargo fmt --manifest-path vendor/mailparse/Cargo.toml --check, cargo clippy --all-targets -- -D warnings -W clippy::cast_possible_truncation, mypy --strict, ruff check . — all green. No target/ under vendor/mailparse/. __init__.pyi, tests/test_contract.py and docs/compatibility.md are untouched, as the issue requires — there is no API or behaviour change.

The M4 is not a proxy for x86; the Benchmark quality gate is the verdict.

Rebase note

#244 (base64 SIMD) also edits vendor/mailparse/PATCH.md, the root Cargo.toml [patch.crates-io] comment, fuzz/Cargo.toml and the FUZZ_TARGETS list — each adding a third fuzz target and a third changed function. Whichever merges second needs those merged by hand rather than taken wholesale; the conflicts are additive and small. #244 also adds .gitignore entries for what cargo fuzz leaves behind, which I deliberately did not duplicate here.

Quoted-printable is the transfer encoding of most HTML newsletters and
transactional mail, and it was the last transfer decoder here still running
byte-at-a-time. decode_quoted_printable handed the whole body to the
quoted_printable crate, which makes three passes over it -- a char-by-char
copy into a String through a filter_map, a second walk with lines() and
trim_end(), then a decode loop with one bounds-checked push per byte into a
Vec that was never given a capacity.

Bodies now go through vendor/mailparse/src/qp.rs: one allocation sized to
the input, memchr to find line breaks and escapes, extend_from_slice for
everything between them, so a line with no '=' in it is a single copy. Plain
std plus the memchr already vendored here; no unsafe, no new dependency. The
RFC 2047 encoded-word path in header.rs still uses the crate -- header-sized
inputs, and different semantics.

Nothing in the suite could see this path before. Every benchmark fixture is
base64 or 8bit, which is also why the maintainer's profile of a full parse
(53% base64 decode, 27% whitespace strip) describes base64 mail only: a
quoted-printable message goes through none of it, and the gate would not
have noticed this getting slower. So the benchmark comes with the change --
parse_qp_message over tests/data/valid_message.eml (89,932 bytes of
quoted-printable HTML, 8,122 of text), plus a metadata-mode control on the
same message, which is what shows the gain is the decode and not the parse
around it.

Apple M4 (10 vCPU), 5 interleaved rounds, pure-Python controls within 3.2%:

  parse_qp_message            0.219 -> 0.134 ms   -39%
  parse_qp_message_metadata   0.022 -> 0.022 ms    flat

Every other benchmark landed inside the noise floor.

Output is byte-identical to quoted_printable 0.5.1's Robust mode, which is
the whole safety argument, so it is asserted three ways rather than claimed.
The in-crate differential test compares the two over a generated corpus at
every length to 96, over every (x, y) pair after '=' in the four positions
where the crate's escape handling branches, and over hand-written cases for
each of the six rules -- soft breaks, literal '=X' at end of line, non-hex
octets resuming after Y, the trimmed set, bare CR, dropped bytes, empty
lines, trailing newline. It also decodes the real fixture. The qp_agreement
fuzz target asserts the same equality on arbitrary input through the public
entry point: 22,934,494 executions in 601 s, zero findings.

Writing that test is what turned up the reason quoted_printable is now
pinned to =0.5.1. 0.5.2 changed what Robust mode emits for a body whose last
line ends in a soft break -- b"abc=\n" decodes to b"abc\r\n" on 0.5.1 and
b"abc" on 0.5.2 -- and the requirement here was a caret. Two consequences:
cargo update could have changed body and encoded-word decoding with nothing
in the suite to catch it, and this crate's own cargo test resolves its own
lockfile, so it was pulling 0.5.2 while the extension links 0.5.1, which
meant the differential test was comparing against a version that does not
ship. The first run failed for exactly that reason. Cargo.lock is unchanged
by the pin; the root already resolved to 0.5.1.

The PR fuzz budget stays at a minute: three targets at 20 s rather than two
at 30 s. deep-fuzz.yml gains the target in its matrix. PATCH.md describes
the third changed function, the pin and its reasoning, and the sync
checklist and diff -r claim name src/qp.rs; the root Cargo.toml comment,
which described only the memmem change, is brought up to date.

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
@kurok
kurok force-pushed the feat/229-qp-decoder branch from 519756e to 0e66c75 Compare September 17, 2026 09:10
@kurok

kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto e7bbb05 now that #244 has landed. Five files conflicted, all additively — PATCH.md, the root Cargo.toml patch comment, fuzz/Cargo.toml, test.yml and deep-fuzz.yml each gained a third changed function / fourth fuzz target. Both sides kept; PR fuzz budget stays at a minute (four targets × 15 s).

Two things the rebase turned up that are worth flagging.

1. #244 left a small gap in PATCH.md that I fixed here. Its "The changes" entry 2 still described only the whitespace strip — the paragraph about base64_simd::STANDARD and the data_encoding fallback never made it into master, and the diff -r claim still said src/body.rs (one function). Since "the diff -r list is accurate" is an acceptance criterion for this issue, entry 2 now describes both halves and the list names src/qp.rs and the mod tests.

2. The metadata benchmarks read ~23% faster on this branch, and that is not this PR's doing. Re-measured on the M4 against the new base:

Benchmark master e7bbb05 this branch
parse_qp_message 0.221 ms 0.137 ms +61% — the actual change
parse_metadata 0.037 ms 0.030 ms +23% — see below
parse_many_metadata 0.295 ms 0.241 ms +22%
parse_tree_metadata 0.038 ms 0.031 ms +23%
parse_lazy_untouched 0.050 ms 0.043 ms +16%
parse_qp_message_metadata 0.023 ms 0.022 ms +3.4% (control, flat)

Those metadata numbers are the M4 code-placement artifact I documented on #244 — master sits at 0.037 ms there, where it was 0.030 ms before base64-simd was linked, and this change happens to shift the layout back. It is not a quoted-printable win: metadata mode never transfer-decodes. The x86 gate measured those same benchmarks as flat on #244, so I expect it to show them flat here too, and the gate is the number to quote, not this table. Reporting it because a 23% swing on an unrelated benchmark should be explained rather than pocketed.

The claim this PR actually makes is the first row and its control: same fixture, same headers, decode removed — 0.221 → 0.137 ms.

Re-verified after the rebase: 811 passed / 3 skipped, the vendored suite 57 + 25 (all four QP differential tests among them), fmt for both manifests, clippy, mypy --strict, ruff.

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.

Vendored mailparse: replace quoted_printable::decode with a run-copying QP decoder, and benchmark a quoted-printable message

1 participant