feat(framework): multipart/form-data parsing — Part, multipart_parts, part_named

- http/multipart.wo: RFC 7578 whole-body parsing within BODY_MAX —
  boundary from the raw content-type (quoted or bare, key
  case-insensitive), parts split on --boundary, each part = headers,
  blank line, content; filename + per-part content-type kept (lowercased)
- strict malformed-is-nil: no closing --boundary-- marker, a part
  without content-disposition, missing blank line, no boundary param,
  wrong media type — all nil, the caller's 400
- part_named(parts, name): first matching field's content, caller-owned
- web-app CreateProduct now accepts multipart/form/JSON (curl -F shape)
  into the shared insert path
- probe 13/13 + 3x reuse loop (fields, crlf-in-content, quoted boundary,
  file part, zero-part close, five malformed shapes) release + ASan
- gate grows 19 -> 21: multipart create 201, missing closing marker 400
- README: multipart row ✅ (all three body hooks done), limits updated;
  story 16 + board record the landing
- gates: web-app 21/0, oop-e2e 89/0, deps-accept 8/0, log-watcher 7/0,
  employee 8/0, woc-test green

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-20 03:33:14 +02:00
parent 1995de5c28
commit f5a92fe536
6 changed files with 149 additions and 8 deletions

View file

@ -43,8 +43,14 @@ core checklist (✅ / candidate / parked-by-design rows).
**Form-encoded bodies landed 2026-08-20** (same branch): `media_type(req)`
+ `form_values(req)` (nil on any other content-type; '+'/%XX decoded);
CreateProduct accepts form OR JSON into one insert path; `just web-app`
**19/0**. Multipart is the candidate next slice.
CreateProduct accepts form OR JSON into one insert path.
**Multipart landed 2026-08-20** (same branch): `http/multipart.wo` —
RFC 7578 fields + file parts, strict malformed-is-nil, `part_named`;
whole-body within BODY_MAX (streaming parks behind 8/11). CreateProduct
takes multipart/form/JSON; `just web-app` **21/0**. Surfaced + fixed the
RETURN flavor of the interp-of-borrowed-place emitter bug (emit_return now
sees through Interp; same corpus pin). Body-parsing hooks: all three ✅.
Next per the implementation order (17 parked): finish the half-done
database branches — 9c (ipc-attach: manifest + binding) and 9d

View file

