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))))