feat: client

This commit is contained in:
Sofia Rodrigues
2026-03-16 00:01:33 -03:00
parent d4884cde14
commit 65da1ee047
14 changed files with 1595 additions and 246 deletions

View File

@@ -63,19 +63,23 @@ def proxy (b : Client.Builder) (host : String) (port : UInt16) : Client.Builder
/--
Routes all connections through a proxy specified as a URL string.
Returns `none` if the URL is invalid or has no authority component.
Only HTTP proxies are supported. The scheme determines the default port
when no explicit port is specified (`http` → 80, `https` → 443). TLS
(HTTPS proxy CONNECT tunnels) is not supported.
-/
def proxy? (b : Client.Builder) (url : String) : Option Client.Builder := do
let uri URI.parse? url
let auth uri.authority
let host := toString auth.host
let defaultPort : UInt16 := if uri.scheme.val == "https" then 443 else 80
let port : UInt16 := match auth.port with
| .value p => p
| _ => defaultPort
| _ => URI.Scheme.defaultPort uri.scheme
pure { b with config := { b.config with proxy := some (host, port) } }
/--
Sets the total request timeout (connect + send + receive).
Sets the request timeout (send + receive).
DNS resolution and TCP connect are not covered by this timeout;
use the OS-level socket timeout for those.
-/
def timeout (b : Client.Builder) (ms : Time.Millisecond.Offset) : Client.Builder :=
if h : 0 < ms then

View File

