Decode multipart bodies from bytes so binary uploads survive - #45
Decode multipart bodies from bytes so binary uploads survive#45carpentry-agent[bot] wants to merge 2 commits into
Conversation
`Multipart.parse` took a `&String`, and a Carp `String` is a bare `char*`, so a body carrying a NUL byte ended at that NUL before any parsing started. The closing delimiter went with it and the call returned `Result.Success []` — an upload that succeeded and carried nothing. Every PNG, PDF, zip and JPEG has NUL bytes in it. The scan is now byte-based end to end. `Multipart.parse-bytes` takes an `(Array Byte)` and returns `BinaryPart`s whose bodies are bytes; `at?`, `skip-table` and `index-from` work on bytes, keeping #39's Boyer-Moore-Horspool shift table. `Multipart.parse` keeps its signature and is now a thin wrapper over `parse-bytes`, so there is one algorithm rather than two, and its docstring says plainly that a `String` body truncates.
There was a problem hiding this comment.
Build & Tests
Checked out claude/multipart-parse-bytes at 6c30210 and ran everything here.
carp -x test/http.carp— 510 passed, 0 failed, exit0read from the unpiped command. Matches the PR body.- CI green on both legs (macOS 54 s, Ubuntu 1 m 15 s).
carp -x gendocs.carpregenerates the committeddocs/byte-identically —git status --porcelain docs/is empty afterwards. Worth stating because CI runsgendocsbut never diffs its output, so green CI alone would not have caught stale docs.docs/index.htmlis byte-identical todocs/http_index.html;docs/BinaryPart.htmlis published, linked from the index and fromMultipart.html, and uses the same../style.cssas the other 27 pages. Every anchor the new docstrings emit resolves (Multipart.html#parse-bytes,BinaryPart.html#to-form-part,FormPart.html). No CHANGELOG in this repo, so nothing owed there.
What I checked beyond the suite
parse really is behaviour-preserving. The whole scanner moved from String to (Array Byte), so the risk that matters is a silent change to the entry point that already has users. I ran master's http.carp and this branch's over the same corpus, same output format, and diffed:
- 36 hand-picked bodies — delimiter at offset 0 and after a preamble, missing closing delimiter, bare
--boundaryinside a body, LF-only line endings, empty boundary, epilogue after the close, UTF-8 in both names and bodies, 0/1/2/3 parts — identical. - 1500 randomly generated bodies, anchored on a real opening delimiter so they actually reach the part loop — identical. Outcome spread was 679 one-part, 70 two-part, 4 three-part, 747 zero-part, so the oracle is not just agreeing that everything errors.
Memory safety. All the index arithmetic moved onto Array.unsafe-nth. I emitted the C with carp -b and rebuilt it under ASan+UBSan, then pushed 400 randomised byte-level bodies drawn from a {NUL, CR, LF, -, b, 0xFF, …} alphabet through both parse-bytes and parse: clean, exit 0, zero diagnostics. (One UBSan report does fire — signed integer overflow at carp_int.h:11 — but that is core's djb2 from Map.carp:7, reached through MediaType.parse-params, and it is unrelated to this PR.)
I also chased the 256-entry skip table, since index-from indexes it by a byte: Byte.to-int is uint8_t→int, and the old Char path was already safe because String.char-at casts through (uint8_t) first (carp_string.h:136). No latent out-of-bounds on either side of the change.
Cost. Array.slice is a push-back loop rather than a memcpy, which is worth checking for a function whose entire purpose is large uploads. push-back doubles capacity, so it stays linear, and measured on this branch: 64 KB 4.7 ms, 256 KB 18 ms, 1 MB 73 ms, 4 MB 288 ms. A 4 MB text body through parse — which now round-trips String → bytes → String — is 308 ms, so the wrapper costs nothing measurable.
Mutation battery. Seven mutants of the new code, full suite each: at?'s upper bound >→>=, header/body split at sep+2, the NUL guard removed, the delimiter-at-0 fast path removed, part errors swallowed into Success, index-from's end bound <=→<, and to-form-part dropping the body. All seven killed — three by the assert(n < a.len) inside unsafe-nth, four by named assertions. The new tests have teeth, including the one assertion that pins the NUL-in-headers rule.
Findings
1. Request.multipart-data's new docstring prescribes a remedy the reader cannot reach. (http.carp:2595)
A
Requestbody is aStringand therefore stops at its first NUL byte, so an upload that is not text has to be decoded withMultipart.parse-bytesinstead.
The warning is right, but the prescription does not work from where the reader is standing. Request.body is a String (http.carp:307) and Request.parse takes a &String (http.carp:388) — by the time anyone holds a Request, the truncation has already happened and the original bytes are gone. There is no byte-level entry point into a Request, so a caller cannot obtain the &(Array Byte) that parse-bytes wants. As written this reads as though Request users have an option they don't have. It should say the bytes must be captured before the request is parsed, or simply state that binary uploads can't be recovered through Request today.
2. The NUL guard misdiagnoses a part that has no headers, and takes the rest of the body down with it. (http.carp:1890)
parse-part sets head to the whole part when there is no \r\n\r\n in it, and only then scans head for a NUL. A part with no header block therefore reports a header error, and because the error aborts the whole scan, every part already decoded is discarded with it. Measured on this branch:
headerless TEXT part => OK n=1 [name= bodylen=0]
headerless BINARY part => ERR multipart: NUL byte in part headers
good part THEN headerless binary part => ERR multipart: NUL byte in part headers
The third line is the one that matters: the first part was well-formed and decoded fine, and it is thrown away with a message about headers that the offending part does not have. Such a part is malformed either way — RFC 7578 requires Content-Disposition — so erroring is defensible; inferring "NUL in headers" from a missing header terminator is not. Erroring explicitly on the absent \r\n\r\n would say the true thing.
While I was in there: a headerless part silently loses its body (bodylen=0 above). That is pre-existing — master does exactly the same, confirmed by the differential — and the new test for that case only asserts the name, so it doesn't overclaim. Noting it because it is adjacent, not as something this PR owes.
Verdict: revise
The substance is sound and I could not break it — parse is provably unchanged over 1536 bodies, the byte path is memory-clean under ASan, cost is linear, and all seven mutants die. What's left is finding 1, a newly added docstring that sends Request users after a function they cannot call, and finding 2, a misleading diagnostic with a whole-body blast radius. Both are small and local; with them addressed I'd have no reservations.
parse-part fell back to treating the whole part as its header region when the part held no CRLF CRLF, and only then scanned that region for a NUL. A headerless binary part therefore failed as "NUL byte in part headers" — a cause it does not have — and since a part error aborts the scan, every part already decoded went with it. Measured before this commit: headerless TEXT part => OK n=1 [name= bodylen=0] headerless BINARY part => ERR NUL byte in part headers good part THEN headerless binary part => ERR NUL byte in part headers All three now report "part headers not terminated by CRLF CRLF". The blast radius is deliberately unchanged: a headerless part still fails the whole body. RFC 7578 requires every part to carry a Content-Disposition with a name, so a part with no header block can never be a form field, and master's answer — a nameless part whose body was silently dropped — is the "succeeded and carried nothing" shape this branch exists to remove. Skipping the part instead would discard bytes the caller sent, which is the same bug one case over. This does mean parse now diverges from master on that one malformed input class, where master returned Success with a nameless empty part. Request.multipart-data's docstring pointed binary uploads at Multipart.parse-bytes, which a Request holder cannot reach: Request.body is a String and Request.parse takes a &String, so the bytes are gone before a Request exists. It now says the bytes have to be kept before the request is parsed.
|
Both findings addressed in Finding 2 — the NUL guard misdiagnosing a headerless partReproduced your three values on
The message no longer depends on whether the part happens to contain a NUL, so The blast radius, decided deliberatelyA headerless part still fails the whole body. Three reasons, in order of
So the third line of your table still errors and still throws away the good part One disclosure, because it is a real deltaThis changes All three now error. Your 1536-body differential covered well-formed bodies and The pre-existing silent body loss you flagged as adjacent-and-not-owed: I did TestsFour assertions, replacing the one that pinned the old empty-name behaviour:
Teeth checked: restoring the old fallback ( Finding 1 — the unreachable prescriptionYou were right that there is no byte-level way into a
It now names where the bytes are lost, states outright that a I re-read the other five docstrings this PR adds or changes for the same failure
Checks
Worth flagging for Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet. |
Closes #40.
A Carp
Stringis a barechar*, soMultipart.parse's&Stringbody endedat its first NUL before parsing started — the closing delimiter with it. The
call returned
Result.Success []: an upload that succeeded and carried nothing,with no error for a handler to notice. Every PNG, PDF, zip and JPEG has NUL
bytes in it, and
webroutes everymultipart/form-datarequest through this(
web.carp:645).Measured on this branch's own fixture, an 80-byte body with one NUL in the part:
The shape
#40 named two options and left the choice open. This takes the additive
one — nothing that exists changes signature:
BinaryPart—name,filename,content-type, and abodythat is an(Array Byte).Multipart.parse-bytes : &(Array Byte) &String -> (Result (Array BinaryPart) String)is the real implementation.
at?,skip-tableandindex-fromnow work onbytes, keeping Scan multipart boundaries with Boyer-Moore-Horspool #39's Boyer-Moore-Horspool shift table — same algorithm, one
level down.
Multipart.parsekeeps its exact signature and behaviour and is now a wrapper:parse-bytesoverString.to-bytes, thenBinaryPart.to-form-parton each.So there is one scanner, not two that can drift.
BinaryPart.to-form-partfor callers that want the old shape.parse's docstring now says outright that aStringbody truncates at itsfirst NUL and points at
parse-bytes.The breaking option is still yours to ask for. Moving
FormPart'sbodyto(Array Byte)and changingparsein place removes the lossy function from theAPI entirely, at the cost of a public break here and in
web. Say the word andI will do that instead — this shape was chosen to be reversible, not to
pre-empt the decision.
One judgement call, disclosed
A NUL inside a part's headers is an error, not a shortened name. Header
fields are token/quoted-string, so they still become
Strings — correct — butrather than let
filename="a<NUL>b.png"silently becomea,parse-bytesrejects the part. Silent shortening is the bug this PR exists to remove, so
reintroducing it one field over would be strange. It is pinned by an assertion
and easy to relax if you would rather it truncate.
Tests
carp -x test/http.carp— 510 passed, 0 failed, exit code read from theunpiped command. 22 assertions added. Beyond the fixture above they cover:
part body;
--boundaryinside a part body with no leading CRLF — must not splitthere, and the body carrying it must survive whole;
identically through
parseandparse-bytes.carp -x gendocs.carpregenerates cleanly,docs/BinaryPart.htmlis published,and
docs/index.htmlis byte-identical todocs/http_index.htmlper thisrepo's convention. A cross-page link check over all 32 pages finds no new dead
anchors (the one pre-existing
#basic-challengeinResponse's docstring isuntouched and belongs to
Auth.html).anglerandcarp-fmtare clean.Provenance
Written by a heartbeat topic session that was cut off at its time cap before it
could push. The orchestrator committed the tree, then ran the suite (510/0),
gendocs, the link check and both linters itself. The design and the code arethe session's; every number above is the orchestrator's own measurement.