mirror of
https://github.com/leanprover/lean4.git
synced 2026-03-17 18:34:06 +00:00
refactor: head -> line
This commit is contained in:
@@ -263,9 +263,9 @@ private def handle
|
||||
| .close =>
|
||||
pure ()
|
||||
|
||||
if let some head := pendingHead then
|
||||
if let some line := pendingHead then
|
||||
waitingResponse := true
|
||||
let newResponse := Handler.onRequest handler { head, body := requestIncoming, extensions := connection.extensions } connectionContext
|
||||
let newResponse := Handler.onRequest handler { line, body := requestIncoming, extensions := connection.extensions } connectionContext
|
||||
let task ← newResponse.asTask
|
||||
BaseIO.chainTask task fun x => discard <| response.send x
|
||||
pendingHead := none
|
||||
@@ -376,11 +376,11 @@ private def handle
|
||||
machine := machine.setKnownSize knownSize
|
||||
|
||||
let head ← do
|
||||
if config.generateDate ∧ ¬res.head.headers.contains Header.Name.date then
|
||||
if config.generateDate ∧ ¬res.line.headers.contains Header.Name.date then
|
||||
let now ← Std.Time.DateTime.now (tz := .UTC)
|
||||
pure { res.head with headers := res.head.headers.insert Header.Name.date (Header.Value.ofString! now.toRFC822String) }
|
||||
pure { res.line with headers := res.line.headers.insert Header.Name.date (Header.Value.ofString! now.toRFC822String) }
|
||||
else
|
||||
pure res.head
|
||||
pure res.line
|
||||
machine := machine.send head
|
||||
waitingResponse := false
|
||||
if machine.writer.omitBody then
|
||||
|
||||
@@ -327,7 +327,7 @@ def echoBodyHandler : TestHandler := fun req => do
|
||||
let mut body := ByteArray.empty
|
||||
for chunk in req.body do
|
||||
body := body ++ chunk.data
|
||||
Response.ok |>.text s!"{toString req.head.uri}:{String.fromUTF8! body}")
|
||||
Response.ok |>.text s!"{toString req.line.uri}:{String.fromUTF8! body}")
|
||||
|
||||
assertStatusCount "Pipelined parse after exact CL" response 2
|
||||
assertContains "Pipelined first response" response "/:hello"
|
||||
|
||||
@@ -35,7 +35,7 @@ structure TestCase where
|
||||
deriving Inhabited
|
||||
|
||||
def toByteArray (req : Request (Array Chunk)) (chunked := false) : IO ByteArray := Async.block do
|
||||
let mut data := Internal.Encode.encode (v := .v11) .empty req.head
|
||||
let mut data := Internal.Encode.encode (v := .v11) .empty req.line
|
||||
let toByteArray (chunkData : Internal.ChunkedBuffer) (part : Chunk) := Internal.Encode.encode .v11 chunkData part
|
||||
|
||||
for part in req.body do data := toByteArray data part
|
||||
@@ -79,18 +79,18 @@ def runTestCase (tc : TestCase) : IO Unit := do
|
||||
|
||||
/-- Check if request is a basic GET requests to the specified URI and host. -/
|
||||
def isBasicGetRequest (req : Request Body.Incoming) (uri : String) (host : String) : Bool :=
|
||||
req.head.method == .get ∧
|
||||
req.head.version == .v11 ∧
|
||||
toString req.head.uri = uri ∧
|
||||
req.head.headers.hasEntry (.mk "host") (.ofString! host)
|
||||
req.line.method == .get ∧
|
||||
req.line.version == .v11 ∧
|
||||
toString req.line.uri = uri ∧
|
||||
req.line.headers.hasEntry (.mk "host") (.ofString! host)
|
||||
|
||||
/-- Check if request has a specific Content-Length header. -/
|
||||
def hasContentLength (req : Request Body.Incoming) (length : String) : Bool :=
|
||||
req.head.headers.hasEntry (.mk "content-length") (.ofString! length)
|
||||
req.line.headers.hasEntry (.mk "content-length") (.ofString! length)
|
||||
|
||||
/-- Check if request uses chunked transfer encoding. -/
|
||||
def isChunkedRequest (req : Request Body.Incoming) : Bool :=
|
||||
if let some te := req.head.headers.get? (.mk "transfer-encoding") then
|
||||
if let some te := req.line.headers.get? (.mk "transfer-encoding") then
|
||||
match Header.TransferEncoding.parse te with
|
||||
| some te => te.isChunked
|
||||
| none => false
|
||||
@@ -100,17 +100,17 @@ def isChunkedRequest (req : Request Body.Incoming) : Bool :=
|
||||
/-- Check if request has a specific header with a specific value. -/
|
||||
def hasHeader (req : Request Body.Incoming) (name : String) (value : String) : Bool :=
|
||||
if let some name := Header.Name.ofString? name then
|
||||
req.head.headers.hasEntry name (.ofString! value)
|
||||
req.line.headers.hasEntry name (.ofString! value)
|
||||
else
|
||||
false
|
||||
|
||||
/-- Check if request method matches the expected method. -/
|
||||
def hasMethod (req : Request Body.Incoming) (method : Method) : Bool :=
|
||||
req.head.method == method
|
||||
req.line.method == method
|
||||
|
||||
/-- Check if request URI matches the expected URI string. -/
|
||||
def hasUri (req : Request Body.Incoming) (uri : String) : Bool :=
|
||||
toString req.head.uri = uri
|
||||
toString req.line.uri = uri
|
||||
|
||||
-- Tests
|
||||
|
||||
@@ -412,7 +412,7 @@ def hasUri (req : Request Body.Incoming) (uri : String) : Bool :=
|
||||
|>.body #[]
|
||||
|
||||
handler := fun req => do
|
||||
Response.ok |>.text (toString (toString req.head.uri).length)
|
||||
Response.ok |>.text (toString (toString req.line.uri).length)
|
||||
|
||||
expected := "HTTP/1.1 400 Bad Request\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
}
|
||||
@@ -429,7 +429,7 @@ def hasUri (req : Request Body.Incoming) (uri : String) : Bool :=
|
||||
|>.body #[]
|
||||
|
||||
handler := fun req => do
|
||||
Response.ok |>.text (toString (toString req.head.uri).length)
|
||||
Response.ok |>.text (toString (toString req.line.uri).length)
|
||||
|
||||
expected := "HTTP/1.1 400 Bad Request\x0d\nServer: LeanHTTP/1.1\x0d\nConnection: close\x0d\nContent-Length: 0\x0d\n\x0d\n"
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ def runPipelinedReadAll
|
||||
let seenRef ← IO.mkRef (#[] : Array String)
|
||||
|
||||
let handler : TestHandler := fun req => do
|
||||
let uri := toString req.head.uri
|
||||
let uri := toString req.line.uri
|
||||
seenRef.modify (·.push uri)
|
||||
let _body : String ← req.body.readAll
|
||||
Response.ok |>.text uri
|
||||
|
||||
235
tests/lean/run/async_http_h1_compliance.lean
Normal file
235
tests/lean/run/async_http_h1_compliance.lean
Normal file
@@ -0,0 +1,235 @@
|
||||
import Std.Internal.Http
|
||||
|
||||
open Std Http
|
||||
open Std.Http.Protocol.H1
|
||||
|
||||
private def ensure (name : String) (cond : Bool) (msg : String) : IO Unit := do
|
||||
unless cond do
|
||||
throw <| IO.userError s!"Test '{name}' failed:\n{msg}"
|
||||
|
||||
private def hasFailedEvent (events : Array (Event .receiving)) : Bool :=
|
||||
events.any fun
|
||||
| .failed _ => true
|
||||
| _ => false
|
||||
|
||||
private def runOneStep
|
||||
(payload : ByteArray)
|
||||
(config : Protocol.H1.Config := {}) :
|
||||
Machine .receiving × StepResult .receiving :=
|
||||
let machine0 : Machine .receiving := { config := config }
|
||||
(machine0.feed payload).step
|
||||
|
||||
private def assertFailedWith
|
||||
(name : String)
|
||||
(payload : ByteArray)
|
||||
(expected : Error)
|
||||
(config : Protocol.H1.Config := {}) : IO Unit := do
|
||||
let (machine, step) := runOneStep payload config
|
||||
ensure name (hasFailedEvent step.events) s!"expected failure event, events:\n{repr step.events}"
|
||||
ensure name (machine.error == some expected)
|
||||
s!"expected error {repr expected}, got {repr machine.error}"
|
||||
|
||||
private def assertMachineSucceeds
|
||||
(name : String)
|
||||
(payload : ByteArray)
|
||||
(config : Protocol.H1.Config := {}) : IO Unit := do
|
||||
let (machine, step) := runOneStep payload config
|
||||
ensure name (machine.error.isNone)
|
||||
s!"expected machine success but it failed with {repr machine.error}\nevents:\n{repr step.events}"
|
||||
|
||||
-- =========================================================================
|
||||
-- HTTP/1.0 transfer-encoding (RFC 2068 §19.4.6)
|
||||
-- Chunked transfer encoding is not defined for HTTP/1.0.
|
||||
-- =========================================================================
|
||||
|
||||
-- HTTP/1.0 + Transfer-Encoding: chunked must be rejected.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"HTTP/1.0 chunked TE rejected"
|
||||
("POST / HTTP/1.0\r\nHost: example.com\r\n" ++
|
||||
"Transfer-Encoding: chunked\r\n\r\n" ++
|
||||
"5\r\nhello\r\n0\r\n\r\n").toUTF8
|
||||
.badMessage
|
||||
|
||||
-- HTTP/1.0 + Transfer-Encoding: gzip, chunked (compound) must also be rejected.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"HTTP/1.0 compound chunked TE rejected"
|
||||
("POST / HTTP/1.0\r\nHost: example.com\r\n" ++
|
||||
"Transfer-Encoding: gzip, chunked\r\n\r\n" ++
|
||||
"5\r\nhello\r\n0\r\n\r\n").toUTF8
|
||||
.badMessage
|
||||
|
||||
-- HTTP/1.1 + Transfer-Encoding: chunked must be accepted.
|
||||
#eval show IO Unit from do
|
||||
assertMachineSucceeds
|
||||
"HTTP/1.1 chunked TE accepted"
|
||||
("POST / HTTP/1.1\r\nHost: example.com\r\n" ++
|
||||
"Transfer-Encoding: chunked\r\n\r\n" ++
|
||||
"5\r\nhello\r\n0\r\n\r\n").toUTF8
|
||||
|
||||
-- =========================================================================
|
||||
-- HTTP/1.0 Host header (RFC 2616 §14.23)
|
||||
-- Host is optional in HTTP/1.0; required only in HTTP/1.1 (RFC 9112 §3.2).
|
||||
-- =========================================================================
|
||||
|
||||
-- HTTP/1.0 GET without a Host header must be accepted.
|
||||
#eval show IO Unit from do
|
||||
assertMachineSucceeds
|
||||
"HTTP/1.0 GET without Host accepted"
|
||||
"GET / HTTP/1.0\r\n\r\n".toUTF8
|
||||
|
||||
-- HTTP/1.0 POST without a Host header must be accepted.
|
||||
#eval show IO Unit from do
|
||||
assertMachineSucceeds
|
||||
"HTTP/1.0 POST without Host accepted"
|
||||
"POST / HTTP/1.0\r\nContent-Length: 0\r\n\r\n".toUTF8
|
||||
|
||||
-- HTTP/1.0 with a Host header must also be accepted.
|
||||
#eval show IO Unit from do
|
||||
assertMachineSucceeds
|
||||
"HTTP/1.0 with Host accepted"
|
||||
"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
|
||||
-- HTTP/1.1 without a Host header must be rejected.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"HTTP/1.1 without Host rejected"
|
||||
"GET / HTTP/1.1\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- =========================================================================
|
||||
-- Keep-alive persistence
|
||||
-- RFC 2616 §19.7.1: HTTP/1.0 + Connection: keep-alive requests persistence.
|
||||
-- RFC 9112: HTTP/1.1 is persistent by default; Connection: close disables it.
|
||||
-- =========================================================================
|
||||
|
||||
-- HTTP/1.0 + Connection: keep-alive must enable persistence.
|
||||
#eval show IO Unit from do
|
||||
let payload :=
|
||||
"GET / HTTP/1.0\r\nHost: example.com\r\nConnection: keep-alive\r\n\r\n".toUTF8
|
||||
let machine0 : Machine .receiving := { config := {} }
|
||||
let (machine, step) := (machine0.feed payload).step
|
||||
ensure
|
||||
"HTTP/1.0 Connection: keep-alive enables keepAlive"
|
||||
machine.keepAlive
|
||||
s!"expected keepAlive=true for HTTP/1.0 + Connection: keep-alive\n{repr step.events}"
|
||||
|
||||
-- HTTP/1.0 without Connection: keep-alive must be non-persistent.
|
||||
#eval show IO Unit from do
|
||||
let payload := "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
let machine0 : Machine .receiving := { config := {} }
|
||||
let (machine, _) := (machine0.feed payload).step
|
||||
ensure
|
||||
"HTTP/1.0 bare request is non-persistent"
|
||||
(!machine.keepAlive)
|
||||
"expected keepAlive=false for plain HTTP/1.0 request"
|
||||
|
||||
-- HTTP/1.1 keep-alive defaults to true.
|
||||
#eval show IO Unit from do
|
||||
let payload := "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
let machine0 : Machine .receiving := { config := {} }
|
||||
let (machine, step) := (machine0.feed payload).step
|
||||
ensure
|
||||
"HTTP/1.1 keepAlive defaults to true"
|
||||
machine.keepAlive
|
||||
s!"expected keepAlive=true for HTTP/1.1 request\n{repr step.events}"
|
||||
|
||||
-- HTTP/1.1 + Connection: close must disable keep-alive.
|
||||
#eval show IO Unit from do
|
||||
let payload :=
|
||||
"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n".toUTF8
|
||||
let machine0 : Machine .receiving := { config := {} }
|
||||
let (machine, _) := (machine0.feed payload).step
|
||||
ensure
|
||||
"HTTP/1.1 Connection: close disables keepAlive"
|
||||
(!machine.keepAlive)
|
||||
"expected keepAlive=false for HTTP/1.1 + Connection: close"
|
||||
|
||||
-- =========================================================================
|
||||
-- RFC 9112 §2.2: bare LF in header section must be rejected.
|
||||
-- =========================================================================
|
||||
|
||||
-- Header section ending with CRLF + bare LF (instead of two CRLFs) is rejected.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"Bare LF blank line rejected"
|
||||
"GET / HTTP/1.1\r\nHost: example.com\r\n\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- Bare LF blank line with a body following must also be rejected.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"Bare LF blank line with body rejected"
|
||||
("POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\nhello").toUTF8
|
||||
.badMessage
|
||||
|
||||
-- =========================================================================
|
||||
-- Regression: well-established behaviours must not regress.
|
||||
-- =========================================================================
|
||||
|
||||
-- Content-Length + Transfer-Encoding together must be rejected (request smuggling guard).
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"CL + TE together rejected"
|
||||
("POST / HTTP/1.1\r\nHost: example.com\r\n" ++
|
||||
"Content-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n" ++
|
||||
"5\r\nhello\r\n0\r\n\r\n").toUTF8
|
||||
.badMessage
|
||||
|
||||
-- Duplicate Host header must be rejected.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"Duplicate Host rejected"
|
||||
"GET / HTTP/1.1\r\nHost: a.example\r\nHost: b.example\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- CONNECT requires authority-form target.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"CONNECT with non-authority target rejected"
|
||||
"CONNECT / HTTP/1.1\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- Asterisk-form is reserved for OPTIONS only.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"GET * rejected"
|
||||
"GET * HTTP/1.1\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- =========================================================================
|
||||
-- Host/authority matching with default ports.
|
||||
-- =========================================================================
|
||||
|
||||
-- https default-port equivalence: URI omits port, Host uses :443 => accepted.
|
||||
#eval show IO Unit from do
|
||||
assertMachineSucceeds
|
||||
"absolute https + Host :443 accepted"
|
||||
"GET https://a.com/path HTTP/1.1\r\nHost: a.com:443\r\n\r\n".toUTF8
|
||||
|
||||
-- https default-port equivalence: URI uses :443, Host omits port => accepted.
|
||||
#eval show IO Unit from do
|
||||
assertMachineSucceeds
|
||||
"absolute https:443 + Host omitted port accepted"
|
||||
"GET https://a.com:443/path HTTP/1.1\r\nHost: a.com\r\n\r\n".toUTF8
|
||||
|
||||
-- http default-port equivalence: URI omits port, Host uses :80 => accepted.
|
||||
#eval show IO Unit from do
|
||||
assertMachineSucceeds
|
||||
"absolute http + Host :80 accepted"
|
||||
"GET http://a.com/path HTTP/1.1\r\nHost: a.com:80\r\n\r\n".toUTF8
|
||||
|
||||
-- Non-default port mismatch must be rejected.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"absolute https + Host :555 rejected"
|
||||
"GET https://a.com/path HTTP/1.1\r\nHost: a.com:555\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- Explicit non-default URI port must not match omitted Host port.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"absolute https:444 + Host omitted port rejected"
|
||||
"GET https://a.com:444/path HTTP/1.1\r\nHost: a.com\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
@@ -27,6 +27,23 @@ private def hasCloseBodyEvent (events : Array (Event .receiving)) : Bool :=
|
||||
| .closeBody => true
|
||||
| _ => false
|
||||
|
||||
private def hasContinueEvent (events : Array (Event .receiving)) : Bool :=
|
||||
events.any fun
|
||||
| .«continue» => true
|
||||
| _ => false
|
||||
|
||||
private def countNeedAnswerEvents (events : Array (Event .receiving)) : Nat :=
|
||||
events.foldl (init := 0) fun n e =>
|
||||
match e with
|
||||
| .needAnswer => n + 1
|
||||
| _ => n
|
||||
|
||||
private def countFailedEvents (events : Array (Event .receiving)) : Nat :=
|
||||
events.foldl (init := 0) fun n e =>
|
||||
match e with
|
||||
| .failed _ => n + 1
|
||||
| _ => n
|
||||
|
||||
private def pulledBodyBytes (chunks : Array PulledChunk) : ByteArray :=
|
||||
chunks.foldl (fun acc c => acc ++ c.chunk.data) .empty
|
||||
|
||||
@@ -185,6 +202,23 @@ private def assertIncrementalSuccess
|
||||
else
|
||||
pure ()
|
||||
|
||||
private def runOneStepReceiving
|
||||
(payload : ByteArray)
|
||||
(config : Protocol.H1.Config := {}) :
|
||||
Machine .receiving × StepResult .receiving :=
|
||||
let machine0 : Machine .receiving := { config := config }
|
||||
(machine0.feed payload).step
|
||||
|
||||
private def assertFailedWith
|
||||
(name : String)
|
||||
(payload : ByteArray)
|
||||
(expected : Error)
|
||||
(config : Protocol.H1.Config := {}) : IO Unit := do
|
||||
let (machine, step) := runOneStepReceiving payload config
|
||||
ensure name (hasFailedEvent step.events) s!"expected failure event, events:\n{repr step.events}"
|
||||
ensure name (machine.error == some expected)
|
||||
s!"expected error {repr expected}, got {repr machine.error}"
|
||||
|
||||
-- Deterministic: one-byte incremental content-length request.
|
||||
#eval show IO Unit from do
|
||||
let body := "hello".toUTF8
|
||||
@@ -276,3 +310,110 @@ private def assertIncrementalSuccess
|
||||
|
||||
let trace := runIncrementalReceiving parts
|
||||
assertIncrementalSuccess s!"Property chunked #{i}" trace body (expectNeedMoreData := parts.size > 1)
|
||||
|
||||
-- Edge case: unsupported HTTP version in request-line.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"Unsupported HTTP version"
|
||||
"GET / HTTP/2.0\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
.unsupportedVersion
|
||||
|
||||
-- Edge case: URI length limit must map to uriTooLong.
|
||||
#eval show IO Unit from do
|
||||
let cfg : Protocol.H1.Config := { maxUriLength := 8, maxStartLineLength := 256 }
|
||||
assertFailedWith
|
||||
"URI too long"
|
||||
"GET /this/path/is/way/too/long HTTP/1.1\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
.uriTooLong
|
||||
cfg
|
||||
|
||||
-- Edge case: header count limit exceeded.
|
||||
#eval show IO Unit from do
|
||||
let cfg : Protocol.H1.Config := { maxHeaders := 1 }
|
||||
assertFailedWith
|
||||
"Too many headers"
|
||||
"GET / HTTP/1.1\r\nHost: example.com\r\nX-Test: 1\r\n\r\n".toUTF8
|
||||
.tooManyHeaders
|
||||
cfg
|
||||
|
||||
-- Edge case: aggregate header bytes limit exceeded.
|
||||
#eval show IO Unit from do
|
||||
let cfg : Protocol.H1.Config := { maxHeaderBytes := 8 }
|
||||
assertFailedWith
|
||||
"Headers too large"
|
||||
"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
.headersTooLarge
|
||||
cfg
|
||||
|
||||
-- Edge case: duplicate Host headers rejected.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"Duplicate Host rejected"
|
||||
"GET / HTTP/1.1\r\nHost: a.example\r\nHost: b.example\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- Edge case: mixed Content-Length and Transfer-Encoding rejected.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"TE and CL mixed"
|
||||
"POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n0\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- Edge case: CONNECT must use authority-form target.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"CONNECT non-authority target rejected"
|
||||
"CONNECT / HTTP/1.1\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- Edge case: asterisk-form reserved for OPTIONS.
|
||||
#eval show IO Unit from do
|
||||
assertFailedWith
|
||||
"GET * rejected"
|
||||
"GET * HTTP/1.1\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
.badMessage
|
||||
|
||||
-- Edge case: Expect: 100-continue pauses reader and can be accepted/rejected.
|
||||
#eval show IO Unit from do
|
||||
let request :=
|
||||
"POST /expect HTTP/1.1\r\nHost: example.com\r\nExpect: 100-continue\r\nContent-Length: 4\r\n\r\n".toUTF8
|
||||
|
||||
let machine0 : Machine .receiving := { config := {} }
|
||||
let (machine1, step1) := (machine0.feed request).step
|
||||
|
||||
ensure "Expect setup not failed" (!machine1.failed) s!"unexpected failure:\n{repr step1.events}"
|
||||
ensure "Expect emitted continue event" (hasContinueEvent step1.events) s!"missing continue event:\n{repr step1.events}"
|
||||
ensure "Expect reader paused"
|
||||
(match machine1.reader.state with
|
||||
| .«continue» _ => true
|
||||
| _ => false)
|
||||
s!"expected continue state, got {repr machine1.reader.state}"
|
||||
|
||||
let machine2 := machine1.canContinue .«continue»
|
||||
ensure "Expect accepted"
|
||||
(match machine2.reader.state with
|
||||
| .readBody (.fixed 4) => true
|
||||
| _ => false)
|
||||
s!"expected fixed(4) body state, got {repr machine2.reader.state}"
|
||||
|
||||
let machine3 := machine1.canContinue .ok
|
||||
ensure "Expect rejected closes reader"
|
||||
(match machine3.reader.state with
|
||||
| .closed => true
|
||||
| _ => false)
|
||||
s!"expected closed reader, got {repr machine3.reader.state}"
|
||||
ensure "Expect rejected disables keep-alive" (!machine3.keepAlive) "keepAlive should be disabled after reject"
|
||||
|
||||
-- Regression: receiving path should emit needAnswer only once per message.
|
||||
#eval show IO Unit from do
|
||||
let payload := "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
let (_machine, step) := runOneStepReceiving payload
|
||||
ensure "Single needAnswer event" (countNeedAnswerEvents step.events == 1)
|
||||
s!"expected exactly one needAnswer event, got events:\n{repr step.events}"
|
||||
|
||||
-- Regression: malformed request should emit failed only once.
|
||||
#eval show IO Unit from do
|
||||
let payload := "GET / HTTP/2.0\r\nHost: example.com\r\n\r\n".toUTF8
|
||||
let (_machine, step) := runOneStepReceiving payload
|
||||
ensure "Single failed event" (countFailedEvents step.events == 1)
|
||||
s!"expected exactly one failed event, got events:\n{repr step.events}"
|
||||
|
||||
202
tests/lean/run/async_http_h1_parser_fuzz.lean
Normal file
202
tests/lean/run/async_http_h1_parser_fuzz.lean
Normal file
@@ -0,0 +1,202 @@
|
||||
import Std.Internal.Http
|
||||
import Std.Internal.Http.Protocol.H1.Parser
|
||||
|
||||
open Std Internal Parsec ByteArray
|
||||
open Std.Http.Protocol.H1
|
||||
|
||||
private def ensure (name : String) (cond : Bool) (msg : String) : IO Unit := do
|
||||
unless cond do
|
||||
throw <| IO.userError s!"{name}: {msg}"
|
||||
|
||||
private def runParser (p : Parser α) (s : String) : Except String α :=
|
||||
match (p <* eof).run s.toUTF8 with
|
||||
| .ok x => .ok x
|
||||
| .error e => .error e
|
||||
|
||||
private def randBelow (gen : StdGen) (maxExclusive : Nat) : Nat × StdGen :=
|
||||
if maxExclusive = 0 then
|
||||
(0, gen)
|
||||
else
|
||||
randNat gen 0 (maxExclusive - 1)
|
||||
|
||||
private def pick! [Inhabited α] (gen : StdGen) (xs : Array α) : α × StdGen :=
|
||||
let (i, gen') := randBelow gen xs.size
|
||||
(xs[i]!, gen')
|
||||
|
||||
private def randomToken (gen : StdGen) (len : Nat) : String × StdGen := Id.run do
|
||||
let mut g := gen
|
||||
let mut out := ""
|
||||
for _ in [0:len] do
|
||||
let (r, g') := randBelow g 38
|
||||
g := g'
|
||||
let c :=
|
||||
if r < 26 then Char.ofNat (97 + r)
|
||||
else if r < 36 then Char.ofNat (48 + (r - 26))
|
||||
else if r = 36 then '-'
|
||||
else '_'
|
||||
out := out.push c
|
||||
(out, g)
|
||||
|
||||
private def randomReason (gen : StdGen) (len : Nat) : String × StdGen := Id.run do
|
||||
let mut g := gen
|
||||
let mut out := ""
|
||||
for _ in [0:len] do
|
||||
let (r, g') := randBelow g 30
|
||||
g := g'
|
||||
let c := if r < 26 then Char.ofNat (65 + r) else ' '
|
||||
out := out.push c
|
||||
(out.trimAscii.toString, g)
|
||||
|
||||
private def pad3 (n : Nat) : String :=
|
||||
if n < 10 then s!"00{n}" else if n < 100 then s!"0{n}" else s!"{n}"
|
||||
|
||||
private def expectRequestOk (s : String) : IO Unit := do
|
||||
match runParser (parseRequestLine {}) s with
|
||||
| .ok _ => pure ()
|
||||
| .error e => throw <| IO.userError s!"expected request-line success for {s.quote}, got: {e}"
|
||||
|
||||
private def expectRequestFail (s : String) : IO Unit := do
|
||||
match runParser (parseRequestLine {}) s with
|
||||
| .ok _ => throw <| IO.userError s!"expected request-line failure for {s.quote}"
|
||||
| .error _ => pure ()
|
||||
|
||||
private def expectStatusOk (s : String) : IO Unit := do
|
||||
match runParser (parseStatusLine {}) s with
|
||||
| .ok _ => pure ()
|
||||
| .error e => throw <| IO.userError s!"expected status-line success for {s.quote}, got: {e}"
|
||||
|
||||
private def expectStatusFail (s : String) : IO Unit := do
|
||||
match runParser (parseStatusLine {}) s with
|
||||
| .ok _ => throw <| IO.userError s!"expected status-line failure for {s.quote}"
|
||||
| .error _ => pure ()
|
||||
|
||||
private def expectOk {α} (name : String) (p : Parser α) (s : String) : IO α := do
|
||||
match runParser p s with
|
||||
| .ok x => pure x
|
||||
| .error e => throw <| IO.userError s!"{name}: expected success for {s.quote}, got {e}"
|
||||
|
||||
private def expectFail {α} (name : String) (p : Parser α) (s : String) : IO Unit := do
|
||||
match runParser p s with
|
||||
| .ok _ => throw <| IO.userError s!"{name}: expected failure for {s.quote}"
|
||||
| .error _ => pure ()
|
||||
|
||||
#eval show IO Unit from do
|
||||
let methods : Array String := #["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "CONNECT"]
|
||||
let targets : Array String := #["/", "/a", "/a/b", "/a/b?q=1", "*", "http://example.com", "example.com:443"]
|
||||
let versions : Array String := #["HTTP/1.1", "HTTP/1.0"]
|
||||
|
||||
let mut gen : StdGen := StdGen.mk 0x5eed1234 0x12345
|
||||
for i in [0:400] do
|
||||
let (m, g1) := pick! gen methods
|
||||
let (t, g2) := pick! g1 targets
|
||||
let (v, g3) := pick! g2 versions
|
||||
gen := g3
|
||||
|
||||
let line := s!"{m} {t} {v}\r\n"
|
||||
expectRequestOk line
|
||||
|
||||
-- Mutation 1: drop the first space
|
||||
expectRequestFail s!"{m}{t} {v}\r\n"
|
||||
|
||||
-- Mutation 2: invalid version token
|
||||
expectRequestFail s!"{m} {t} HTTP/2.0\r\n"
|
||||
|
||||
-- Mutation 3: bad method character
|
||||
expectRequestFail s!"{m}! {t} {v}\r\n"
|
||||
|
||||
ensure "request fuzz progress" (i < 100000) "unreachable safety check"
|
||||
|
||||
#eval show IO Unit from do
|
||||
let knownCodes : Array Nat := #[200, 201, 204, 301, 400, 404, 500, 503]
|
||||
let mut gen : StdGen := StdGen.mk 0xabcde123 0x777
|
||||
|
||||
for _ in [0:400] do
|
||||
let (code, g1) := pick! gen knownCodes
|
||||
let (len, g2) := randBelow g1 20
|
||||
let (reasonRaw, g3) := randomReason g2 (len + 1)
|
||||
gen := g3
|
||||
let reason := if reasonRaw.isEmpty then "OK" else reasonRaw
|
||||
|
||||
let line := s!"HTTP/1.1 {pad3 code} {reason}\r\n"
|
||||
expectStatusOk line
|
||||
|
||||
-- Mutation 1: unsupported version
|
||||
expectStatusFail s!"HTTP/2.0 {pad3 code} {reason}\r\n"
|
||||
|
||||
-- Mutation 2: non-digit in status code
|
||||
expectStatusFail s!"HTTP/1.1 A{(pad3 code).drop 1} {reason}\r\n"
|
||||
|
||||
-- Mutation 3: illegal reason byte (DEL)
|
||||
expectStatusFail s!"HTTP/1.1 {pad3 code} bad{Char.ofNat 127}\r\n"
|
||||
|
||||
#eval show IO Unit from do
|
||||
-- Randomized malformed gibberish smoke: parser must simply return error or success,
|
||||
-- but never crash/panic.
|
||||
let mut gen : StdGen := StdGen.mk 0x31415926 0x27182818
|
||||
for _ in [0:300] do
|
||||
let (len, g1) := randBelow gen 80
|
||||
let (tok, g2) := randomToken g1 (len + 1)
|
||||
gen := g2
|
||||
let _ := runParser (parseRequestLine {}) (tok ++ "\r\n")
|
||||
let _ := runParser (parseStatusLine {}) (tok ++ "\r\n")
|
||||
pure ()
|
||||
|
||||
-- Component tests for individual parser parts.
|
||||
#eval show IO Unit from do
|
||||
-- parseSingleHeader
|
||||
let sh1 ← expectOk "parseSingleHeader some" (parseSingleHeader {} <* eof) "Host: x\r\n"
|
||||
ensure "parseSingleHeader some present" sh1.isSome "expected some header"
|
||||
|
||||
let sh2 ← expectOk "parseSingleHeader none" (parseSingleHeader {} <* eof) "\r\n"
|
||||
ensure "parseSingleHeader none present" sh2.isNone "expected header terminator"
|
||||
|
||||
-- parseChunkSize / parseChunkPartial
|
||||
let (n1, ext1) ← expectOk "parseChunkSize bare" (parseChunkSize {} <* eof) "A\r\n"
|
||||
ensure "parseChunkSize value" (n1 == 10) "chunk-size mismatch"
|
||||
ensure "parseChunkSize ext empty" (ext1.isEmpty) "expected no extensions"
|
||||
|
||||
let (n2, ext2) ← expectOk "parseChunkSize ext" (parseChunkSize {} <* eof) "4;foo=bar;baz=\"qux\"\r\n"
|
||||
ensure "parseChunkSize ext value" (n2 == 4) "chunk-size mismatch with ext"
|
||||
ensure "parseChunkSize ext count" (ext2.size == 2) "expected 2 extensions"
|
||||
|
||||
let cp1 ← expectOk "parseChunkPartial some" (parseChunkPartial {} <* eof) "4\r\nWiki"
|
||||
ensure "parseChunkPartial some isSome" cp1.isSome "expected chunk data"
|
||||
ensure "parseChunkPartial some size" ((cp1.map (fun (n, _, _) => n)).getD 0 == 4) "size mismatch"
|
||||
|
||||
let cp0 ← expectOk "parseChunkPartial none" (parseChunkPartial {} <* eof) "0\r\n"
|
||||
ensure "parseChunkPartial none isNone" cp0.isNone "expected last-chunk marker"
|
||||
|
||||
-- parseFixedSizeData / parseChunkSizedData
|
||||
let fs1 ← expectOk "parseFixedSizeData complete" (parseFixedSizeData 4 <* eof) "Wiki"
|
||||
ensure "parseFixedSizeData complete shape"
|
||||
(match fs1 with | .complete _ => true | _ => false)
|
||||
"expected complete result"
|
||||
let fs2 ← expectOk "parseFixedSizeData incomplete" (parseFixedSizeData 4 <* eof) "Wi"
|
||||
ensure "parseFixedSizeData incomplete shape"
|
||||
(match fs2 with | .incomplete _ 2 => true | _ => false)
|
||||
"expected incomplete result with remaining=2"
|
||||
|
||||
let cs1 ← expectOk "parseChunkSizedData complete" (parseChunkSizedData 4 <* eof) "Wiki\r\n"
|
||||
ensure "parseChunkSizedData complete shape"
|
||||
(match cs1 with | .complete _ => true | _ => false)
|
||||
"expected complete chunk-sized result"
|
||||
let cs2 ← expectOk "parseChunkSizedData incomplete" (parseChunkSizedData 4 <* eof) "Wi"
|
||||
ensure "parseChunkSizedData incomplete shape"
|
||||
(match cs2 with | .incomplete _ 2 => true | _ => false)
|
||||
"expected incomplete chunk-sized result with remaining=2"
|
||||
|
||||
-- parseTrailers
|
||||
let trailers ← expectOk "parseTrailers ok" (parseTrailers {} <* eof) "X-Test: a\r\nY-Test: b\r\n\r\n"
|
||||
ensure "parseTrailers count" (trailers.size == 2) "expected 2 trailers"
|
||||
expectFail "parseTrailers forbidden" (parseTrailers {} <* eof) "Content-Length: 1\r\n\r\n"
|
||||
|
||||
-- parseRequestLineRawVersion / parseStatusLineRawVersion
|
||||
let (m1, _, v1) ← expectOk "parseRequestLineRawVersion" (parseRequestLineRawVersion {} <* eof) "GET / HTTP/1.1\r\n"
|
||||
ensure "parseRequestLineRawVersion method" (m1 == Std.Http.Method.get) "method mismatch"
|
||||
ensure "parseRequestLineRawVersion version" (v1 == some Std.Http.Version.v11) "expected recognized v11"
|
||||
let (_, rv) ← expectOk "parseStatusLineRawVersion" (parseStatusLineRawVersion {} <* eof) "HTTP/1.1 204 No Content\r\n"
|
||||
ensure "parseStatusLineRawVersion recognized" (rv == some Std.Http.Version.v11) "expected v11"
|
||||
|
||||
-- parseRequestLine / parseStatusLine failures
|
||||
expectFail "parseRequestLine invalid version" (parseRequestLine {} <* eof) "GET / HTTP/2.0\r\n"
|
||||
expectFail "parseStatusLine invalid version" (parseStatusLine {} <* eof) "HTTP/2.0 200 OK\r\n"
|
||||
@@ -104,7 +104,7 @@ def onesChunked (n : Nat) : String := Id.run do
|
||||
|
||||
def ignoreHandler : TestHandler := fun _ => Response.ok |>.text "ok"
|
||||
|
||||
def uriHandler : TestHandler := fun req => Response.ok |>.text (toString req.head.uri)
|
||||
def uriHandler : TestHandler := fun req => Response.ok |>.text (toString req.line.uri)
|
||||
|
||||
def echoBodyHandler : TestHandler := fun req => do
|
||||
let mut body := ByteArray.empty
|
||||
|
||||
@@ -41,7 +41,7 @@ def runPipelined
|
||||
let seenRef ← IO.mkRef (#[] : Array String)
|
||||
|
||||
let handler : TestHandler := fun req => do
|
||||
let uri := toString req.head.uri
|
||||
let uri := toString req.line.uri
|
||||
seenRef.modify (·.push uri)
|
||||
|
||||
let body ←
|
||||
@@ -100,7 +100,7 @@ def assertSeenCount (name : String) (seen : Array String) (expected : Nat) : IO
|
||||
let req1 := "GET /first HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n"
|
||||
let req2 := "GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n"
|
||||
let response ← sendRaw client server (req1 ++ req2).toUTF8 (fun req =>
|
||||
Response.ok |>.text (toString req.head.uri))
|
||||
Response.ok |>.text (toString req.line.uri))
|
||||
|
||||
assertStatusCount "Two keep-alive responses" response 2
|
||||
assertContains "Two keep-alive first" response "/first"
|
||||
@@ -112,7 +112,7 @@ def assertSeenCount (name : String) (seen : Array String) (expected : Nat) : IO
|
||||
let req1 := "GET /first HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n"
|
||||
let req2 := "GET /second HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n"
|
||||
let response ← sendRaw client server (req1 ++ req2).toUTF8 (fun req =>
|
||||
Response.ok |>.text (toString req.head.uri))
|
||||
Response.ok |>.text (toString req.line.uri))
|
||||
|
||||
assertStatusCount "Connection close response count" response 1
|
||||
assertContains "Connection close first served" response "/first"
|
||||
@@ -124,7 +124,7 @@ def assertSeenCount (name : String) (seen : Array String) (expected : Nat) : IO
|
||||
let req1 := "GET /1 HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n"
|
||||
let req2 := "GET /2 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n"
|
||||
let response ← sendRaw client server (req1 ++ req2).toUTF8
|
||||
(fun req => Response.ok |>.text (toString req.head.uri))
|
||||
(fun req => Response.ok |>.text (toString req.line.uri))
|
||||
(config := { lingeringTimeout := 3000, enableKeepAlive := false, generateDate := false })
|
||||
|
||||
assertStatusCount "Keep-alive disabled response count" response 1
|
||||
@@ -138,7 +138,7 @@ def assertSeenCount (name : String) (seen : Array String) (expected : Nat) : IO
|
||||
let req1 := "GET /1 HTTP/1.1\x0d\nHost: example.com\x0d\n\x0d\n"
|
||||
let req2 := "GET /2 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n"
|
||||
let response ← sendRaw client server (req0 ++ req1 ++ req2).toUTF8
|
||||
(fun req => Response.ok |>.text (toString req.head.uri))
|
||||
(fun req => Response.ok |>.text (toString req.line.uri))
|
||||
(config := { lingeringTimeout := 3000, maxRequests := 2, generateDate := false })
|
||||
|
||||
assertStatusCount "maxRequests response count" response 2
|
||||
@@ -152,7 +152,7 @@ def assertSeenCount (name : String) (seen : Array String) (expected : Nat) : IO
|
||||
let req1 := "POST /ignore HTTP/1.1\x0d\nHost: example.com\x0d\nContent-Length: 5\x0d\n\x0d\nhello"
|
||||
let req2 := "GET /after HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n"
|
||||
let response ← sendRaw client server (req1 ++ req2).toUTF8 (fun req =>
|
||||
Response.ok |>.text (toString req.head.uri))
|
||||
Response.ok |>.text (toString req.line.uri))
|
||||
|
||||
assertStatusCount "Unread CL body keep-alive responses" response 2
|
||||
assertContains "Unread CL body first" response "/ignore"
|
||||
@@ -164,7 +164,7 @@ def assertSeenCount (name : String) (seen : Array String) (expected : Nat) : IO
|
||||
let req1 := "POST /chunked HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: chunked\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n"
|
||||
let req2 := "GET /next HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n"
|
||||
let response ← sendRaw client server (req1 ++ req2).toUTF8 (fun req =>
|
||||
Response.ok |>.text (toString req.head.uri))
|
||||
Response.ok |>.text (toString req.line.uri))
|
||||
|
||||
assertStatusCount "Unread chunked body keep-alive responses" response 2
|
||||
assertContains "Unread chunked first" response "/chunked"
|
||||
|
||||
@@ -219,11 +219,6 @@ def notImplemented : String :=
|
||||
let responseA ← sendRaw clientA serverA rawA okHandler
|
||||
assertExact "HTTP/2.0 rejected" responseA bad505
|
||||
|
||||
let (clientB, serverB) ← Mock.new
|
||||
let rawB := "GET / HTTP/1.0\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let responseB ← sendRaw clientB serverB rawB okHandler
|
||||
assertExact "HTTP/1.0 rejected" responseB bad505
|
||||
|
||||
-- Request-line parsing failures.
|
||||
#eval show IO _ from do
|
||||
let (clientA, serverA) ← Mock.new
|
||||
@@ -312,14 +307,19 @@ def notImplemented : String :=
|
||||
assertExact "CONNECT authority-form accepted" responseC ok200
|
||||
|
||||
let (clientD, serverD) ← Mock.new
|
||||
let getAsterisk := "GET * HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let responseD ← sendRaw clientD serverD getAsterisk okHandler
|
||||
assertExact "Asterisk-form rejected for non-OPTIONS" responseD bad400
|
||||
let badAuthorityPort := "CONNECT example.com:444 HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let responseD ← sendRaw clientD serverD badAuthorityPort okHandler
|
||||
assertExact "CONNECT authority-form non-default port mismatch rejected" responseD bad400
|
||||
|
||||
let (clientE, serverE) ← Mock.new
|
||||
let getAsterisk := "GET * HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let responseE ← sendRaw clientE serverE getAsterisk okHandler
|
||||
assertExact "Asterisk-form rejected for non-OPTIONS" responseE bad400
|
||||
|
||||
let (clientF, serverF) ← Mock.new
|
||||
let optionsAsterisk := "OPTIONS * HTTP/1.1\x0d\nHost: example.com\x0d\nConnection: close\x0d\n\x0d\n".toUTF8
|
||||
let responseE ← sendRaw clientE serverE optionsAsterisk okHandler
|
||||
assertExact "Asterisk-form accepted for OPTIONS" responseE ok200
|
||||
let responseF ← sendRaw clientF serverF optionsAsterisk okHandler
|
||||
assertExact "Asterisk-form accepted for OPTIONS" responseF ok200
|
||||
|
||||
-- h11-inspired: GET and HEAD should use the same framing headers.
|
||||
#eval show IO _ from do
|
||||
@@ -448,7 +448,7 @@ def notImplemented : String :=
|
||||
let (clientD, serverD) ← Mock.new
|
||||
let unsupportedTe := "POST / HTTP/1.1\x0d\nHost: example.com\x0d\nTransfer-Encoding: gzip, chunked\x0d\nConnection: close\x0d\n\x0d\n5\x0d\nhello\x0d\n0\x0d\n\x0d\n".toUTF8
|
||||
let responseD ← sendRaw clientD serverD unsupportedTe (fun req => do
|
||||
let seenTe := match req.head.headers.getAll? Header.Name.transferEncoding with
|
||||
let seenTe := match req.line.headers.getAll? Header.Name.transferEncoding with
|
||||
| some values => String.intercalate "|" (values.map (·.value) |>.toList)
|
||||
| none => "<none>"
|
||||
Response.ok |>.text seenTe)
|
||||
|
||||
Reference in New Issue
Block a user