@@ -67,11 +67,6 @@ public structure Agent (α : Type) where
-/
port : UInt16
/--
URIs visited in the current redirect chain, used to detect cycles.
-/
redirectHistory : Array URI
/--
Cookie jar shared across all requests and redirects through this agent.
-/
@@ -94,8 +89,27 @@ public structure Agent (α : Type) where
-/
connectTo : Option (URI.Host UInt16 Async (Session α)) := none
/--
Called when a connection error is confirmed (i.e., `session.send` threw and all retries
are committed to using a fresh session). Receives the broken session together with the
host and port so the caller can:
* For pool agents: evict every session to that host so the next retry gets a fresh one.
* For standalone agents: close the session's request channel so the background loop exits.
The default closes the session channel; pool agents set this to an eviction function.
-/
onBrokenSession : Session α URI.Host UInt16 Async Unit :=
fun s _ _ => discard <| s.close
namespace Agent
/--
Returns `true` for HTTP methods that are safe to retry on connection failure.
Non-idempotent methods (POST, PATCH) must not be retried automatically because
the server may have already processed the request before the connection dropped.
-/
private def isIdempotentMethod (m : Method) : Bool :=
m == .get || m == .head || m == .put || m == .delete || m == .options || m == .trace
/--
Rewrites an origin-form request target to absolute-form for proxy forwarding.
`GET /path?q=1 HTTP/1.1` becomes `GET http://host:port/path?q=1 HTTP/1.1`.
@@ -106,13 +120,15 @@ def toAbsoluteFormRequest
(scheme : URI.Scheme) (host : URI.Host) (port : UInt16) : Request Body.AnyBody :=
match request.line.uri with
| .originForm o =>
let pathStr := toString o.path
let queryStr := match o.query with | none => "" | some q => toString q
let portStr := if port == URI.Scheme.defaultPort scheme then "" else s!":{port}"
let urlStr := s!"{scheme}://{host}{portStr}{pathStr}{queryStr}"
match RequestTarget.parse? urlStr with
| some target => { request with line := { request.line with uri := target } }
| none => request
{ request with
line := { request.line with uri := .absoluteForm {
scheme,
path := o.path,
query := o.query.getD .empty,
authority := some { host, port := .value port }
}
}
}
| _ => request
/--
@@ -124,26 +140,30 @@ def ofTransport [Transport α] (socket : α) (scheme : URI.Scheme)
(host : URI.Host) (port : UInt16)
(connectTo : Option (URI.Host UInt16 Async (Session α)) := none)
(config : Config := {}) : Async (Agent α) := do
let session Session.new socket config
let cookieJar Cookie.Jar.new
pure { session, scheme, host, port, redirectHistory := #[], cookieJar, connectTo }
pure { session, scheme, host, port, cookieJar, connectTo }
/--
Injects matching cookies from `cookieJar` into the request headers for `host`.
Does nothing if the jar contains no matching cookies.
-/
def injectCookies (cookieJar : Cookie.Jar) (host : URI.Host)
def injectCookies (cookieJar : Cookie.Jar) (host : URI.Host) (scheme : URI.Scheme)
(request : Request Body.AnyBody) : Async (Request Body.AnyBody) := do
-- Respect an explicit Cookie header set by the caller.
if request.line.headers.contains .cookie then return request
let path := match request.line.uri with
| .originForm o => toString o.path
| .absoluteForm af => toString af.path
| _ => "/"
let cookies cookieJar.cookiesFor host path
match Cookie.Jar.toCookieHeader cookies with
| .originForm o => o.path
| .absoluteForm af => af.path
| _ => URI.Path.parseOrRoot "/"
match cookieJar.cookiesFor host path (secure := scheme.val == "https") with
| none => return request
| some cookieValue =>
let newHeaders :=
request.line.headers.insert Header.Name.cookie (Header.Value.ofString! cookieValue)
let newHeaders := request.line.headers.insert .cookie cookieValue
return { request with line := { request.line with headers := newHeaders } }
/--
@@ -171,87 +191,105 @@ inductive RedirectDecision where
Response is final, should validate status and return it.
-/
| done
/-- Follow a redirect to `(host, port, scheme)` with `request`, updating `history`. -/
| follow (host : URI.Host) (port : UInt16) (scheme : URI.Scheme)
(request : Request Body.AnyBody) (history : Array URI)
/--
Follow a redirect to `(host, port, scheme)` with `request`, updating `history`.
-/
| follow (host : URI.Host) (port : UInt16) (scheme : URI.Scheme) (request : Request Body.AnyBody)
/--
Inspects `response` and decides whether to follow a redirect.
Returns `.done` when:
- `remaining` is 0 or the response is not a redirection,
- the `Location` header is absent,
- a redirect cycle is detected, or
- the `Location` header is absent, or
- the `Location` value cannot be parsed.
Returns `.follow` with the rewritten request (method, body, and headers adjusted per
RFC 9110 §15.4, including `Authorization` stripped on cross-origin hops) when a valid
redirect target is found. The response body is drained before returning `.follow`.
redirect target is found. The response body is drained (up to `drainLimit` bytes) before
returning `.follow`; if the body exceeds `drainLimit` the incoming channel is closed and
the connection is left to recover or time out.
-/
def decideRedirect
(remaining : Nat) (history : Array URI)
(remaining : Nat)
(currentHost : URI.Host) (currentPort : UInt16) (currentScheme : URI.Scheme)
(request : Request Body.AnyBody) (response : Response Body.Incoming)
(drainLimit : Nat)
: Async RedirectDecision := do
if remaining == 0 || !response.line.status.isRedirection then
if remaining == 0 !response.line.status.isRedirection then
return .done
let some locationValue := response.line.headers.get? Header.Name.location
let some locationValue := response.line.headers.get? .location
| return .done
let locationStr := locationValue.value
let locationURI := URI.parse? locationStr
discard <| ContextAsync.run (response.body.readAll (α := ByteArray))
if let some uri := locationURI then
if history.contains uri then return .done
let some target := RequestTarget.parse? locationStr
| return .done
-- Drain
discard <| ContextAsync.run do
try
discard <| response.body.readAll (α := ByteArray) (maximumSize := some drainLimit.toUInt64)
catch _ =>
response.body.close
let newMethod := match response.line.status with
| .seeOther => .get
| .movedPermanently | .found =>
if request.line.method == .post then .get else request.line.method
| _ => request.line.method
let bodyIsStreaming := match request.body with | .outgoing _ => true | _ => false
let newBody : Body.AnyBody :=
if newMethod == .get || newMethod == .head || newMethod != request.line.method then
.empty {}
else
request.body
if newMethod == .get || newMethod == .head || newMethod != request.line.method then .empty {}
else if bodyIsStreaming then .empty {}
else request.body
let newHistory := match locationURI with
| some uri => history.push uri
| none => history
let (newHost, newPort, newScheme) := match target with
| .absoluteForm af =>
let h := af.authority.map URI.Authority.host |>.getD currentHost
let p : UInt16 :=
match af.authority with
| some auth => match auth.port with
| URI.Port.value v => v
| _ => URI.Scheme.defaultPort af.scheme
| none => URI.Scheme.defaultPort af.scheme
(h, p, af.scheme)
| _ => (currentHost, currentPort, currentScheme)
let (newHost, newPort, newScheme) := match locationURI with
| some uri =>
if let some auth := uri.authority then
let h := auth.host
let p : UInt16 := match auth.port with
| .value p => p
| _ => URI.Scheme.defaultPort uri.scheme
(h, p, uri.scheme)
else (currentHost, currentPort, currentScheme)
| none => (currentHost, currentPort, currentScheme)
-- Avoid SSRF.
if newScheme.val != "http" && newScheme.val != "https" then
return .done
-- Strip Authorization
let isCrossOrigin := newHost != currentHost || newPort != currentPort || newScheme != currentScheme
-- Strip Authorization on cross-origin redirects to prevent credential leakage (RFC 9110 §15.4).
let crossOrigin := newHost != currentHost || newPort != currentPort
let newHeaders :=
if crossOrigin then request.line.headers.erase Header.Name.authorization
if isCrossOrigin then
request.line.headers
|>.erase Header.Name.authorization
|>.erase Header.Name.proxyAuthorization
|>.erase Header.Name.cookie
else request.line.headers
return .follow newHost newPort newScheme
{ line := { request.line with uri := target, method := newMethod, headers := newHeaders }
body := newBody
extensions := request.extensions }
newHistory
private partial def sendWithRedirects [Transport α]
(agent : Agent α) (request : Request Body.AnyBody)
(remaining : Nat) (retriesLeft : Nat) : Async (Response Body.Incoming) := do
(remaining : Nat) (retriesLeft : Nat)
(history : Array (URI.Host × UInt16 × String) := #[]) : Async (Response Body.Incoming) := do
-- Record the current URL in the history and detect redirect cycles.
let currentKey := (agent.host, agent.port, toString request.line.uri)
let history := history.push currentKey
-- Rewrite to absolute-form when a proxy is configured.
let request :=
if agent.session.config.proxy.isSome then
@@ -259,51 +297,62 @@ private partial def sendWithRedirects [Transport α]
else
request
let request injectCookies agent.cookieJar agent.host request
let request injectCookies agent.cookieJar agent.host agent.scheme request
let response try agent.session.send request
catch err => do
-- Connection error: retry with a fresh session if budget and factory allow.
if retriesLeft > 0 then
agent.onBrokenSession agent.session agent.host agent.port
let bodyIsReplayable := match request.body with | .outgoing _ => false | _ => true
if retriesLeft > 0 && isIdempotentMethod request.line.method && bodyIsReplayable then
if let some factory := agent.connectTo then
sleep agent.session.config.retryDelay
let newSession factory agent.host agent.port
return sendWithRedirects { agent with session := newSession } request remaining (retriesLeft - 1)
return sendWithRedirects { agent with session := newSession } request remaining (retriesLeft - 1) history
throw err
let response applyInterceptors agent.interceptors response
processCookies agent.cookieJar agent.host response.line.headers
match decideRedirect remaining agent.redirectHistory agent.host agent.port agent.scheme request response with
match decideRedirect remaining agent.host agent.port agent.scheme request response
agent.session.config.redirectBodyDrainLimit with
| .done =>
if let some validate := agent.session.config.validateStatus then
if !validate response.line.status then
throw (.userError s!"unexpected HTTP status: {response.line.status.toCode}")
return response
| .follow newHost newPort newScheme newRequest newHistory =>
| .follow newHost newPort newScheme newRequest =>
if let some policy := agent.session.config.redirectPolicy then
if !policy newHost newPort then
return response
let nextKey := (newHost, newPort, toString newRequest.line.uri)
if history.contains nextKey then
return response
if newHost != agent.host || newPort != agent.port then
-- For custom transports without a connectTo factory we cannot open a new
-- connection to a different host; return the redirect response as-is.
let some factory := agent.connectTo | return response
let _ agent.session.close
let some factory := agent.connectTo
| return response
let newSession factory newHost newPort
let response sendWithRedirects
{ session := newSession
scheme := newScheme
host := newHost
port := newPort
redirectHistory := newHistory
cookieJar := agent.cookieJar
interceptors := agent.interceptors
connectTo := some factory }
newRequest (remaining - 1) retriesLeft
-- Close the cross-host session: signals the background loop to shut down after
-- the response body is consumed (safe because the channel is only polled once
-- the current response is fully delivered and waitingForRequest becomes true).
let _ newSession.close
return response
sendWithRedirects
{ session := newSession
scheme := newScheme
host := newHost
port := newPort
cookieJar := agent.cookieJar
interceptors := agent.interceptors
connectTo := some factory
onBrokenSession := agent.onBrokenSession }
newRequest (remaining - 1) retriesLeft history
else
sendWithRedirects { agent with redirectHistory := newHistory } newRequest (remaining - 1) retriesLeft
sendWithRedirects agent newRequest (remaining - 1) retriesLeft history
/--
Send a request, automatically following redirects up to `config.maxRedirects` hops and
@@ -312,9 +361,9 @@ For cross-host redirects the agent reconnects using its `connectTo` factory (if
Cookies are automatically injected from the jar and `Set-Cookie` responses are stored.
Response interceptors are applied after every response.
-/
def send {β : Type} [Coe β Body.AnyBody] [Transport α]
(agent : Agent α) (request : Request β) : Async (Response Body.Incoming) :=
sendWithRedirects agent
def send {β : Type} [Coe β Body.AnyBody] [Transport α] (agent : Agent α) (request : Request β) : Async (Response Body.Incoming) :=
sendWithRedirects
agent
{ line := request.line, body := (request.body : Body.AnyBody), extensions := request.extensions }
agent.session.config.maxRedirects
agent.session.config.maxRetries
@@ -369,30 +418,12 @@ private def withHostHeader [Transport α] (rb : Agent.RequestBuilder α) : Agent
{ rb with builder := rb.builder.header! "Host" hostValue }
/--
Injects matching cookies from the agent's jar if no `Cookie` header is already present.
-/
private def withCookies [Transport α] (rb : Agent.RequestBuilder α) : Async (Agent.RequestBuilder α) := do
if rb.builder.line.headers.contains Header.Name.cookie then
return rb
let path := match rb.builder.line.uri with
| .originForm o => toString o.path
| .absoluteForm af => toString af.path
| _ => "/"
let cookies rb.agent.cookieJar.cookiesFor rb.agent.host path
match Cookie.Jar.toCookieHeader cookies with
| none => return rb
| some cookieValue =>
return { rb with builder := rb.builder.header! "Cookie" cookieValue }
/--
Prepares the builder by injecting Host and Cookie headers, then calls `f` to build
and send the request.
Prepares the builder by injecting the `Host` header, then calls `f` to build and send the
request. Cookie injection is handled by `Agent.injectCookies` inside `sendWithRedirects`.
-/
private def prepare [Transport α] (rb : Agent.RequestBuilder α)
(f : Agent.RequestBuilder α Async (Response Body.Incoming)) : Async (Response Body.Incoming) := do
let rb := rb.withHostHeader
let rb rb.withCookies
f rb
(f : Agent.RequestBuilder α Async (Response Body.Incoming)) : Async (Response Body.Incoming) :=
f rb.withHostHeader
/--
Adds a typed header to the request.
@@ -471,36 +502,46 @@ end Agent.RequestBuilder
namespace Agent
/-- Creates a GET request builder for the given path or URL. -/
/--
Creates a GET request builder for the given path or URL
-/
def get [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
{ agent, builder := Request.get (RequestTarget.parse! path) }
/-- Creates a POST request builder for the given path or URL. -/
/--
Creates a POST request builder for the given path or URL
-/
def post [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
{ agent, builder := Request.post (RequestTarget.parse! path) }
/-- Creates a PUT request builder for the given path or URL. -/
/--
Creates a PUT request builder for the given path or URL
-/
def put [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
{ agent, builder := Request.put (RequestTarget.parse! path) }
/-- Creates a DELETE request builder for the given path or URL. -/
/--
Creates a DELETE request builder for the given path or URL
-/
def delete [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
{ agent, builder := Request.delete (RequestTarget.parse! path) }
/-- Creates a PATCH request builder for the given path or URL. -/
/--
Creates a PATCH request builder for the given path or URL
-/
def patch [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
{ agent, builder := Request.patch (RequestTarget.parse! path) }
/-- Creates a HEAD request builder for the given path or URL. -/
/--
Creates a HEAD request builder for the given path or URL
-/
def headReq [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
{ agent, builder := Request.head (RequestTarget.parse! path) }
/-- Creates an OPTIONS request builder for the given path or URL. -/
/--
Creates an OPTIONS request builder for the given path or URL.
-/
def options [Transport α] (agent : Agent α) (path : String) : Agent.RequestBuilder α :=
{ agent, builder := Request.options (RequestTarget.parse! path) }
end Agent
end Client
end Http
end Std
end Std.Http.Client.Agent

View File

@@ -57,7 +57,8 @@ structure Config where
keepAliveTimeout : { x : Time.Millisecond.Offset // 0 < x } := 60000, by decide
/--
Timeout for the entire request lifecycle (connect + read + write).
Timeout for the request lifecycle (send + receive) per connection.
DNS resolution and TCP connect are not covered by this timeout.
-/
requestTimeout : { x : Time.Millisecond.Offset // 0 < x } := 120000, by decide
@@ -105,6 +106,14 @@ structure Config where
-/
proxy : Option (String × UInt16) := none
/--
Maximum number of bytes allowed in a single response body.
When `some n`, reading more than `n` bytes from the body resolves the current
request with an error and closes the connection.
`none` (default) imposes no limit.
-/
maxResponseBodySize : Option Nat := none
/--
Optional predicate that decides whether a response status is acceptable.
When `none`, all status codes are accepted (no error is thrown).
@@ -119,6 +128,16 @@ structure Config where
-/
validateStatus : Option (Status Bool) := none
/--
Maximum number of bytes drained from an intermediate redirect response body before
-/
redirectBodyDrainLimit : Nat := 1024 * 1024
/--
Optional predicate called before following each redirect.
-/
redirectPolicy : Option (URI.Host UInt16 Bool) := none
namespace Config
/--

View File

@@ -161,6 +161,7 @@ private structure ConnectionState where
uploadBytes : UInt64 := 0
downloadProgress : Option (Watch UInt64) := none
downloadBytes : UInt64 := 0
downloadBodyBytes : UInt64 := 0
@[inline]
private def requestHasExpectContinue (request : Request Body.Operations) : Bool :=
@@ -230,6 +231,10 @@ private def processH1Events
| .needMoreData expect =>
st := { st with requiresData := true, expectData := expect }
-- `.needAnswer` is emitted by processWrite when the writer is in `waitingHeaders`
-- state in `.sending` mode, signalling that the client machine needs the next request.
-- The client loop tracks this through `waitingForRequest` instead, so this event
-- is intentionally a no-op here.
| .needAnswer => pure ()
| .endHeaders head =>
@@ -305,6 +310,7 @@ private def processH1Events
uploadBytes := 0
downloadProgress := none
downloadBytes := 0
downloadBodyBytes := 0
}
| .failed err =>
@@ -395,7 +401,28 @@ private def handleRecvEvent
let mut st := { state with machine := newMachine }
if let some pulled := pulledChunk then
let newBodyBytes := st.downloadBodyBytes + pulled.chunk.data.size.toUInt64
st := { st with downloadBodyBytes := newBodyBytes }
-- Enforce the response body size limit before writing data to the caller.
if let some maxSize := config.maxResponseBodySize then
if newBodyBytes > maxSize.toUInt64 then
if let some packet := st.currentRequest then
packet.onError (.userError "response body exceeds maximum allowed size")
if let some body := st.responseOutgoing then
if ¬( Body.Writer.isClosed body) then Body.Writer.close body
if let some w := st.downloadProgress then Watch.close w
return ({ st with
machine := st.machine.closeWriter.closeReader.noMoreInput
currentRequest := none
responseOutgoing := none
downloadProgress := none
}, false)
if let some body := st.responseOutgoing then
-- If the caller has dropped/closed the incoming side, the write fails.
-- Silently swallowing the error is correct: the loop must continue pulling
-- wire bytes to keep the connection in a valid state for reuse.
try Body.Writer.send body pulled.chunk pulled.incomplete
catch _ => pure ()

View File

@@ -54,6 +54,7 @@ private def createTcpSession (host : URI.Host) (port : UInt16) (config : Config)
-- Try each resolved address in order; return on first successful connect.
-- This handles hosts that resolve to both IPv6 (::1) and IPv4 (127.0.0.1).
let mut lastErr : IO.Error := IO.userError s!"could not connect to {connectHost.quote}:{connectPort}"
for ipAddr in addrs do
let socketAddr : Std.Net.SocketAddress := match ipAddr with
| .v4 ip => .v4 ip, connectPort
@@ -92,6 +93,11 @@ public structure Agent.Pool where
-/
cookieJar : Cookie.Jar
/--
Monotonically increasing counter used to assign unique IDs to pooled sessions.
-/
nextId : Mutex UInt64
/--
Response interceptors applied (in order) after every response from any session in the pool.
-/
@@ -105,7 +111,8 @@ Creates a new, empty connection pool.
def new (config : Config := {}) (maxPerHost : Nat := 4) : Async Agent.Pool := do
let state Mutex.new ( : Std.HashMap (String × UInt16) (Array (Session Socket.Client) × Nat))
let cookieJar Cookie.Jar.new
pure { state, maxPerHost, config, cookieJar }
let nextId Mutex.new (1 : UInt64)
pure { state, maxPerHost, config, cookieJar, nextId }
/--
Returns a session for `(host, port)`, reusing an existing one when available or
@@ -127,6 +134,11 @@ def getOrCreateSession (pool : Agent.Pool) (host : URI.Host) (port : UInt16) : A
-- Slow path: create a new session and register it.
let session createTcpSession host port pool.config
let newId pool.nextId.atomically do
let id MonadState.get
MonadState.set (id + 1)
return id
let session := { session with id := newId }
pool.state.atomically do
let st MonadState.get
let (sessions, idx) := (st.get? (toString host, port)).getD (#[], 0)
@@ -138,48 +150,17 @@ def getOrCreateSession (pool : Agent.Pool) (host : URI.Host) (port : UInt16) : A
return session
/--
Removes all sessions for `(host, port)` from the pool.
Called when a connection error is detected so the next request gets a fresh connection.
Removes a single broken session from the pool by its unique ID.
Healthy sibling sessions to the same host are preserved.
-/
private def evictSessions (pool : Agent.Pool) (host : URI.Host) (port : UInt16) : Async Unit := do
private def evictSession (pool : Agent.Pool) (host : URI.Host) (port : UInt16) (sessionId : UInt64) : Async Unit := do
pool.state.atomically do
let st MonadState.get
MonadState.set (st.erase (toString host, port))
private partial def sendPoolWithRedirects
(pool : Agent.Pool) (host : URI.Host) (port : UInt16)
(request : Request Body.AnyBody)
(remaining : Nat)
(retriesLeft : Nat)
(redirectHistory : Array URI) : Async (Response Body.Incoming) := do
let request :=
if pool.config.proxy.isSome then
Agent.toAbsoluteFormRequest request (URI.Scheme.ofPort port) host port
else
request
let request Agent.injectCookies pool.cookieJar host request
let session pool.getOrCreateSession host port
let response try session.send request
catch err => do
evictSessions pool host port
if retriesLeft > 0 then
sleep pool.config.retryDelay
return sendPoolWithRedirects pool host port request remaining (retriesLeft - 1) redirectHistory
throw err
let response Agent.applyInterceptors pool.interceptors response
Agent.processCookies pool.cookieJar host response.line.headers
match Agent.decideRedirect remaining redirectHistory host port (URI.Scheme.ofPort port) request response with
| .done =>
if let some validate := pool.config.validateStatus then
if !validate response.line.status then
throw (.userError s!"unexpected HTTP status: {response.line.status.toCode}")
return response
| .follow newHost newPort _ newRequest newHistory =>
sendPoolWithRedirects pool newHost newPort newRequest (remaining - 1) retriesLeft newHistory
match st.get? (toString host, port) with
| none => pure ()
| some (sessions, idx) =>
let sessions' := sessions.filter (fun s => s.id != sessionId)
MonadState.set (st.insert (toString host, port) (sessions', idx))
/--
Sends a request through a pooled session for `(host, port)`, injecting cookies from the
@@ -189,12 +170,19 @@ failure (retrying up to `config.maxRetries` times).
-/
def send {β : Type} [Coe β Body.AnyBody]
(pool : Agent.Pool) (host : URI.Host) (port : UInt16)
(request : Request β) : Async (Response Body.Incoming) :=
sendPoolWithRedirects pool host port
{ line := request.line, body := (request.body : Body.AnyBody), extensions := request.extensions }
pool.config.maxRedirects
pool.config.maxRetries
#[]
(request : Request β) : Async (Response Body.Incoming) := do
let session pool.getOrCreateSession host port
Agent.send {
session
scheme := URI.Scheme.ofPort port
host := host
port := port
cookieJar := pool.cookieJar
interceptors := pool.interceptors
connectTo := some pool.getOrCreateSession
onBrokenSession := fun brokenSession h p => pool.evictSession h p brokenSession.id
} request
end Agent.Pool
@@ -211,11 +199,6 @@ def connect (host : URI.Host) (port : UInt16) (config : Config := {}) : Async (A
let session createTcpSession host port config
let cookieJar Cookie.Jar.new
let scheme := URI.Scheme.ofPort port
pure { session, scheme, host, port, redirectHistory := #[], cookieJar,
connectTo := some (fun h p => createTcpSession h p config) }
pure { session, scheme, host, port, cookieJar, connectTo := some (createTcpSession · · config) }
end Agent
end Client
end Http
end Std
end Std.Http.Client.Agent

View File

@@ -44,6 +44,12 @@ public structure Session (α : Type) where
-/
config : Config
/--
Unique identifier assigned by the pool when this session is registered.
Zero for sessions created outside a pool.
-/
id : UInt64 := 0
namespace Session
/--

View File

@@ -8,6 +8,8 @@ module
prelude
public import Std.Sync.Mutex
public import Std.Internal.Http.Data.URI
public import Std.Internal.Http.Data.Cookie.Parser
public import Std.Internal.Http.Data.Headers
public import Init.Data.String
public import Init.Data.Array.Basic
public import Init.Data.List.Basic
@@ -206,8 +208,14 @@ structure Cookie where
-/
secure : Bool
/--
When `true` the cookie must not be exposed to non-HTTP APIs.
Stored for completeness; no client-side script enforcement applies here.
-/
httpOnly : Bool
/--
A thread-safe HTTP cookie jar.
A HTTP cookie jar.
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-5
-/
@@ -236,34 +244,24 @@ private def domainMatches (cookieDomain : URI.Host) (hostOnly : Bool) (host : UR
/--
Path matching per RFC 6265 §5.1.4.
-/
private def pathMatches (cookiePath : URI.Path) (requestPath : String) : Bool :=
let s := toString cookiePath
requestPath == s ||
(requestPath.startsWith s &&
(s.endsWith "/" || requestPath.startsWith (s ++ "/")))
/--
Splits `s` at the first occurrence of `sep`, returning `(before, after)`.
Returns `(s, "")` when `sep` does not appear in `s`.
-/
private def splitFirst (s : String) (sep : String) : String × String :=
match s.splitOn sep with
| [] | [_] => (s, "")
| first :: rest => (first, String.intercalate sep rest)
A request path matches a cookie path when they are identical, or when the cookie path is a
strict segment-wise prefix of the request path. Segment boundaries correspond to `/`, so
`/foo` never prefix-matches `/foobar` (different segments).
/--
Attempts to parse a host string into a `URI.Host`, trying IPv4, bracketed IPv6, and
domain name forms in order.
A trailing `/` in the cookie path is normalised away before the prefix test; this covers
both RFC conditions:
- cookie-path ends with `/` → its meaningful segments are a strict prefix of request-path.
- first char after prefix is `/` → satisfied automatically at segment boundaries.
-/
private def parseHostStr (s : String) : Option URI.Host :=
if let some ip := Net.IPv4Addr.ofString s then
some (.ipv4 ip)
else if s.startsWith "[" && s.endsWith "]" && s.length > 2 then
let inner := s.dropEnd 1 |>.drop 1 |>.toString
(Net.IPv6Addr.ofString inner).map .ipv6
else
(URI.DomainName.ofString? s).map .name
private def pathMatches (cookiePath : URI.Path) (requestPath : URI.Path) : Bool :=
requestPath == cookiePath ||
let cp :=
if cookiePath.hasTrailingSlash && !cookiePath.isEmpty
then cookiePath.segments.pop
else cookiePath.segments
requestPath.segments.size > cp.size &&
requestPath.startsWith { cookiePath with segments := cp }
/--
Parses a single `Set-Cookie` header value and stores the resulting cookie.
@@ -273,70 +271,79 @@ Parses a single `Set-Cookie` header value and stores the resulting cookie.
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-5.2
-/
def processSetCookie (jar : Jar) (host : URI.Host) (headerValue : String) : BaseIO Unit := do
let rawParts := (headerValue.splitOn ";").map String.trimAscii
let some rawNameValue := rawParts[0]?
let .ok parsed := Cookie.Parser.parseSetCookie.run headerValue.toUTF8
| return ()
let (rawName, rawValue) := splitFirst rawNameValue.toString "="
let some cookieName := Cookie.Name.ofString? parsed.name
| return ()
let some cookieName := Cookie.Name.ofString? rawName | return ()
let some cookieValue := Cookie.Value.ofString? rawValue | return ()
let some cookieValue := Cookie.Value.ofString? parsed.value
| return ()
let mut cookieDomain : Option URI.Host := none
let mut cookiePath : URI.Path := URI.Path.parseOrRoot "/"
let mut secure := false
let cookiePath : URI.Path :=
if let some p := parsed.path then URI.Path.parseOrRoot p
else URI.Path.parseOrRoot "/"
for attr in rawParts.drop 1 do
let (attrName, attrVal) := splitFirst attr.toString "="
match attrName.trimAscii.toString.toLower with
| "domain" =>
let d := if attrVal.startsWith "." then attrVal.drop 1 else attrVal
let d := d.trimAscii.toString
if !d.isEmpty then
cookieDomain := (URI.DomainName.ofString? d).map URI.Host.name
| "path" =>
let p := attrVal.trimAscii.toString
if !p.isEmpty then cookiePath := URI.Path.parseOrRoot p
| "secure" => secure := true
| _ => pure ()
-- RFC 6265 §5.2.3: resolve domain; missing or invalid Domain → host-only
let (domain, hostOnly) :=
match parsed.domain with
| some d =>
match URI.DomainName.ofString? d with
| some name => (URI.Host.name name, false)
| none => (host, true)
| none => (host, true)
let (domain, hostOnly) match cookieDomain with
| some d => pure (d, false)
| none => pure (host, true)
-- RFC 6265 §5.3 step 6: if domain attribute is set, the origin host must domain-match it.
-- This prevents a server at api.example.com from setting Domain=evil.com or Domain=com.
if !hostOnly && !domainMatches domain false host then
return ()
let cookie : Cookie := { name := cookieName, value := cookieValue, domain, hostOnly, path := cookiePath, secure }
-- RFC 6265 §5.2.2: Max-Age ≤ 0 signals deletion — remove any matching cookie and stop.
if let some maxAgeVal := parsed.maxAge then
if maxAgeVal 0 then
jar.cookies.atomically do
let cs get
set (cs.filter fun c => !(c.name == cookieName && c.domain == domain && c.path == cookiePath))
return ()
let cookie : Cookie := {
name := cookieName
value := cookieValue
domain
hostOnly
path := cookiePath
secure := parsed.secure
httpOnly := parsed.httpOnly
}
-- Limit the total cookie count to prevent unbounded memory growth.
-- RFC 6265 §6.1 recommends supporting at least 3000 cookies total.
let maxCookies := 3000
jar.cookies.atomically do
let cs get
let cs := cs.filter fun c =>
!(c.name == cookie.name && c.domain == cookie.domain && c.path == cookie.path)
set (cs.push cookie)
let cs := cs.filter fun c => !(c.name == cookie.name && c.domain == cookie.domain && c.path == cookie.path)
if cs.size < maxCookies then
set (cs.push cookie)
/--
Returns all cookies that should be sent for a request to `host` at `path`.
Pass `secure := true` when the request channel is HTTPS.
Returns the `Cookie` header value for all cookies that should be sent for a request to `host`
at `path`. Pass `secure := true` when the request channel is HTTPS. Returns `none` when no
cookies match.
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-5.4
-/
def cookiesFor
(jar : Jar) (host : URI.Host) (path : String)
(secure : Bool := false) : BaseIO (Array Cookie) :=
(jar : Jar) (host : URI.Host) (path : URI.Path)
(secure : Bool := false) : BaseIO (Option Header.Value) :=
jar.cookies.atomically do
let cs get
return cs.filter fun c =>
let matching := cs.filter fun c =>
domainMatches c.domain c.hostOnly host &&
pathMatches c.path path &&
(!c.secure || secure)
/--
Formats an array of cookies into a `Cookie` header value string.
Returns `none` when the array is empty.
-/
def toCookieHeader (cookies : Array Cookie) : Option String :=
if cookies.isEmpty then
none
else
some (String.intercalate "; " (cookies.map (fun c => c.name.value ++ "=" ++ c.value.value)).toList)
if matching.isEmpty then
return none
else
return Header.Value.ofString? (String.intercalate "; " (matching.map (fun c => c.name.value ++ "=" ++ c.value.value)).toList)
end Std.Http.Cookie.Jar

View File

@@ -0,0 +1,225 @@
/-
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
Authors: Sofia Rodrigues
-/
module
prelude
import Init.Data.String
public import Std.Internal.Parsec
public import Std.Internal.Parsec.ByteArray
public import Std.Internal.Http.Internal.Char
public section
/-!
# Cookie Parser
This module provides a `Set-Cookie` response-header parser following RFC 6265 §4.1. The
`parseSetCookie` combinator returns a `Parsed` structure with raw `String` fields; callers are
responsible for validating cookie-name and cookie-value semantics (e.g. via `Cookie.Name.ofString?`
and `Cookie.Value.ofString?`).
On parse failure the cookie is silently discarded per RFC 6265 §5.2.
Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1
-/
namespace Std.Http.Cookie.Parser
set_option linter.all true
open Std Internal Parsec ByteArray Internal.Char
/-
cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
; US-ASCII visible characters excluding SP, DQUOTE,
; comma, semicolon, and backslash.
; Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
-/
@[inline]
private def isCookieOctetByte (c : UInt8) : Bool :=
c == 0x21 ||
(0x23 c && c 0x2B) ||
(0x2D c && c 0x3A) ||
(0x3C c && c 0x5B) ||
(0x5D c && c 0x7E)
/-
av-octet = %x20-3A / %x3C-7E
; any CHAR except CTLs or ";"
; Reference: https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
-/
@[inline]
private def isAvOctetByte (c : UInt8) : Bool :=
(0x20 c && c 0x3A) || (0x3C c && c 0x7E)
/-
token = 1*tchar
tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
"^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
; Reference: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.2
-/
@[inline]
private def parseToken (limit : Nat) : Parser ByteSlice :=
takeWhile1AtMost (fun c => tchar (Char.ofUInt8 c)) limit
/--
Parsed result of a `Set-Cookie` header value, prior to semantic validation.
Cookie-name and cookie-value are raw strings that callers must validate
(e.g. via `Cookie.Name.ofString?` and `Cookie.Value.ofString?`).
- `domain`: the `Domain` attribute value with any leading `.` already stripped;
`none` if the attribute is absent.
- `path`: the `Path` attribute value (guaranteed to start with `/`);
`none` if the attribute is absent or does not start with `/`.
- `secure`: `true` when the `Secure` attribute is present.
- `httpOnly`: `true` when the `HttpOnly` attribute is present.
-/
structure Parsed where
/--
Raw cookie name (an HTTP token).
-/
name : String
/--
Raw cookie value (`*cookie-octet` or double-quoted).
-/
value : String
/--
`Domain` attribute value with any leading `.` stripped, or `none` if absent.
-/
domain : Option String
/--
`Path` attribute value starting with `/`, or `none` if absent or invalid.
-/
path : Option String
/--
`true` when the `Secure` attribute is present.
-/
secure : Bool
/-- `true` when the `HttpOnly` attribute is present. -/
httpOnly : Bool
/-- `Max-Age` attribute value in seconds, or `none` if absent or unparseable.
Values ≤ 0 signal cookie deletion per RFC 6265 §5.2.2. -/
maxAge : Option Int := none
-- cookie-name = token
private def parseCookieName : Parser String := do
let bytes parseToken 4096
let some str := String.fromUTF8? bytes.toByteArray
| fail "invalid cookie name encoding"
return str
/-
cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
-/
private def parseCookieValue : Parser String := do
let bytes
if ( peekWhen? (· == '"'.toUInt8)).isSome then
skipByte '"'.toUInt8
let inner takeWhileAtMost isCookieOctetByte 4096
skipByte '"'.toUInt8
pure inner
else
takeWhileAtMost isCookieOctetByte 4096
let some str := String.fromUTF8? bytes.toByteArray
| fail "invalid cookie value encoding"
return str
-- av-name = token (parsed case-insensitively)
private def parseAttrName : Parser String := do
let bytes ← takeWhileAtMost (fun c => tchar (Char.ofUInt8 c)) 256
return (String.fromUTF8! bytes.toByteArray).toLower
-- av-value = *av-octet
private def parseAttrValue : Parser String := do
let bytes ← takeWhileAtMost isAvOctetByte 4096
let some str := String.fromUTF8? bytes.toByteArray
| fail "invalid attribute value encoding"
return str
/-
cookie-av = expires-av / max-age-av / domain-av / path-av / secure-av /
httponly-av / extension-av
domain-av = "Domain=" domain-value
domain-value = <subdomain> ; as per RFC 1034, Section 3.5
path-av = "Path=" path-value
path-value = *av-octet
secure-av = "Secure"
httponly-av = "HttpOnly"
extension-av = *av-octet
-/
private def parseCookieAv : Parser (String × Option String) := do
let name ← parseAttrName
let value ← optional (attempt (skipByte '='.toUInt8 *> parseAttrValue))
return (name, value)
/-
set-cookie-string = cookie-pair *( ";" SP cookie-av )
cookie-pair = cookie-name "=" cookie-value
-/
/--
Parses a `Set-Cookie` header value and returns a `Parsed` result.
Attribute processing follows RFC 6265 §5.2:
- `Domain`: leading `.` is stripped; invalid domain strings set `domain` to `none`.
- `Path`: values not starting with `/` set `path` to `none` (caller uses the default `/`).
- `Secure`: sets `secure` to `true` regardless of whether a value follows the attribute name.
- `HttpOnly`: sets `httpOnly` to `true`.
- All other attributes (including `Expires`, `Max-Age`, `SameSite`) are ignored.
-/
public def parseSetCookie : Parser Parsed := do
let name ← parseCookieName
skipByte '='.toUInt8
let value ← parseCookieValue
-- *( ";" SP cookie-av )
let attrs ← many (attempt do
skipByte ';'.toUInt8
let _ ← optional (skipByte ' '.toUInt8)
parseCookieAv)
let mut domain : Option String := none
let mut path : Option String := none
let mut secure := false
let mut httpOnly := false
let mut maxAge : Option Int := none
for (attrName, attrVal) in attrs do
match attrName with
| "domain" =>
let v := (attrVal.getD "").trimAscii.toString
-- RFC 6265 §5.2.3: ignore a leading U+002E FULL STOP character
let v := if v.startsWith "." then (v.drop 1).toString else v
if !v.isEmpty then domain := some v
| "path" =>
let v := (attrVal.getD "").trimAscii.toString
-- RFC 6265 §5.2.4: if av-value is empty or does not start with "/", use default
if !v.isEmpty && v.startsWith "/" then path := some v
| "secure" => secure := true
| "httponly" => httpOnly := true
| "max-age" =>
-- RFC 6265 §5.2.2: parse an optional leading '-' followed by one or more digits.
if let some v := attrVal then
let s := v.trimAscii.toString
let (neg, digits) := if s.startsWith "-" then (true, s.drop 1) else (false, s)
if !digits.isEmpty && digits.all Char.isDigit then
let absVal : Nat := digits.foldl (fun acc c => acc * 10 + (c.toNat - '0'.toNat)) 0
maxAge := some (if neg then -(absVal : Int) else (absVal : Int))
| _ => pure ()
return { name, value, domain, path, secure, httpOnly, maxAge }
end Std.Http.Cookie.Parser

View File

@@ -192,4 +192,9 @@ Standard Location header name
-/
def location : Header.Name := .mk "location"
/--
Standard Proxy-Authorization header name
-/
def proxyAuthorization : Header.Name := .mk "proxy-authorization"
end Std.Http.Header.Name

View File

@@ -381,6 +381,32 @@ def toDecodedSegments (p : Path) : Array String :=
p.segments.map fun seg =>
seg.decode.getD (toString seg)
/--
Returns `true` if `pre` is a segment-wise prefix of `p`. Each segment in `pre` must equal
the corresponding segment in `p` by encoded value. An absolute `pre` additionally requires
`p` to be absolute.
-/
def startsWith (p pre : Path) : Bool :=
(!pre.absolute || p.absolute) &&
pre.segments.size p.segments.size &&
(Array.range pre.segments.size).all fun i => p.segments[i]! == pre.segments[i]!
/--
Returns `true` if the path ends with a trailing slash. The root path (`/`) is considered to
have a trailing slash.
-/
def hasTrailingSlash (p : Path) : Bool :=
(p.absolute && p.segments.isEmpty) ||
(p.segments.back?.map (toString · == "") |>.getD false)
/--
Ensures the path ends with a trailing slash by appending an empty segment if needed. Idempotent:
the root path (`/`) and any path already ending with `/` are returned unchanged.
-/
def ensureTrailingSlash (p : Path) : Path :=
if p.hasTrailingSlash then p
else { p with segments := p.segments.push (EncodedSegment.encode "") }
end Path
/--

View File

@@ -18,5 +18,6 @@ public import Std.Sync.Broadcast
public import Std.Sync.StreamMap
public import Std.Sync.CancellationToken
public import Std.Sync.CancellationContext
public import Std.Sync.Watch
@[expose] public section

319
src/Std/Sync/Watch.lean Normal file
View File

@@ -0,0 +1,319 @@
/-
Copyright (c) 2026 Lean FRO, LLC. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
Authors: Sofia Rodrigues
-/
module
prelude
public import Init.Data.Queue
public import Std.Sync.Mutex
public import Std.Internal.Async.Select
public section
open Std.Internal.IO.Async
/-!
This module contains the implementation of `Std.Watch`. `Std.Watch` is a single-value watch
channel, inspired by [tokio's watch implementation](https://github.com/tokio-rs/tokio/blob/master/tokio/src/sync/watch.rs).
A watch channel holds a single value that can be updated by the sender. Multiple receivers
can independently observe the current value and wait for it to change.
Unlike `Std.Channel`, a watch channel:
- Retains only the latest value, not a queue of messages.
- Allows receivers to read the current value without consuming it.
- Notifies all receivers when the value changes (broadcast semantics on change).
- Returns an error on `changed` if the sender has been dropped.
-/
namespace Std
/--
Errors that may be thrown while interacting with the watch channel API.
-/
inductive Watch.Error where
/--
The sender was dropped, so no new values will ever be sent.
-/
| closed
deriving Repr, DecidableEq, Hashable
instance : ToString Watch.Error where
toString
| .closed => "watch channel sender was dropped"
instance : MonadLift (EIO Watch.Error) IO where
monadLift x := EIO.toIO (.userError <| toString ·) x
private inductive Watch.Waiter (α : Type) where
| normal (promise : IO.Promise α)
| select (waiter : Internal.IO.Async.Waiter α)
private def Watch.Waiter.resolve (c : Watch.Waiter α) (x : α) : BaseIO Bool := do
match c with
| .normal promise =>
promise.resolve x
return true
| .select waiter =>
waiter.race (return false) fun promise => do
promise.resolve (.ok x)
return true
/--
The shared state of a watch channel.
-/
private structure Watch.State (α : Type) where
/--
The current value held by the watch channel.
-/
value : α
/--
Monotonically increasing version. Incremented on every `send`.
-/
version : Nat
/--
Whether the sender has been dropped (closed).
-/
closed : Bool
/--
Receivers waiting for the value to change.
-/
waiters : Std.Queue (Watch.Waiter (Except Watch.Error Unit))
deriving Nonempty
/--
A watch channel sender. Holds a reference to the shared state and can update the value.
-/
structure Watch (α : Type) where
private mk ::
private state : Mutex (Watch.State α)
deriving Nonempty
/--
A watch channel receiver. Each receiver independently tracks the version it last observed.
-/
structure Watch.Receiver (α : Type) where
private mk ::
private state : Mutex (Watch.State α)
private lastSeen : IO.Ref Nat
deriving Nonempty
namespace Watch
/--
Creates a new watch channel with an initial value. Returns the sender and a receiver.
-/
def new (initial : α) : BaseIO (Watch α × Watch.Receiver α) := do
let state Mutex.new {
value := initial
version := 0
closed := false
waiters :=
}
let lastSeen IO.mkRef 0
return (state, state, lastSeen)
/--
Sends a new value, updating the watched value and notifying all waiting receivers.
-/
def send (w : Watch α) (v : α) : BaseIO Unit := do
w.state.atomically do
let st get
let newVersion := st.version + 1
set { st with value := v, version := newVersion, waiters := }
for waiter in st.waiters.toArray do
discard <| waiter.resolve (.ok ())
/--
Closes the watch channel, signaling to receivers that no more values will be sent.
Waiting receivers will be woken up and their `changed` call will return `Watch.Error.closed`.
-/
def close (w : Watch α) : BaseIO Unit := do
w.state.atomically do
let st get
set { st with closed := true, waiters := }
for waiter in st.waiters.toArray do
discard <| waiter.resolve (.error .closed)
/--
Returns `true` if the sender has been closed.
-/
def isClosed (w : Watch α) : BaseIO Bool :=
w.state.atomically do
return ( get).closed
/--
Returns the current value held by the watch channel, as seen from the sender side.
-/
def current (w : Watch α) : BaseIO α :=
w.state.atomically do
return ( MonadState.get).value
namespace Receiver
/--
Borrow the current value without marking it as seen.
-/
def borrow (r : Watch.Receiver α) : BaseIO α :=
r.state.atomically do
return ( get).value
/--
Borrow the current value and mark the current version as seen, so that
the next `changed` call will only wake on a strictly newer value.
-/
def borrowAndUpdate (r : Watch.Receiver α) : BaseIO α := do
r.state.atomically do
let st get
r.lastSeen.set st.version
return st.value
/--
Returns `true` if the watched value has changed since this receiver last called
`borrowAndUpdate` or `changed`.
-/
def hasChanged (r : Watch.Receiver α) : BaseIO Bool := do
r.state.atomically do
let st get
let seen r.lastSeen.get
return st.version > seen
/--
Wait until the watched value changes relative to the version last seen by this receiver.
Returns `ok ()` on success or `error Watch.Error.closed` if the sender was dropped.
After a successful return the new value can be retrieved with `borrow` or `borrowAndUpdate`.
-/
partial def changed (r : Watch.Receiver α) : BaseIO (Task (Except Watch.Error Unit)) := do
r.state.atomically do
let st get
let seen r.lastSeen.get
if st.version > seen then
r.lastSeen.set st.version
return .pure <| .ok ()
else if st.closed then
return .pure <| .error .closed
else
let promise IO.Promise.new
modify fun s => { s with waiters := s.waiters.enqueue (.normal promise) }
BaseIO.bindTask promise.result? fun
| none => return .pure <| .error .closed
| some (Except.error e) => return .pure <| .error e
| some (Except.ok ()) =>
/- A notification arrived; recurse so `lastSeen` is updated atomically. -/
r.changed
/--
Creates a `Selector` that resolves when the watched value changes.
-/
def changedSelector (r : Watch.Receiver α) : Selector (Except Watch.Error Unit) where
tryFn := do
r.state.atomically do
let st get
let seen r.lastSeen.get
if st.version > seen then
r.lastSeen.set st.version
return some (.ok ())
else if st.closed then
return some (.error .closed)
else
return none
registerFn waiter := do
r.state.atomically do
let st get
let seen r.lastSeen.get
if st.version > seen || st.closed then
let result : Except Watch.Error Unit :=
if st.version > seen then .ok () else .error .closed
if st.version > seen then r.lastSeen.set st.version
waiter.race (return ()) fun promise =>
promise.resolve (.ok result)
else
modify fun s => { s with waiters := s.waiters.enqueue (.select waiter) }
unregisterFn := do
r.state.atomically do
let st get
let waiters st.waiters.filterM fun
| .normal _ => return true
| .select w => return !( w.checkFinished)
set { st with waiters }
end Receiver
/--
A sync wrapper around `Watch.Receiver` for blocking use.
-/
@[expose] def Sync (α : Type) : Type := Watch α
/--
A sync wrapper around `Watch.Receiver` for blocking use.
-/
@[expose] def Sync.Receiver (α : Type) : Type := Watch.Receiver α
namespace Sync
/--
Creates a new watch channel with an initial value. Returns the sender and a sync receiver.
-/
@[inline]
def new (initial : α) : BaseIO (Sync α × Sync.Receiver α) :=
Watch.new initial
/--
Sends a new value, updating the watched value and notifying all waiting receivers.
-/
@[inline]
def send (w : Sync α) (v : α) : BaseIO Unit :=
Watch.send w v
/--
Closes the watch channel.
-/
@[inline]
def close (w : Sync α) : BaseIO Unit :=
Watch.close w
/--
Returns `true` if the sender has been closed.
-/
@[inline]
def isClosed (w : Sync α) : BaseIO Bool :=
Watch.isClosed w
namespace Receiver
/--
Borrow the current value without marking it as seen.
-/
@[inline]
def borrow (r : Sync.Receiver α) : BaseIO α :=
Watch.Receiver.borrow r
/--
Borrow the current value and mark it as seen.
-/
@[inline]
def borrowAndUpdate (r : Sync.Receiver α) : BaseIO α :=
Watch.Receiver.borrowAndUpdate r
/--
Returns `true` if the watched value has changed since last seen.
-/
@[inline]
def hasChanged (r : Sync.Receiver α) : BaseIO Bool :=
Watch.Receiver.hasChanged r
/--
Block until the watched value changes. Returns `ok ()` or `error Watch.Error.closed`.
-/
def changed (r : Sync.Receiver α) : BaseIO (Except Watch.Error Unit) := do
IO.wait ( Watch.Receiver.changed r)
end Receiver
end Sync
end Watch
end Std

View File

@@ -0,0 +1,431 @@
import Std.Internal.Http
import Std.Internal.Async
import Std.Internal.Async.Timer
open Std.Internal.IO Async
open Std Http
/-!
# HTTP Client Security Tests
Tests for security properties of the HTTP client:
- `Authorization` is stripped on cross-scheme redirects (same host+port, different scheme).
Before the fix `crossOrigin` checked host+port only; a http→https redirect to the same
host+port would silently keep the credential header.
- Streaming (`.outgoing`) request bodies must not be retried on connection failure.
A channel-backed body is consumed on first use; retrying would send an empty body.
-/
private def runWithTimeout (name : String) (timeoutMs : Nat := 3000) (action : IO Unit) : IO Unit := do
let task IO.asTask action
let ticks := (timeoutMs + 9) / 10
let rec loop (remaining : Nat) : IO Unit := do
if ( IO.getTaskState task) == .finished then
match ( IO.wait task) with
| .ok x => pure x
| .error err => throw err
else
match remaining with
| 0 =>
IO.cancel task
throw <| IO.userError s!"Test '{name}' timed out after {timeoutMs}ms"
| n + 1 =>
IO.sleep 10
loop n
loop ticks
-- Build a raw HTTP/1.1 response byte string.
private def rawResp
(status : String) (hdrs : Array (String × String)) (body : String) : ByteArray :=
let hdrLines := hdrs.foldl (fun s (k, v) => s ++ s!"{k}: {v}\r\n") ""
s!"HTTP/1.1 {status}\r\n{hdrLines}\r\n{body}".toUTF8
-- ============================================================
-- Redirect: Authorization stripped on scheme-change redirect
-- ============================================================
-- A 302 redirect from http://example.com:443/ to https://example.com:443/r has the
-- same host and port but a different scheme. crossOrigin must be true so that the
-- Authorization header is stripped before the redirect request is sent.
-- ============================================================
#eval show IO _ from runWithTimeout "scheme-change strips Authorization" 3000 <| Async.block do
let (mockClient, mockServer) Mock.new
let session Client.Session.new mockServer (config := {})
let cookieJar Cookie.Jar.new
let some domain := URI.DomainName.ofString? "example.com"
| throw (IO.userError "DomainName parse failed")
-- Agent with scheme=http on port 443. Redirect target https://example.com:443/r
-- has same host+port but different scheme → crossOrigin must be true after the fix.
let agent : Client.Agent Mock.Server := {
session
scheme := URI.Scheme.ofString! "http"
host := .name domain
port := 443
cookieJar
}
let request Request.new
|>.method .get
|>.uri! "/"
|>.header! "Host" "example.com:443"
|>.header! "Authorization" "Bearer secret-token"
|>.blank
let resultPromise : IO.Promise (Except String (Response Body.Incoming)) IO.Promise.new
background do
let result : Except String (Response Body.Incoming) try
let resp Client.Agent.send agent request
pure (Except.ok resp)
catch e => pure (Except.error (toString e))
discard <| resultPromise.resolve result
-- First exchange: drain the request, reply with 302 redirecting to HTTPS same host+port.
let _ mockClient.recv?
mockClient.send (rawResp "302 Found"
#[("Location", "https://example.com:443/redirected"),
("Content-Length", "0"),
("Connection", "keep-alive")] "")
-- Second exchange: receive the redirected request and capture its bytes.
let some redirectBytes mockClient.recv?
| throw (IO.userError "Test failed: no redirect request received")
mockClient.send (rawResp "200 OK"
#[("Content-Length", "2"), ("Connection", "close")] "ok")
-- Wait for the agent to finish.
match await resultPromise.result! with
| Except.error e => throw (IO.userError s!"agent error: {e}")
| Except.ok _ => pure ()
let redirectText := String.fromUTF8! redirectBytes
if redirectText.contains "Authorization:" then
throw <| IO.userError
s!"Test 'scheme-change strips Authorization' FAILED: \
Authorization header present in redirect request\n{redirectText.quote}"
-- ============================================================
-- Redirect: Authorization preserved on same-origin redirect
-- ============================================================
-- A 302 redirect to the same scheme, host, and port is a same-origin redirect.
-- The Authorization header must NOT be stripped in this case.
-- ============================================================
#eval show IO _ from runWithTimeout "same-origin preserves Authorization" 3000 <| Async.block do
let (mockClient, mockServer) Mock.new
let session Client.Session.new mockServer (config := {})
let cookieJar Cookie.Jar.new
let some domain := URI.DomainName.ofString? "example.com"
| throw (IO.userError "DomainName parse failed")
let agent : Client.Agent Mock.Server := {
session
scheme := URI.Scheme.ofString! "http"
host := .name domain
port := 80
cookieJar
}
let request Request.new
|>.method .get
|>.uri! "/"
|>.header! "Host" "example.com"
|>.header! "Authorization" "Bearer secret-token"
|>.blank
let resultPromise : IO.Promise (Except String (Response Body.Incoming)) IO.Promise.new
background do
let result : Except String (Response Body.Incoming) try
let resp Client.Agent.send agent request
pure (Except.ok resp)
catch e => pure (Except.error (toString e))
discard <| resultPromise.resolve result
-- First exchange: reply with 302 to same scheme+host+port.
let _ mockClient.recv?
mockClient.send (rawResp "302 Found"
#[("Location", "http://example.com/same-origin"),
("Content-Length", "0"),
("Connection", "keep-alive")] "")
-- Second exchange: receive the redirected request.
let some redirectBytes mockClient.recv?
| throw (IO.userError "Test failed: no redirect request received")
mockClient.send (rawResp "200 OK"
#[("Content-Length", "2"), ("Connection", "close")] "ok")
match await resultPromise.result! with
| Except.error e => throw (IO.userError s!"agent error: {e}")
| Except.ok _ => pure ()
let redirectText := String.fromUTF8! redirectBytes
unless redirectText.contains "Authorization:" do
throw <| IO.userError
s!"Test 'same-origin preserves Authorization' FAILED: \
Authorization header was stripped on same-origin redirect\n{redirectText.quote}"
-- ============================================================
-- Body replay classification
-- ============================================================
-- Verifies that only `.outgoing` (channel-backed streaming) bodies are classified as
-- non-replayable, while `.full` (fixed bytes) and `.empty` bodies are safe to retry.
-- This mirrors the `bodyIsReplayable` check added to the retry guard in Agent.lean.
-- ============================================================
#eval show IO _ from Async.block do
-- .outgoing: channel is consumed on first send, must not be retried.
let (out, _) Body.mkChannel
let streamBody : Body.AnyBody := .outgoing out
let replayable := match streamBody with | .outgoing _ => false | _ => true
if replayable then
throw <| IO.userError "Test 'outgoing body is not replayable' FAILED"
-- .full: fixed ByteArray, safe to send again.
let fullBody : Body.AnyBody := .full ( Body.Full.ofByteArray "hello".toUTF8)
let replayable2 := match fullBody with | .outgoing _ => false | _ => true
unless replayable2 do
throw <| IO.userError "Test 'full body is replayable' FAILED"
-- .empty: trivially safe.
let emptyBody : Body.AnyBody := .empty {}
let replayable3 := match emptyBody with | .outgoing _ => false | _ => true
unless replayable3 do
throw <| IO.userError "Test 'empty body is replayable' FAILED"
-- ============================================================
-- Redirect: non-HTTP/HTTPS scheme in Location is not followed
-- ============================================================
-- A 302 response with Location: ftp://internal-host/secret must not be followed.
-- Before the fix, decideRedirect accepted any scheme that RequestTarget.parse? could
-- parse and would try to connect to the ftp host on port 80 (SSRF).
-- After the fix, only http:// and https:// redirect targets are followed; everything
-- else returns the 3xx response as-is.
-- ============================================================
#eval show IO _ from runWithTimeout "ftp:// redirect not followed" 3000 <| Async.block do
let (mockClient, mockServer) Mock.new
let session Client.Session.new mockServer (config := {})
let cookieJar Cookie.Jar.new
let some domain := URI.DomainName.ofString? "example.com"
| throw (IO.userError "DomainName parse failed")
let agent : Client.Agent Mock.Server := {
session
scheme := URI.Scheme.ofString! "http"
host := .name domain
port := 80
cookieJar
}
let request Request.new
|>.method .get
|>.uri! "/"
|>.header! "Host" "example.com"
|>.blank
let resultPromise : IO.Promise (Except String (Response Body.Incoming)) IO.Promise.new
background do
let result try
let resp Client.Agent.send agent request
pure (Except.ok resp)
catch e => pure (Except.error (toString e))
discard <| resultPromise.resolve result
-- Server replies with a redirect to ftp:// (non-HTTP scheme).
let _ mockClient.recv?
mockClient.send (rawResp "302 Found"
#[("Location", "ftp://internal-host/secret"),
("Content-Length", "0")] "")
match await resultPromise.result! with
| Except.error e => throw (IO.userError s!"agent error: {e}")
| Except.ok resp =>
-- The agent must return the 302 as-is, not follow it.
unless resp.line.status == .found do
throw <| IO.userError
s!"Test 'ftp:// redirect not followed' FAILED: expected 302, got {resp.line.status.toCode}"
#eval show IO _ from runWithTimeout "file:// redirect not followed" 3000 <| Async.block do
let (mockClient, mockServer) Mock.new
let session Client.Session.new mockServer (config := {})
let cookieJar Cookie.Jar.new
let some domain := URI.DomainName.ofString? "example.com"
| throw (IO.userError "DomainName parse failed")
let agent : Client.Agent Mock.Server := {
session
scheme := URI.Scheme.ofString! "http"
host := .name domain
port := 80
cookieJar
}
let request Request.new
|>.method .get
|>.uri! "/"
|>.header! "Host" "example.com"
|>.blank
let resultPromise : IO.Promise (Except String (Response Body.Incoming)) IO.Promise.new
background do
let result try
let resp Client.Agent.send agent request
pure (Except.ok resp)
catch e => pure (Except.error (toString e))
discard <| resultPromise.resolve result
let _ mockClient.recv?
mockClient.send (rawResp "301 Moved Permanently"
#[("Location", "file:///etc/passwd"),
("Content-Length", "0")] "")
match await resultPromise.result! with
| Except.error e => throw (IO.userError s!"agent error: {e}")
| Except.ok resp =>
unless resp.line.status == .movedPermanently do
throw <| IO.userError
s!"Test 'file:// redirect not followed' FAILED: expected 301, got {resp.line.status.toCode}"
-- ============================================================
-- Redirect: https:// redirect IS followed (sanity check)
-- ============================================================
-- Verify that the scheme restriction doesn't accidentally block legitimate
-- https:// redirects (same host, different scheme from http to https).
-- ============================================================
#eval show IO _ from runWithTimeout "https:// redirect is followed" 3000 <| Async.block do
let (mockClient, mockServer) Mock.new
let session Client.Session.new mockServer (config := {})
let cookieJar Cookie.Jar.new
let some domain := URI.DomainName.ofString? "example.com"
| throw (IO.userError "DomainName parse failed")
-- Agent with connectTo = none; cross-host redirects return the 3xx as-is.
-- We use the same-host case: http://example.com:80/target (same host+port, scheme changes).
let agent : Client.Agent Mock.Server := {
session
scheme := URI.Scheme.ofString! "http"
host := .name domain
port := 80
cookieJar
}
let request Request.new
|>.method .get
|>.uri! "/"
|>.header! "Host" "example.com"
|>.blank
let resultPromise : IO.Promise (Except String (Response Body.Incoming)) IO.Promise.new
background do
let result try
let resp Client.Agent.send agent request
pure (Except.ok resp)
catch e => pure (Except.error (toString e))
discard <| resultPromise.resolve result
-- 302 to https://example.com/page — same host+port, scheme differs from http.
-- No connectTo factory means cross-origin redirect returns 302 as-is, but at least
-- the scheme check must not block it before that point; the 302 must be attempted.
let _ mockClient.recv?
mockClient.send (rawResp "302 Found"
#[("Location", "https://example.com/page"),
("Content-Length", "0")] "")
-- For same-host https: the redirect IS attempted on the same session; serve 200.
let redirectReqOpt mockClient.recv?
if redirectReqOpt.isSome then
mockClient.send (rawResp "200 OK"
#[("Content-Length", "2"), ("Connection", "close")] "ok")
match await resultPromise.result! with
| Except.error e => throw (IO.userError s!"agent error: {e}")
| Except.ok resp =>
-- We accept either 302 (no connectTo for cross-host) or 200 (same-session follow).
let code := resp.line.status.toCode
unless code == 200 || code == 302 do
throw <| IO.userError
s!"Test 'https:// redirect is followed' FAILED: unexpected status {code}"
-- ============================================================
-- Redirect: streaming body dropped on method-preserving redirect
-- ============================================================
-- A 307 redirect preserves the original method and body. When the body is a
-- streaming channel (.outgoing) that has already been consumed by the first
-- request, it cannot be replayed. The redirect request must send no body
-- (Content-Length: 0) rather than silently retransmitting whatever bytes remain
-- in the channel (none, so it would be empty anyway — but the fix must explicitly
-- classify .outgoing as non-replayable so future semantics stay correct).
-- ============================================================
#eval show IO _ from runWithTimeout "streaming body dropped on 307 redirect" 3000 <| Async.block do
let (mockClient, mockServer) Mock.new
let session Client.Session.new mockServer (config := {})
let cookieJar Cookie.Jar.new
let some domain := URI.DomainName.ofString? "example.com"
| throw (IO.userError "DomainName parse failed")
let agent : Client.Agent Mock.Server := {
session
scheme := URI.Scheme.ofString! "http"
host := .name domain
port := 80
cookieJar
}
-- Build a PUT request with a streaming body.
let (bodyOut, bodyIn) Body.mkChannel
-- Write some payload and close the channel.
Body.Writer.send bodyOut { data := "payload".toUTF8 } false
Body.Writer.close bodyOut
let request Request.new
|>.method .put
|>.uri! "/upload"
|>.header! "Host" "example.com"
|>.stream (fun out => do
Body.Writer.send out { data := "payload".toUTF8 } false
Body.Writer.close out)
let resultPromise : IO.Promise (Except String (Response Body.Incoming)) IO.Promise.new
background do
let result try
let resp Client.Agent.send agent request
pure (Except.ok resp)
catch e => pure (Except.error (toString e))
discard <| resultPromise.resolve result
-- First request: drain it and reply with 307 (method-preserving redirect).
let some _firstBytes mockClient.recv?
| throw (IO.userError "Test failed: no first request received")
mockClient.send (rawResp "307 Temporary Redirect"
#[("Location", "/new-upload"),
("Content-Length", "0")] "")
-- Second request: the redirect. The streaming body is already consumed so
-- the client must send no body (Content-Length: 0 or absent, no body bytes).
let some redirectBytes mockClient.recv?
| throw (IO.userError "Test failed: no redirect request received")
mockClient.send (rawResp "200 OK"
#[("Content-Length", "2"), ("Connection", "close")] "ok")
match await resultPromise.result! with
| Except.error e => throw (IO.userError s!"agent error: {e}")
| Except.ok _ => pure ()
let redirectText := String.fromUTF8! redirectBytes
-- The redirect request must target /new-upload.
unless redirectText.contains "PUT /new-upload" do
throw <| IO.userError
s!"Test 'streaming body dropped on 307 redirect' FAILED: expected PUT /new-upload\n{redirectText.quote}"
-- The body must be empty: Content-Length 0 (or no body bytes after blank line).
-- We check that "payload" does not appear in the redirect request.
if redirectText.contains "payload" then
throw <| IO.userError
s!"Test 'streaming body dropped on 307 redirect' FAILED: \
streaming body payload present in redirect request\n{redirectText.quote}"
-- Discard the incoming body channel created during test setup.
Body.Reader.close bodyIn

View File

@@ -0,0 +1,255 @@
import Std.Internal.Http.Data.Cookie
open Std.Http
/-!
# Cookie Parser Tests
Tests for `Set-Cookie` header parsing following RFC 6265 §4.1.
-/
-- Helper: parse a Set-Cookie header value, throw on failure.
def parseCookie (s : String) : IO Cookie.Parser.Parsed :=
IO.ofExcept (Cookie.Parser.parseSetCookie.run s.toUTF8)
-- Helper: assert parsing fails.
def parseShouldFail (label : String) (s : String) : IO Unit := do
match Cookie.Parser.parseSetCookie.run s.toUTF8 with
| .ok _ => throw <| IO.userError s!"Test '{label}' failed: expected parse failure but succeeded"
| .error _ => pure ()
-- ============================================================================
-- Basic cookie-pair
-- ============================================================================
#eval show IO _ from do
-- Minimal name=value
let p parseCookie "foo=bar"
unless p.name == "foo" do
throw <| IO.userError s!"Test 'basic name' failed: expected 'foo', got {p.name.quote}"
unless p.value == "bar" do
throw <| IO.userError s!"Test 'basic value' failed: expected 'bar', got {p.value.quote}"
unless p.domain == none do
throw <| IO.userError s!"Test 'basic domain absent' failed: expected none, got {repr p.domain}"
unless p.path == none do
throw <| IO.userError s!"Test 'basic path absent' failed: expected none, got {repr p.path}"
unless p.secure == false do
throw <| IO.userError "Test 'basic secure absent' failed: expected false"
-- Empty value is allowed (cookie-value = *cookie-octet)
let pEmpty parseCookie "session="
unless pEmpty.value == "" do
throw <| IO.userError s!"Test 'empty value' failed: expected '', got {pEmpty.value.quote}"
-- Numeric name is not a valid token (digits alone are not all tchar? Actually digits ARE tchar)
-- tchar includes DIGIT, so "123" is a valid token
let pNum parseCookie "123=abc"
unless pNum.name == "123" do
throw <| IO.userError s!"Test 'numeric name' failed: expected '123', got {pNum.name.quote}"
-- ============================================================================
-- Quoted cookie values
-- ============================================================================
#eval show IO _ from do
-- Double-quoted value: quotes are stripped, inner value is returned
let p parseCookie "id=\"abc123\""
unless p.value == "abc123" do
throw <| IO.userError s!"Test 'quoted value' failed: expected 'abc123', got {p.value.quote}"
-- Empty quoted value
let pEq parseCookie "id=\"\""
unless pEq.value == "" do
throw <| IO.userError s!"Test 'empty quoted value' failed: expected '', got {pEq.value.quote}"
-- Quoted value with all valid cookie-octets (excluding DQUOTE, SP, comma, semicolon, backslash)
let pOctets parseCookie "t=\"!#$%&'*+-.^_`|~\""
unless pOctets.value == "!#$%&'*+-.^_`|~" do
throw <| IO.userError s!"Test 'quoted cookie-octets' failed: got {pOctets.value.quote}"
-- ============================================================================
-- Domain attribute
-- ============================================================================
#eval show IO _ from do
-- Domain present
let p parseCookie "x=y; Domain=example.com"
unless p.domain == some "example.com" do
throw <| IO.userError s!"Test 'domain' failed: expected some \"example.com\", got {repr p.domain}"
-- Leading dot is stripped per RFC 6265 §5.2.3
let pDot parseCookie "x=y; Domain=.example.com"
unless pDot.domain == some "example.com" do
throw <| IO.userError s!"Test 'domain leading dot stripped' failed: expected some \"example.com\", got {repr pDot.domain}"
-- Empty domain attribute → domain is none
let pEmpty parseCookie "x=y; Domain="
unless pEmpty.domain == none do
throw <| IO.userError s!"Test 'empty domain' failed: expected none, got {repr pEmpty.domain}"
-- Dot-only domain → stripped to empty → domain is none
let pDotOnly parseCookie "x=y; Domain=."
unless pDotOnly.domain == none do
throw <| IO.userError s!"Test 'dot-only domain' failed: expected none, got {repr pDotOnly.domain}"
-- ============================================================================
-- Path attribute
-- ============================================================================
#eval show IO _ from do
-- Valid path starting with /
let p parseCookie "x=y; Path=/foo/bar"
unless p.path == some "/foo/bar" do
throw <| IO.userError s!"Test 'path' failed: expected some \"/foo/bar\", got {repr p.path}"
-- Root path
let pRoot parseCookie "x=y; Path=/"
unless pRoot.path == some "/" do
throw <| IO.userError s!"Test 'root path' failed: expected some \"/\", got {repr pRoot.path}"
-- Path not starting with / → none per RFC 6265 §5.2.4
let pNoSlash parseCookie "x=y; Path=noslash"
unless pNoSlash.path == none do
throw <| IO.userError s!"Test 'path without leading slash' failed: expected none, got {repr pNoSlash.path}"
-- Empty path → none
let pEmpty parseCookie "x=y; Path="
unless pEmpty.path == none do
throw <| IO.userError s!"Test 'empty path' failed: expected none, got {repr pEmpty.path}"
-- ============================================================================
-- Secure attribute
-- ============================================================================
#eval show IO _ from do
-- Secure present
let p parseCookie "x=y; Secure"
unless p.secure == true do
throw <| IO.userError "Test 'secure' failed: expected true"
-- Secure absent
let pNo parseCookie "x=y"
unless pNo.secure == false do
throw <| IO.userError "Test 'secure absent' failed: expected false"
-- Secure= (with a value — treated as Secure since we match the attr name)
let pVal parseCookie "x=y; Secure=true"
unless pVal.secure == true do
throw <| IO.userError "Test 'secure with value' failed: expected true"
-- ============================================================================
-- Combined attributes
-- ============================================================================
#eval show IO _ from do
let p parseCookie "sessionId=abc123; Domain=example.com; Path=/app; Secure"
unless p.name == "sessionId" do
throw <| IO.userError s!"Test 'combined name' failed: got {p.name.quote}"
unless p.value == "abc123" do
throw <| IO.userError s!"Test 'combined value' failed: got {p.value.quote}"
unless p.domain == some "example.com" do
throw <| IO.userError s!"Test 'combined domain' failed: got {repr p.domain}"
unless p.path == some "/app" do
throw <| IO.userError s!"Test 'combined path' failed: got {repr p.path}"
unless p.secure == true do
throw <| IO.userError "Test 'combined secure' failed: expected true"
-- ============================================================================
-- Case-insensitive attribute names (RFC 6265 §5.2)
-- ============================================================================
#eval show IO _ from do
-- Uppercase attribute names must be recognized
let p parseCookie "x=y; DOMAIN=example.com; PATH=/; SECURE"
unless p.domain == some "example.com" do
throw <| IO.userError s!"Test 'uppercase domain' failed: got {repr p.domain}"
unless p.path == some "/" do
throw <| IO.userError s!"Test 'uppercase path' failed: got {repr p.path}"
unless p.secure == true do
throw <| IO.userError "Test 'uppercase secure' failed: expected true"
-- Mixed-case attribute names
let pMixed parseCookie "x=y; Domain=a.com; Secure; Path=/x"
unless pMixed.domain == some "a.com" do
throw <| IO.userError s!"Test 'mixed-case domain' failed: got {repr pMixed.domain}"
unless pMixed.secure == true do
throw <| IO.userError "Test 'mixed-case secure' failed: expected true"
unless pMixed.path == some "/x" do
throw <| IO.userError s!"Test 'mixed-case path' failed: got {repr pMixed.path}"
-- ============================================================================
-- Unknown attributes are silently ignored
-- ============================================================================
#eval show IO _ from do
-- HttpOnly is silently ignored
let p parseCookie "x=y; HttpOnly"
unless p.name == "x" && p.value == "y" do
throw <| IO.userError s!"Test 'HttpOnly ignored' failed: name={p.name.quote} value={p.value.quote}"
-- Expires is silently ignored
let pExp parseCookie "x=y; Expires=Thu, 01 Jan 2026 00:00:00 GMT"
unless pExp.name == "x" && pExp.value == "y" do
throw <| IO.userError s!"Test 'Expires ignored' failed"
-- SameSite is silently ignored
let pSS parseCookie "x=y; SameSite=Strict"
unless pSS.name == "x" && pSS.value == "y" do
throw <| IO.userError s!"Test 'SameSite ignored' failed"
-- Max-Age is silently ignored
let pMaxAge parseCookie "x=y; Max-Age=3600"
unless pMaxAge.name == "x" && pMaxAge.value == "y" do
throw <| IO.userError s!"Test 'Max-Age ignored' failed"
-- Multiple unknown attributes
let pMulti parseCookie "x=y; Foo=bar; HttpOnly; Baz; Path=/p"
unless pMulti.path == some "/p" do
throw <| IO.userError s!"Test 'unknown attrs + path' failed: got {repr pMulti.path}"
-- ============================================================================
-- Duplicate attribute handling (last-write-wins is fine, RFC 6265 §5.3)
-- ============================================================================
#eval show IO _ from do
-- Last Domain wins
let p parseCookie "x=y; Domain=first.com; Domain=second.com"
unless p.domain == some "second.com" do
throw <| IO.userError s!"Test 'duplicate domain last wins' failed: got {repr p.domain}"
-- Last Path wins
let pPath parseCookie "x=y; Path=/first; Path=/second"
unless pPath.path == some "/second" do
throw <| IO.userError s!"Test 'duplicate path last wins' failed: got {repr pPath.path}"
-- ============================================================================
-- Semicolon spacing: RFC 6265 §4.1 allows optional SP after ";"
-- ============================================================================
#eval show IO _ from do
-- No space after semicolon
let pNoSp parseCookie "x=y;Secure"
unless pNoSp.secure == true do
throw <| IO.userError "Test 'no space after semicolon' failed: expected Secure=true"
-- Space after semicolon (standard)
let pSp parseCookie "x=y; Secure"
unless pSp.secure == true do
throw <| IO.userError "Test 'space after semicolon' failed: expected Secure=true"
-- ============================================================================
-- Invalid cookie names → parse failure
-- ============================================================================
#eval show IO _ from do
-- Empty name is not a valid token (token = 1*tchar, requires at least one char)
parseShouldFail "empty name" "=value"
-- Space in name (space is not a tchar)
parseShouldFail "space in name" "foo bar=value"
-- Semicolon in name (not a tchar)
parseShouldFail "semicolon in name" "foo;bar=value"
-- Missing '=' separator
parseShouldFail "missing equals" "nameonly"