form-net 0.5.0

Sans-IO form parsing for Rust: application/x-www-form-urlencoded and streaming multipart/form-data, with budgets checked before allocation, safe upload spilling, and typed extraction. No third-party dependencies.
Documentation
# form-net architecture

How the crate is built, as of 0.5. The contract is [`API.md`](./API.md); the reasons are in the decision log of [`../dev/PLAN.md`](../dev/PLAN.md). This file is the map between the two.

---

## Shape

form-net is sans-IO. Every parser takes bytes and returns events; nothing in the crate opens a socket, starts a thread, sets a timer, or reads a clock (except `sweep`, at startup). The only I/O is the file system behind the `fs` feature, and the `std::io` adapters behind `std`.

There are three tiers over one engine per format:

| Tier | What | Allocation |
|---|---|---|
| 1 | `parse_urlencoded`, `parse_query`, `parse_multipart`, `read_multipart`, `form!` and `FromForm` | owned results |
| 2 | the `_with` and `_inspected` forms, `Store`, `UploadSession`, `MultipartReader`, `MultipartWriter` | owned results, caller limits |
| 3 | `raw_pairs`, `decode_into`, `UrlencodedParser`, `MultipartParser`, `Driver`, `encode_part_head` | none after construction; caller buffers |

Every Tier 1 and Tier 2 function runs the Tier 3 engine. There is no second parser to disagree with the first.

---

## Modules

```
src/
  lib.rs              crate docs, re-exports, VERSION, Send/Sync assertions
  error.rs            FormError (Copy), reason enums, Location, status_hint, code
  limits.rs           Limits (effective values, memory_bound), Policy and its enums
  kind.rs             FormKind::detect over parse-net's media-type grammar
  scan.rs             SWAR byte search shared by both formats
  hook.rs             Inspector (and chains), Verdict, Refusal, Sink, DiscardSink, MemorySink
  wipe.rs             crypt-io's volatile wipe, wiping growth, WipeVec
  random.rs           (random) 128-bit hex from the OS random source
  io.rs               (std) MultipartReader, PartReader
  urlencoded/
    mod.rs            parse_urlencoded*, parse_query, pairs, Form
    raw.rs            raw_pairs, RawPair, the shared per-sequence Budget
    stream.rs         UrlencodedParser
    decode.rs         decode_into: + and %XX in one pass, then UTF-8
    encode.rs         serialize_urlencoded, Serializer, encode_into
  multipart/
    mod.rs            parse_multipart*, Multipart, Part
    boundary.rs       Boundary: validation, delimiter, KMP failure table, generate
    parser.rs         MultipartParser state machine, Step, Event, PartHead
    header.rs         header blocks, Content-Disposition, part budgets
    filename.rs       FileName views
    driver.rs         Driver: the one implementation of the hook order
    owned_head.rs     OwnedPartHead
    writer.rs         encode_part_head, MultipartWriter
  extract/
    mod.rs            FromForm, FormBuilder, FromField, FileValue, FormErrors
    value.rs          FromField for the value types
    secret.rs         Secret
    file.rs           FileData
    macros.rs         form!
  store/              (fs)
    mod.rs            Store, StoreSink, StoredFile, Upload, UploadSession, read_multipart
    temp.rs           private directory, random names, platform open flags
    quota.rs          SpillBudget, SpillQuota
    sweep.rs          sweep
```

---

## urlencoded

`raw_pairs` splits on `&` only, finds `=` `%` `+` in the same SWAR pass, and hands out borrowed sequences. A `Budget` (field count, name length, value length, text total) admits each complete sequence. The decoder turns `+` and `%XX` into bytes and checks UTF-8 in one pass, into a caller buffer or a `Cow`.

`UrlencodedParser` is the same scanner over chunks. A sequence that ends inside a chunk is borrowed from it; one that straddles chunks is copied into a scratch buffer of `max_name_bytes + max_value_bytes + 1` bytes. Before any byte is held, `Budget::admit_partial` checks what is known so far and, when the complete sequence is certain to be refused, refuses it now with the error `admit` would give. So the scratch never overflows, and pairs and errors are the same for every split. The body budget is enforced on bytes consumed: pairs whose `&` lies inside the budget are emitted, then `Limit { Body }`.

