Commit57102322Recorded6 Sep 2026Repositorysigil-http

Preserve raw multipart file bytes and bound parser framing; release 0.18.7

Changed
 README.md                     | 10 ++++++++++
 package.sgl                   |  2 +-
 src/sigil/http/request.sgl    | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 test/test-multipart-bytes.sgl | 40 ++++++++++++++++++++++++++++++++++++++++
 4 files changed, 145 insertions(+), 2 deletions(-)
Diff
README.mdmodified
@@ -78,3 +78,13 @@ Otherwise, let `sigil deps install` pull the pinned monorepo tag.
78
## License
79
80
BSD-3-Clause. See `package.sgl` for author list.
+81
+82
### Binary multipart forms
+83
+84
`parse-form-data` prefers a request's raw bytes for multipart input. File parts
+85
return a bytevector in `content:`; ordinary fields are independently decoded as
+86
UTF-8. Consumers that previously treated uploaded file content as text should
+87
decode explicitly only for text formats. `parse-multipart-form-data-bytes` is
+88
also available directly. It requires CRLF framing and a closing delimiter, with
+89
a maximum of 128 parts and 16 KiB of headers per part. The server request-size
+90
limit still controls the total body. The older string parser remains available.
package.sglmodified
@@ -5,7 +5,7 @@
5
6
(package
7
name: "sigil-http"
−8
version: "0.18.6"
+8
version: "0.18.7"
9
sigil: "0.20"
10
description: "HTTP client and server library for Sigil"
11
url: "https://codeberg.org/sigil/sigil-http"
src/sigil/http/request.sglmodified
@@ -5,6 +5,7 @@
5
6
(define-library (sigil http request)
7
(import (sigil core)
+8
(sigil math)
9
(sigil io)
10
(sigil string)
11
(sigil struct))
@@ -44,6 +45,7 @@
45
url-decode
46
parse-form-urlencoded
47
parse-multipart-form-data
+48
parse-multipart-form-data-bytes
49
extract-multipart-boundary
50
parse-form-data)
51
@@ -550,6 +552,94 @@
552
(parse-multipart-part part))))
553
parts))))
554
+555
;; Find an exact byte sequence without decoding a file body as text.
+556
(define (multipart-match? body pattern at)
+557
(and (<= (+ at (bytevector-length pattern)) (bytevector-length body))
+558
(let loop ((i 0))
+559
(or (= i (bytevector-length pattern))
+560
(and (= (bytevector-u8-ref body (+ at i))
+561
(bytevector-u8-ref pattern i))
+562
(loop (+ i 1)))))))
+563
+564
(define (multipart-find body pattern start limit)
+565
(let loop ((i start))
+566
(cond
+567
((> (+ i (bytevector-length pattern)) limit) #f)
+568
((multipart-match? body pattern i) i)
+569
(else (loop (+ i 1))))))
+570
+571
;;; Parse raw multipart bytes. File content is a bytevector; regular fields
+572
;;; are decoded independently as UTF-8. Requires CRLF MIME framing and a
+573
;;; closing delimiter. Caps header size at 16 KiB and part count at 128.
+574
;;; The server's max-request-size bounds the complete body.
+575
(define (parse-multipart-form-data-bytes body boundary)
+576
(unless (and (bytevector? body)
+577
(string? boundary)
+578
(<= 1 (string-length boundary) 70)
+579
(every (lambda (c)
+580
(and (char>=? c #\space)
+581
(char<? c (integer->char 127))))
+582
(string->list boundary))
+583
(not (char=? (string-ref boundary (- (string-length boundary) 1))
+584
#\space)))
+585
(error "Invalid multipart boundary"))
+586
(let* ((delimiter (string->utf8 (string-append "\r\n--" boundary)))
+587
(opening (string->utf8 (string-append "--" boundary)))
+588
(crlf (string->utf8 "\r\n"))
+589
(closing (string->utf8 "--"))
+590
(separator (string->utf8 "\r\n\r\n"))
+591
(size (bytevector-length body)))
+592
(unless (multipart-match? body opening 0)
+593
(error "Malformed multipart opening"))
+594
(let loop ((at (bytevector-length opening)) (parts '()) (count 0))
+595
(cond
+596
((multipart-match? body closing at)
+597
(unless (or (= (+ at 2) size)
+598
(and (= (+ at 4) size)
+599
(multipart-match? body crlf (+ at 2))))
+600
(error "Malformed multipart ending"))
+601
(reverse parts))
+602
(else
+603
(unless (and (< count 128) (multipart-match? body crlf at))
+604
(error "Malformed multipart framing or too many parts"))
+605
(let* ((start (+ at 2))
+606
(header-end (multipart-find body separator start
+607
(min size (+ start 16384)))))
+608
(unless header-end
+609
(error "Multipart headers missing or too large"))
+610
(let* ((headers (parse-part-headers
+611
(string-split
+612
(utf8->string (bytevector-copy body start header-end))
+613
"\r\n")))
+614
(disposition (dict-ref headers content-disposition: #f))
+615
(params (and disposition (parse-content-disposition disposition)))
+616
(name (and params (dict-ref params name: #f)))
+617
(filename (and params (dict-ref params filename: #f)))
+618
(content-start (+ header-end 4))
+619
(end (let find ((position content-start))
+620
(let ((candidate (multipart-find body delimiter position size)))
+621
(if (not candidate)
+622
#f
+623
(let ((next (+ candidate (bytevector-length delimiter))))
+624
(if (or (multipart-match? body crlf next)
+625
(multipart-match? body closing next))
+626
candidate
+627
(find (+ candidate 1)))))))))
+628
(unless (and name (> (string-length name) 0) end)
+629
(error "Malformed multipart part"))
+630
(let ((content (bytevector-copy body content-start end)))
+631
(loop (+ end (bytevector-length delimiter))
+632
(cons (cons (string->symbol name)
+633
(if filename
+634
(dict name: name
+635
filename: filename
+636
content-type: (or (dict-ref headers content-type: #f)
+637
"application/octet-stream")
+638
content: content)
+639
(utf8->string content)))
+640
parts)
+641
(+ count 1))))))))))
+642
643
;;; Parse form data from request, auto-detecting format.
644
;;;
645
;;; Handles both application/x-www-form-urlencoded and multipart/form-data.
@@ -569,7 +659,10 @@
659
'())
660
((string-starts-with? content-type "multipart/form-data")
661
(let ((boundary (extract-multipart-boundary content-type)))
−572
(parse-multipart-form-data body boundary)))
+662
(if (bytevector? (http-request-raw-body req))
+663
(parse-multipart-form-data-bytes (http-request-raw-body req) boundary)
+664
(if (bytevector? body) (parse-multipart-form-data-bytes body boundary)
+665
(parse-multipart-form-data body boundary)))))
666
((string-starts-with? content-type "application/x-www-form-urlencoded")
667
(parse-form-urlencoded body))
668
(else '()))))
test/test-multipart-bytes.sgladded
@@ -0,0 +1,40 @@
+1
(import (sigil core)
+2
(sigil io)
+3
(sigil test)
+4
(sigil http request))
+5
+6
(define (wrap bytes ending)
+7
(bytevector-append (string->utf8 "--boundary\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\né雪\r\n--boundary\r\nContent-Disposition: form-data; name=\"logo\"; filename=\"test.png\"\r\nContent-Type: image/png\r\n\r\n") bytes (string->utf8 ending)))
+8
(define binary (make-bytevector 256))
+9
(let loop ((i 0))
+10
(when (< i 256)
+11
(bytevector-u8-set! binary i i)
+12
(loop (+ i 1))))
+13
(test-group "byte multipart"
+14
(test "UTF-8 fields decode independently of arbitrary file bytes"
+15
(let* ((parts (parse-multipart-form-data-bytes (wrap binary "\r\n--boundary--\r\n") "boundary"))
+16
(file (dict-ref (cdr (assoc 'logo parts)) content:)))
+17
(assert-equal (cdr (assoc 'title parts)) "é雪")
+18
(assert-equal (bytevector-length file) 256)
+19
(let loop ((i 0))
+20
(when (< i 256)
+21
(assert-equal (bytevector-u8-ref file i) i)
+22
(loop (+ i 1))))))
+23
(test "file trailing CRLF and boundary lookalikes are retained"
+24
(let* ((payload "x\r\n--boundaryX\r\n ")
+25
(parts (parse-multipart-form-data-bytes (wrap (string->utf8 payload) "\r\n--boundary--") "boundary")))
+26
(assert-equal (utf8->string (dict-ref (cdr (assoc 'logo parts)) content:)) payload)))
+27
(test "empty file stays a zero-length bytevector"
+28
(let ((parts (parse-multipart-form-data-bytes (wrap (make-bytevector 0) "\r\n--boundary--\r\n") "boundary")))
+29
(assert-equal (bytevector-length (dict-ref (cdr (assoc 'logo parts)) content:)) 0)))
+30
(test "truncated and malformed boundaries fail"
+31
(assert-error (parse-multipart-form-data-bytes (wrap binary "") "boundary"))
+32
(assert-error (parse-multipart-form-data-bytes (wrap binary "\r\n--boundary--oops") "boundary"))
+33
(assert-error (parse-multipart-form-data-bytes binary "")))
+34
(test "auto parser uses raw bytes even if body text was decoded"
+35
(let* ((raw (wrap binary "\r\n--boundary--\r\n"))
+36
(req (http-request method: 'POST path: "/" query: #f version: "HTTP/1.1"
+37
headers: #{ content-type: "multipart/form-data; boundary=boundary" }
+38
body: "damaged text" raw-body: raw))
+39
(parts (parse-form-data req)))
+40
(assert-equal (bytevector-length (dict-ref (cdr (assoc 'logo parts)) content:)) 256))))