@ -42,8 +42,8 @@ class ShowProduct {
}
}
-- Accepts BOTH bodies: a form post (application/x-www-form-urlencoded,
-- the framework's form_values hook) and JSON — same insert either way.
-- Accepts THREE bodies: multipart/form-data (curl -F), a form post
-- (application/x-www-form-urlencoded), and JSON — same insert either way.
fn create_product(name: Text, price: Int, stock: Int) -> Resp {
let made = try insert Product { name: name, price: price, stock: stock }
catch (e) nil;
@ -54,6 +54,21 @@ fn create_product(name: Text, price: Int, stock: Int) -> Resp {
class CreateProduct {
pad: Int
fn handle(req: Req) -> Resp {
if media_type(req) == "multipart/form-data" {
let ps = multipart_parts(req);
if ps == nil { return bad_request("unreadable multipart body"); }
let name = part_named(ps, "name");
if name == nil { return bad_request("multipart needs name, price, stock"); }
let pstr = part_named(ps, "price");
if pstr == nil { return bad_request("multipart needs name, price, stock"); }
let sstr = part_named(ps, "stock");
if sstr == nil { return bad_request("multipart needs name, price, stock"); }
let price = parse_int(pstr);
if price == nil { return bad_request("price must be a number"); }
let stock = parse_int(sstr);
if stock == nil { return bad_request("stock must be a number"); }
return create_product(name, price, stock);
}
if media_type(req) == "application/x-www-form-urlencoded" {
let f = form_values(req);
if f == nil { return bad_request("unreadable form body"); }

View file

@ -58,8 +58,10 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework",
- `Content-Length` bodies only (no chunked encoding), no WebSockets/SSE,
JSON-first (no templates). Form-encoded bodies parse through
`form_values(req)` (`+` and `%XX` decoded, nil on any other
content-type); `media_type(req)` names the body's media type for
content negotiation. Multipart: not yet.
content-type); multipart/form-data through `multipart_parts(req)`
(whole-body, bounded by BODY_MAX — no streaming uploads until
fibers/shards) with `part_named` for fields; `media_type(req)` names
the body's media type for content negotiation.
## The core checklist (what a framework core owes, and where this one is)
@ -72,7 +74,7 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework",
| Bearer/Basic auth mechanism + principal | ✅ `http/auth.wo`, `req.principal` |
| Body parsing hooks: JSON | ✅ the language's checked `json.decode` |
| Body parsing hooks: form-encoded | ✅ `form_values(req)` — nil unless the content-type says form; `media_type(req)` exposed for content negotiation |
| Body parsing hooks: multipart | ⬜ candidate next slice |
| Body parsing hooks: multipart | ✅ `multipart_parts(req)` (RFC 7578: fields + file parts, filename/mime kept) + `part_named` |
| Error handling → status mapping | 🔶 trap = 500, builders per status; a per-error mapping hook is a candidate slice |
| Body streaming, backpressure | ⏸ needs fibers/shards (iterations 8/11) — whole bodies until then, by design |
| Cancellation propagation | ⏸ process-level only (`env.stopping()`); per-request cancel needs fibers (11) |

View file

@ -0,0 +1,103 @@
-- http/multipart.wo — multipart/form-data parsing (RFC 7578), the body
-- hook for file uploads and curl -F. Whole-body parsing over the buffered
-- body (the serve loop already bounds it at BODY_MAX): parts split on the
-- boundary, each part = headers, blank line, content. Anything malformed
-- answers nil — the caller's 400, never a guess.
pub typedef Part = {
name: Text, -- content-disposition name (form field)
filename: Text, -- "" unless the part is a file
mime: Text, -- the part's own content-type, lowercased, "" if absent
content: Text -- the raw bytes
}
-- A quoted parameter value loses its quotes; a bare one is trimmed.
fn unquote(v: Text) -> Text {
let t = trim(v);
if len(t) >= 2 and starts_with(t, "\"") and ends_with(t, "\"") {
return substr(t, 1, len(t) - 2);
}
return t;
}
-- The boundary parameter from the RAW content-type header (media_type
-- strips parameters, so this reads the header itself). Value may be quoted;
-- the parameter key is case-insensitive, the value is not.
fn boundary_of(req: Req) -> ?Text {
let ct = req.headers["content-type"];
if ct == nil { return nil; }
for tok in split(ct, ";") {
let t = trim(tok);
if starts_with(to_lower(t), "boundary=") {
let v = unquote(substr(t, 9, len(t) - 9));
if v != "" { return v; }
}
}
return nil;
}
-- One piece between boundary markers: "\r\n<headers>\r\n\r\n<content>\r\n".
-- nil on any malformation; a part without a content-disposition is
-- malformed (RFC 7578: every part carries one).
fn parse_part(piece: Text) -> ?Part {
if starts_with(piece, "\r\n") == false { return nil; }
let he = index_of(piece, "\r\n\r\n");
if he < 0 { return nil; }
if he + 6 > len(piece) { return nil; }
if ends_with(piece, "\r\n") == false { return nil; }
let headers = substr(piece, 2, he - 2);
let content = substr(piece, he + 4, len(piece) - he - 6);
let name = "";
let filename = "";
let mime = "";
let disposed = false;
for line in split(headers, "\r\n") {
let low = to_lower(line);
if starts_with(low, "content-disposition:") {
disposed = true;
for tok in split(line, ";") {
let t = trim(tok);
let tl = to_lower(t);
if starts_with(tl, "name=") { name = unquote(substr(t, 5, len(t) - 5)); }
if starts_with(tl, "filename=") { filename = unquote(substr(t, 9, len(t) - 9)); }
}
}
if starts_with(low, "content-type:") {
mime = to_lower(trim(substr(line, 13, len(line) - 13)));
}
}
if disposed == false { return nil; }
return Part { name: name, filename: filename, mime: mime, content: content };
}
-- The request's parts, in body order. nil unless the content-type is
-- multipart/form-data with a boundary, every part parses, and the body
-- carries the closing "--boundary--" marker.
pub fn multipart_parts(req: Req) -> ?multi Part {
if media_type(req) != "multipart/form-data" { return nil; }
let b = boundary_of(req);
if b == nil { return nil; }
let pieces = split(req.body, "--${b}");
if len(pieces) < 2 { return nil; }
let parts: multi Part = [];
let i = 1;
let ended = false;
while i < len(pieces) {
let piece = pieces[i];
if starts_with(piece, "--") { ended = true; break; }
let p = parse_part(piece);
if p == nil { return nil; }
push(parts, p);
i = i + 1;
}
if ended == false { return nil; }
return parts;
}
-- The content of the first part with this field name; nil if absent.
pub fn part_named(parts: multi Part, name: Text) -> ?Text {
for p in parts {
if p.name == name { return "${p.content}"; }
}
return nil;
}

View file

@ -42,7 +42,17 @@
> (nil unless the content-type says form; '+' and %XX decoded through the
> existing query decoder). Probe 7/7 release + ASan; web-app's
> CreateProduct now accepts form OR JSON into one insert path;
> `just web-app` 19/0. Multipart stays the candidate next slice.
> `just web-app` 19/0.
>
> **Multipart LANDED 2026-08-20** (same branch): `http/multipart.wo` —
> RFC 7578 parts (fields + file parts with filename/mime), quoted or bare
> boundary, strict malformed-is-nil (missing closing marker, missing
> content-disposition), `part_named` accessor; whole-body within BODY_MAX,
> streaming parks behind 8/11. Probe 13/13 + reuse loop, release + ASan;
> CreateProduct accepts multipart/form/JSON; `just web-app` 21/0. It
> surfaced the RETURN flavor of the interp-of-borrowed-place emitter bug
> (`return "${p.content}"` — corruption two requests after the handler);
> fixed in emit_return, pinned in the same fixture.
>
> The framework is written IN writeonce and imported
> like any dependency (iteration 15 is the prerequisite). TLS terminates at a

View file

@ -112,6 +112,11 @@ expect "form-encoded create (201, + and %XX decoded)" \
"$(hit POST /products 'name=form+kettle&price=1250&stock=2' yes 'application/x-www-form-urlencoded; charset=UTF-8')" 201 '"name":"form kettle"'
expect "form with a non-numeric price is 400" \
"$(hit POST /products 'name=x&price=abc&stock=1' yes 'application/x-www-form-urlencoded')" 400
MP=$'--BXB\r\ncontent-disposition: form-data; name="name"\r\n\r\nmp teapot\r\n--BXB\r\ncontent-disposition: form-data; name="price"\r\n\r\n700\r\n--BXB\r\ncontent-disposition: form-data; name="stock"\r\n\r\n3\r\n--BXB--\r\n'
expect "multipart create (201, curl -F shape)" \
"$(hit POST /products "$MP" yes 'multipart/form-data; boundary=BXB')" 201 '"name":"mp teapot"'
expect "multipart without the closing marker is 400" \
"$(hit POST /products $'--BXB\r\ncontent-disposition: form-data; name="name"\r\n\r\nx\r\n' yes 'multipart/form-data; boundary=BXB')" 400
expect "list shows the product" "$(hit GET /products)" 200 '"price":900'
expect "show by :name capture" "$(hit GET /products/mug)" 200 '"stock":5'
expect "unknown product is 404" "$(hit GET /products/none)" 404