## multipart

`MultipartParser` is a state machine over a streaming KMP matcher for `CRLF--boundary`. The matcher starts in state 2, as if the CRLF before the first delimiter had been seen, so a body that opens with the delimiter and one with a preamble take the same path. From state 0 it skips eight bytes at a time while none is a CR. Every byte is examined a bounded number of times whatever the split (T-4).

Each header block is buffered whole (up to `max_part_header_bytes`) and read by `header.rs` through parse-net: one `Content-Disposition` of type `form-data`, quoted strings read with backslash as a literal and `\"` refused, starred parameters other than `filename*` refused, and the strictness rules of `API.md`. Errors carry split-invariant locations (D-080).

The buffered API is the streaming parser fed the whole body. Part data and names borrow the body.

## Hooks and the driver

The `Driver` owns the hook order, so no caller can get it wrong:

```
PartStart ─▶ Inspector::part_start
first sniff_bytes of a file ─▶ Inspector::sniff
Data ─▶ Inspector::data ─▶ (file) Sink::open on the first accepted byte, then Sink::write
                         └▶ (text) buffered for Inspector::field
PartEnd ─▶ Inspector::field (text) ─▶ Sink::close (file) ─▶ Inspector::part_end
End ─▶ Inspector::end
any error or Refuse ─▶ Sink::abort ─▶ Inspector::failed (once)
```

The sink is opened lazily (D-089): a part refused or skipped by the inspector makes no sink call at all. `UploadSession`, `Upload::parse_with`, `parse_multipart_inspected`, and `FromForm::from_body_inspected` all run this one driver, or follow the same order over a buffered body. Inspectors chain as pairs: `(customs, (sentinel, app))`, the first refusal winning.

## Storage

`Store` is shared state behind an `Arc`: the private directory (created once, or lazily for one-shot parses), counters, and an optional `SpillBudget`. Each request gets a `StoreSink`. A file stays in memory up to the threshold, in a buffer capped at the most it can hold; past it the file spills to `upload-<32 hex>.part`, opened with `create_new`, owner-only on Unix, and in the default anonymous mode unlinked (Unix) or opened delete-on-close with no sharing (Windows). Before every disk write the sink checks the request's `max_disk_bytes` and reserves from the shared budget; every reserved byte is released once, when its file is deleted or persisted. `StoredFile` reads with positional reads, so readers do not share a cursor. `persist` never overwrites and removes a partial copy on failure. `sweep` removes named-mode leftovers at startup. Platform details are in [`PLATFORM-NOTES.md`](./PLATFORM-NOTES.md).

## Typed extraction

`form!` is a `macro_rules!` macro: it emits the struct as written and a builder inside an unnameable `const _: () = { ... }` block. The builder holds one `FromField::Slot` per field; names are matched with a chain of byte comparisons. Arity rules live in the slots (`Option<T>` refuses a second value, `Vec<T>` pushes, `bool` finishes absent as `false`). `finish_all` collects every field's error for re-rendering a form; `file_field` lets `from_upload_mut` move only declared files out of an upload.

## The client side

`encode_part_head` checks every input before writing a byte, then writes the delimiter, the `Content-Disposition` line with the browser escapes (`%0A`, `%0D`, `%22`), the optional `Content-Type`, and the blank line. It refuses what could read back differently: other control bytes, a backslash in a name, a trailing backslash in a file name, a literal `%0A` `%0D` `%22`, a content type parse-net does not accept or that is `multipart/*`. `MultipartWriter` adds CRLF normalisation for text and, under a caller boundary, runs the parser's own matcher over the data to refuse a delimiter inside it. The property test and the `fuzz_writer_roundtrip` target hold it to "every body parses back to the same fields".

## Memory

`Limits::memory_bound` is the worst case of one parse, term by term (`limits.rs`), and `tests/memory_bound.rs` measures hostile bodies against it with a peak-tracking allocator. Buffers that grow do so through `append_wiping`, which wipes the old allocation after each move; the bound includes half the largest growing buffer for that move. Owned values that can hold form bytes are wiped on drop with crypt-io's volatile wipe (D-083).