# Streaming multipart/form-data reader. # # Why this exists: HTTP.jl's `parse_multipart_form` takes the *complete* request # body as a byte vector, so using it means every file in the request sits in # memory at once — and is then copied again per part. That contradicts the whole # point of this service: file bytes belong on disk, and only a small reference # travels through the queue. So intake needs a parser that never holds a file. # # This reader walks the body incrementally: it pulls fixed-size chunks off the # socket and hands each part's bytes straight to a sink (the spool file). Peak # memory per connection is `chunk_bytes` + the boundary length, regardless of how # large — or how many — the uploaded files are. # # Interface: two calls in a loop, so the caller keeps ordinary control flow # rather than inverting into callbacks. # # r = MultipartReader(io, boundary) # while (part = next_part!(r)) !== nothing # part.filename === nothing ? skip_part_body!(r) : write_part_body!(sink, r) # end # # The grammar it implements (RFC 2046 §5.1, RFC 7578): # # [preamble] "--" boundary CRLF # part-headers CRLF CRLF part-body # CRLF "--" boundary CRLF ... another part ... # CRLF "--" boundary "--" CRLF ... end of form, [epilogue] # # So the delimiter that *closes* a body is CRLF + "--" + boundary, and the two # bytes after it say whether another part follows (CRLF) or the form is over # ("--"). Every read is bounded, and the buffer retains only the last # `length(delimiter)-1` bytes when no delimiter is found — that tail is what # makes a delimiter split across two chunks parse correctly. "Default socket read size, and therefore the memory bound per in-flight upload." const UPLOAD_CHUNK_BYTES = 64 * 1024 """ A part header block bigger than this is abuse, not a filename. Bounding it keeps the one genuinely unbounded-looking read (headers, which must be buffered whole to be parsed) from being a memory hole. """ const MAX_PART_HEADER_BYTES = 16 * 1024 const CRLF = UInt8[0x0d, 0x0a] const CRLFCRLF = UInt8[0x0d, 0x0a, 0x0d, 0x0a] const DASHDASH = UInt8[0x2d, 0x2d] "A malformed (or truncated) multipart body. Callers turn this into a 400." struct MultipartError <: Exception msg::String end Base.showerror(io::IO, e::MultipartError) = print(io, "MultipartError: ", e.msg) "What a part's headers said about it. `filename === nothing` means a plain form field, not a file." struct MultipartPart name::Union{String,Nothing} filename::Union{String,Nothing} content_type::Union{String,Nothing} end """ MultipartReader(io, boundary; chunk_bytes = UPLOAD_CHUNK_BYTES) An incremental reader over the multipart body arriving on `io`. `boundary` is the value from the request's `Content-Type` header (see [`multipart_boundary`](@ref)). """ mutable struct MultipartReader{I<:IO} io::I dash_boundary::Vector{UInt8} # "--" boundary: opens the first part delimiter::Vector{UInt8} # CRLF "--" boundary: closes every part buf::Vector{UInt8} # rolling window; bounded by chunk_bytes + delimiter pos::Int # next unconsumed index in buf scratch::Vector{UInt8} # reused socket read target, so chunks don't churn the GC chunk_bytes::Int state::Symbol # :preamble | :at_delimiter | :body | :done end function MultipartReader(io::IO, boundary::AbstractString; chunk_bytes::Int = UPLOAD_CHUNK_BYTES) chunk_bytes > 0 || throw(ArgumentError("chunk_bytes must be positive")) isempty(boundary) && throw(MultipartError("empty multipart boundary")) dash_boundary = Vector{UInt8}(codeunits(string("--", boundary))) return MultipartReader(io, dash_boundary, vcat(CRLF, dash_boundary), UInt8[], 1, Vector{UInt8}(undef, chunk_bytes), chunk_bytes, :preamble) end """ multipart_boundary(content_type) -> String | nothing Pull the boundary out of a `multipart/form-data` Content-Type header. Returns `nothing` if the header is missing, is some other media type, or has no boundary — all of which are the same 400 to a caller. """ function multipart_boundary(content_type::Union{AbstractString,Nothing}) content_type === nothing && return nothing occursin(r"^\s*multipart/form-data"i, content_type) || return nothing m = match(r"(?i:\bboundary)=(?:\"([^\"]+)\"|([^\s;]+))", content_type) m === nothing && return nothing return String(something(m[1], m[2])) end # ------------------------------------------------------------------ buffer plumbing "Unconsumed bytes currently buffered." navail(r::MultipartReader) = length(r.buf) - r.pos + 1 "Drop already-consumed bytes so the buffer stays bounded across a long body." function compact!(r::MultipartReader) r.pos == 1 && return nothing n = navail(r) n > 0 && copyto!(r.buf, 1, r.buf, r.pos, n) resize!(r.buf, max(n, 0)) r.pos = 1 return nothing end """ Pull one more chunk off the wire, returning `false` at end of body. `readbytes!` on an `HTTP.Stream` returns at most what remains of the current content-length or chunk, so this is bounded by `chunk_bytes`; `eof` is what advances a chunked-encoded body to its next chunk, hence the guard. """ function fill_more!(r::MultipartReader) eof(r.io) && return false n = readbytes!(r.io, r.scratch, r.chunk_bytes) n == 0 && return false append!(r.buf, view(r.scratch, 1:n)) return true end "Buffer until `needle` is found, returning its range, or `nothing` at end of body." function seek_needle!(r::MultipartReader, needle::Vector{UInt8}; limit::Int = 0) while true idx = findnext(needle, r.buf, r.pos) idx === nothing || return idx # Only the last length(needle)-1 bytes can still be part of a match, but # the caller may need the skipped bytes (a part body), so trimming is the # caller's job — we only enforce the optional limit. limit > 0 && navail(r) > limit && throw(MultipartError("no delimiter within $limit bytes")) compact!(r) fill_more!(r) || return nothing end end "Ensure at least `n` bytes are buffered; `false` if the body ended first." function ensure!(r::MultipartReader, n::Int) while navail(r) < n compact!(r) fill_more!(r) || return false end return true end # Write `r.buf[range]` to `sink`. Goes through `unsafe_write` because # `write(io, ::SubArray{UInt8})` falls back to a byte-at-a-time loop in Base, # which would dominate the cost of a large upload. function emit!(sink::IO, r::MultipartReader, from::Int, to::Int) n = to - from + 1 n <= 0 && return 0 buf = r.buf # GC.@preserve needs a plain symbol, not a field access GC.@preserve buf unsafe_write(sink, pointer(buf, from), UInt(n)) return n end # ------------------------------------------------------------------- parts """ next_part!(r) -> MultipartPart | nothing Advance to the next part and return its headers, or `nothing` at the end of the form. The previous part's body must have been consumed first (with [`write_part_body!`](@ref) or [`skip_part_body!`](@ref)) — the reader cannot skip a body it hasn't been told to, because the body is only bounded by finding the next delimiter. """ function next_part!(r::MultipartReader) r.state === :done && return nothing r.state === :body && throw(MultipartError("the current part's body must be consumed before the next part")) if r.state === :preamble # Discard the preamble (RFC says ignore it) and consume the opening # delimiter. Bounded: real clients send no preamble at all, and an # unbounded scan here would be a way to make us buffer a whole body. idx = seek_needle!(r, r.dash_boundary; limit = MAX_PART_HEADER_BYTES) idx === nothing && throw(MultipartError("no multipart boundary found in body")) r.pos = last(idx) + 1 r.state = :at_delimiter end # Just after a delimiter: "--" ends the form, CRLF introduces another part. ensure!(r, 2) || throw(MultipartError("truncated body after a boundary delimiter")) if view(r.buf, r.pos:r.pos+1) == DASHDASH r.pos += 2 r.state = :done return nothing end skip_linear_whitespace!(r) ensure!(r, 2) || throw(MultipartError("truncated body after a boundary delimiter")) view(r.buf, r.pos:r.pos+1) == CRLF || throw(MultipartError("boundary delimiter is not followed by a line ending")) r.pos += 2 part = read_part_headers!(r) r.state = :body return part end "RFC 2046 allows spaces/tabs between the delimiter and its line ending." function skip_linear_whitespace!(r::MultipartReader) while ensure!(r, 1) && (r.buf[r.pos] == 0x20 || r.buf[r.pos] == 0x09) r.pos += 1 end return nothing end function read_part_headers!(r::MultipartReader) # A part with no headers at all is `CRLF CRLF body`: the empty line comes # immediately, so searching for CRLFCRLF would run past it into the body. if ensure!(r, 2) && view(r.buf, r.pos:r.pos+1) == CRLF r.pos += 2 return MultipartPart(nothing, nothing, nothing) end # The `limit` here bounds *buffering* — it only fires when the headers span # chunks. The explicit length check below is the actual policy, so the rule # doesn't depend on how the body happened to be chunked on the wire. idx = seek_needle!(r, CRLFCRLF; limit = MAX_PART_HEADER_BYTES) idx === nothing && throw(MultipartError("truncated body inside a part's headers")) first(idx) - r.pos > MAX_PART_HEADER_BYTES && throw(MultipartError("part headers exceed $MAX_PART_HEADER_BYTES bytes")) # Copying is fine: the check above bounds this block. block = String(r.buf[r.pos:first(idx)-1]) r.pos = last(idx) + 1 return parse_part_headers(block) end "Unescape the backslash escapes RFC 2045 allows inside a quoted-string." unquote(s::AbstractString) = replace(s, r"\\(.)" => s"\1") function parse_part_headers(block::AbstractString) name = filename = content_type = nothing for line in eachsplit(block, "\r\n") colon = findfirst(':', line) colon === nothing && continue key = lowercase(strip(line[1:colon-1])) value = strip(line[colon+1:end]) if key == "content-disposition" # `\b` matters: it keeps the `name=` pattern from matching inside `filename=`. m = match(r"(?i:\bname)=(?:\"((?:[^\"\\]|\\.)*)\"|([^\s;]+))", value) m === nothing || (name = unquote(String(something(m[1], m[2])))) m = match(r"(?i:\bfilename)=(?:\"((?:[^\"\\]|\\.)*)\"|([^\s;]+))", value) m === nothing || (filename = unquote(String(something(m[1], m[2])))) elseif key == "content-type" content_type = String(value) end end return MultipartPart(name, filename, content_type) end """ write_part_body!(sink, r) -> Int Stream the current part's body into `sink`, returning the number of bytes written. Nothing larger than a chunk is ever held in memory. """ function write_part_body!(sink::IO, r::MultipartReader) r.state === :body || throw(MultipartError("no part body is open")) total = 0 keep = length(r.delimiter) - 1 # a delimiter may straddle two chunks while true idx = findnext(r.delimiter, r.buf, r.pos) if idx !== nothing total += emit!(sink, r, r.pos, first(idx) - 1) r.pos = last(idx) + 1 r.state = :at_delimiter return total end # Emit only what cannot be the start of a straddling delimiter, then # keep that tail and read more. emit_to = length(r.buf) - keep if emit_to >= r.pos total += emit!(sink, r, r.pos, emit_to) r.pos = emit_to + 1 end compact!(r) fill_more!(r) || throw(MultipartError("truncated body inside a part")) end end "Consume and discard the current part's body (a form field, or a file we can't take)." skip_part_body!(r::MultipartReader) = write_part_body!(devnull, r)