Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import net.ssl
|
||||
import strings
|
||||
|
||||
fn (req &Request) ssl_do(port int, method Method, host_name string, path string, data string, header Header) !Response {
|
||||
$if windows && !no_vschannel ? {
|
||||
return vschannel_ssl_do(req, port, method, host_name, path, data, header)
|
||||
}
|
||||
return net_ssl_do(req, port, method, host_name, path, data, header)
|
||||
}
|
||||
|
||||
fn net_ssl_do(req &Request, port int, method Method, host_name string, path string, data string, header Header) !Response {
|
||||
mut retries := 0
|
||||
req_headers := req.build_request_headers_with(method, host_name, port, path, data, header)
|
||||
$if trace_http_request ? {
|
||||
eprint('> ')
|
||||
eprint(req_headers)
|
||||
eprintln('')
|
||||
}
|
||||
// Advertise ALPN `h2` (with an `http/1.1` fallback) when HTTP/2 is enabled.
|
||||
// This is the default for https requests, so ordinary get()/fetch() calls
|
||||
// advertise ALPN and use HTTP/2 when the server selects it; callers can opt
|
||||
// out with `enable_http2: false`. The HTTP/2 read path feeds the same
|
||||
// streaming callbacks and honors the stop limits, so they do not force
|
||||
// HTTP/1.1.
|
||||
alpn := if req.enable_http2 { ['h2', 'http/1.1'] } else { []string{} }
|
||||
for {
|
||||
mut ssl_conn := ssl.new_ssl_conn(
|
||||
verify: req.verify
|
||||
cert: req.cert
|
||||
cert_key: req.cert_key
|
||||
validate: req.validate
|
||||
in_memory_verification: req.in_memory_verification
|
||||
alpn_protocols: alpn
|
||||
)!
|
||||
ssl_conn.dial(host_name, port) or {
|
||||
retries++
|
||||
if is_no_need_retry_error(err.code()) || retries >= req.max_retries {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Propagate the request's read timeout into the SSL backend.
|
||||
// Without this, mbedtls keeps its init-time default and openssl falls back to no
|
||||
// timeout at all on a stalled socket — see issue surfaced by macOS arm64 + tcc CI hangs.
|
||||
if req.read_timeout > 0 {
|
||||
ssl_conn.set_read_timeout(req.read_timeout)
|
||||
}
|
||||
// If the server negotiated HTTP/2 via ALPN, speak it; otherwise fall
|
||||
// back to the existing HTTP/1.1 path unchanged.
|
||||
if req.enable_http2 && ssl_conn.negotiated_alpn() == 'h2' {
|
||||
return req.h2_do(mut ssl_conn, method, host_name, port, path, data, header)!
|
||||
}
|
||||
return req.do_request(req_headers, mut ssl_conn)!
|
||||
}
|
||||
return error('http.net_ssl_do: exhausted retries')
|
||||
}
|
||||
|
||||
// h2_do runs a single request over an HTTP/2 connection on an already-dialled,
|
||||
// ALPN-negotiated `h2` TLS socket, and returns the response as a net.http
|
||||
// Response. The request's streaming callbacks (on_progress / on_progress_body)
|
||||
// and stop limits are adapted onto the H2 chunk hook so they fire per DATA
|
||||
// frame, matching the HTTP/1.1 streaming semantics as closely as is possible
|
||||
// on the framed wire (on_progress receives DATA payloads rather than raw
|
||||
// network reads).
|
||||
fn (req &Request) h2_do(mut ssl_conn ssl.SSLConn, method Method, host_name string, port int, path string, data string, header Header) !Response {
|
||||
defer {
|
||||
ssl_conn.shutdown() or {}
|
||||
}
|
||||
mut conn := new_h2_conn(ssl_conn)
|
||||
return req.h2_exchange(mut conn, method, host_name, port, path, data, header)!
|
||||
}
|
||||
|
||||
// h2_exchange runs a single request over an already-established H2Conn and
|
||||
// converts the result to a net.http Response. It is transport-agnostic: the
|
||||
// caller is responsible for building the H2Conn over whatever ALPN-negotiated
|
||||
// `h2` transport (net.ssl on most platforms, SChannel on default Windows) and
|
||||
// for tearing it down afterwards. The request's streaming callbacks and stop
|
||||
// limits are adapted onto the H2 chunk hook, as documented on h2_do.
|
||||
fn (req &Request) h2_exchange(mut conn H2Conn, method Method, host_name string, port int, path string, data string, header Header) !Response {
|
||||
base := req.to_h2_request(method, h2_authority(host_name, port), path, data, header)
|
||||
on_progress := req.on_progress
|
||||
on_progress_body := req.on_progress_body
|
||||
mut on_data := H2DataFn(unsafe { nil })
|
||||
if on_progress != unsafe { nil } || on_progress_body != unsafe { nil } {
|
||||
on_data = fn [req, on_progress, on_progress_body] (chunk []u8, body_so_far u64, body_expected u64, status int) ! {
|
||||
if on_progress != unsafe { nil } {
|
||||
on_progress(req, chunk, body_so_far)!
|
||||
}
|
||||
if on_progress_body != unsafe { nil } {
|
||||
on_progress_body(req, chunk, body_so_far, body_expected, status)!
|
||||
}
|
||||
}
|
||||
}
|
||||
h2req := H2ClientRequest{
|
||||
method: base.method
|
||||
scheme: base.scheme
|
||||
authority: base.authority
|
||||
path: base.path
|
||||
headers: base.headers
|
||||
body: base.body
|
||||
on_data: on_data
|
||||
stop_copying_limit: req.stop_copying_limit
|
||||
stop_receiving_limit: req.stop_receiving_limit
|
||||
}
|
||||
h2resp := conn.do(h2req)!
|
||||
if req.on_finish != unsafe { nil } {
|
||||
req.on_finish(req, u64(h2resp.body.len))!
|
||||
}
|
||||
return h2_response_to_http(h2resp)
|
||||
}
|
||||
|
||||
fn read_from_ssl_connection_cb(con voidptr, buf &u8, bufsize int) !int {
|
||||
mut ssl_conn := unsafe { &ssl.SSLConn(con) }
|
||||
return ssl_conn.socket_read_into_ptr(buf, bufsize)
|
||||
}
|
||||
|
||||
fn (req &Request) do_request(req_headers string, mut ssl_conn ssl.SSLConn) !Response {
|
||||
defer {
|
||||
ssl_conn.shutdown() or {}
|
||||
}
|
||||
ssl_conn.write_string(req_headers) or { return err }
|
||||
mut content := strings.new_builder(4096)
|
||||
response_info := req.receive_all_data_from_cb_in_builder(mut content, voidptr(ssl_conn),
|
||||
read_from_ssl_connection_cb)!
|
||||
response_text := content.str()
|
||||
$if trace_http_response ? {
|
||||
eprint('< ')
|
||||
eprint(response_text)
|
||||
eprintln('')
|
||||
}
|
||||
if req.on_finish != unsafe { nil } {
|
||||
req.on_finish(req, u64(response_text.len))!
|
||||
}
|
||||
return parse_received_response(response_text, response_info)
|
||||
}
|
||||
|
||||
// h1_exchange_ssl sends an already-built HTTP/1.x request over an open TLS
|
||||
// connection and reads one response, leaving the connection open (unlike
|
||||
// do_request, which shuts the connection down). The bool result reports
|
||||
// whether the response was precisely framed, so the connection can safely
|
||||
// carry another request (see ReceivedResponseInfo.reusable).
|
||||
fn (req &Request) h1_exchange_ssl(mut ssl_conn ssl.SSLConn, raw string) !(Response, bool) {
|
||||
ssl_conn.write_string(raw) or {
|
||||
return error('http.transport: TLS connection write failed: ${err.msg()}')
|
||||
}
|
||||
mut content := strings.new_builder(4096)
|
||||
response_info := req.receive_all_data_from_cb_in_builder(mut content, voidptr(ssl_conn),
|
||||
read_from_ssl_connection_cb)!
|
||||
response_text := content.str()
|
||||
$if trace_http_response ? {
|
||||
eprint('< ')
|
||||
eprint(response_text)
|
||||
eprintln('')
|
||||
}
|
||||
if req.on_finish != unsafe { nil } {
|
||||
req.on_finish(req, u64(response_text.len))!
|
||||
}
|
||||
resp := parse_received_response(response_text, response_info)!
|
||||
return resp, response_info.reusable
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module http
|
||||
|
||||
fn vschannel_ssl_do(_req &Request, _port int, _method Method, _host_name string, _path string, _data string, _header Header) !Response {
|
||||
return error('vschannel is unavailable in cross C generation')
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
#flag windows -I @VEXEROOT/thirdparty/vschannel
|
||||
#flag -l ws2_32 -l crypt32 -l secur32 -l user32
|
||||
#include "vschannel.c"
|
||||
// Win7 needs TLS 1.2 selected before Schannel credentials are acquired.
|
||||
#define vschannel_use_tls12_client_protocol() (protocol = SP_PROT_TLS1_2_CLIENT)
|
||||
|
||||
pub struct C.TlsContext {}
|
||||
|
||||
const C.vsc_init_resp_buff_size int
|
||||
|
||||
fn C.new_tls_context() C.TlsContext
|
||||
fn C.vschannel_use_tls12_client_protocol()
|
||||
fn C.vschannel_init(tls_ctx &C.TlsContext, validate_server_certificate C.BOOL)
|
||||
fn C.vschannel_last_error(tls_ctx &C.TlsContext) int
|
||||
fn C.vschannel_alpn_supported() int
|
||||
|
||||
// vschannel_request_on_open mirrors C.request (declared in builtin/cfns.c.v) but
|
||||
// runs over an already-open connection. See thirdparty/vschannel/vschannel.c.
|
||||
fn C.vschannel_request_on_open(&C.TlsContext, &u8, u32, &&u8, fn (voidptr, isize) voidptr) i32
|
||||
|
||||
fn vschannel_ssl_do(req &Request, port int, method Method, host_name string, path string, data string, header Header) !Response {
|
||||
// When HTTP/2 is enabled (the default for https), advertise ALPN `h2` and,
|
||||
// if the server selects it, speak HTTP/2. Otherwise fall back to HTTP/1.1
|
||||
// over the same connection (see vschannel_h2_do). When HTTP/2 is opted out
|
||||
// of — or this Windows version's SChannel predates client-side ALPN
|
||||
// (pre-8.1), where injecting the ALPN buffer can fail the handshake
|
||||
// outright — use the original one-shot HTTP/1.1 path with no ALPN.
|
||||
if req.enable_http2 && C.vschannel_alpn_supported() != 0 {
|
||||
return vschannel_h2_do(req, port, method, host_name, path, data, header)!
|
||||
}
|
||||
return vschannel_h1_do(req, port, method, host_name, path, data, header)!
|
||||
}
|
||||
|
||||
// vschannel_h1_do is the original one-shot HTTP/1.1 SChannel request path:
|
||||
// connect, handshake, send the whole request, read the whole response,
|
||||
// disconnect. It is used when HTTP/2 is disabled.
|
||||
fn vschannel_h1_do(req &Request, port int, method Method, host_name string, path string, data string, header Header) !Response {
|
||||
mut ctx := C.new_tls_context()
|
||||
C.vschannel_use_tls12_client_protocol()
|
||||
C.vschannel_init(&ctx, C.BOOL(if req.validate { 1 } else { 0 }))
|
||||
mut buff := unsafe { malloc_noscan(C.vsc_init_resp_buff_size) }
|
||||
addr := host_name
|
||||
sdata := req.build_request_headers_with(method, host_name, port, path, data, header)
|
||||
$if trace_http_request ? {
|
||||
eprintln('> ${sdata}')
|
||||
}
|
||||
length := C.request(&ctx, port, addr.to_wide(), sdata.str, sdata.len, &buff, v_realloc)
|
||||
err_code := C.vschannel_last_error(&ctx)
|
||||
C.vschannel_cleanup(&ctx)
|
||||
return req.vschannel_finish_response(buff, length, err_code)!
|
||||
}
|
||||
|
||||
// vschannel_h1_on_open runs the one-shot HTTP/1.1 request over a connection that
|
||||
// vschannel_h2_connect() already opened, used as the fallback when the server
|
||||
// did not negotiate `h2`. It consumes (and cleans up) `ctx`.
|
||||
fn (req &Request) vschannel_h1_on_open(ctx &C.TlsContext, method Method, host_name string, port int, path string, data string, header Header) !Response {
|
||||
mut buff := unsafe { malloc_noscan(C.vsc_init_resp_buff_size) }
|
||||
sdata := req.build_request_headers_with(method, host_name, port, path, data, header)
|
||||
$if trace_http_request ? {
|
||||
eprintln('> ${sdata}')
|
||||
}
|
||||
length := C.vschannel_request_on_open(ctx, sdata.str, sdata.len, &buff, v_realloc)
|
||||
err_code := C.vschannel_last_error(ctx)
|
||||
C.vschannel_cleanup(ctx)
|
||||
return req.vschannel_finish_response(buff, length, err_code)!
|
||||
}
|
||||
|
||||
// vschannel_finish_response turns the raw response buffer produced by the C
|
||||
// request paths into a parsed Response, firing the progress/finish callbacks.
|
||||
fn (req &Request) vschannel_finish_response(buff &u8, length int, err_code int) !Response {
|
||||
if length <= 0 {
|
||||
if err_code != 0 {
|
||||
return vschannel_request_error(err_code)
|
||||
}
|
||||
return error('http: vschannel request failed')
|
||||
}
|
||||
response_text := unsafe { buff.vstring_with_len(length) }
|
||||
if req.on_progress != unsafe { nil } {
|
||||
req.on_progress(req, unsafe { buff.vbytes(length) }, u64(length))!
|
||||
}
|
||||
$if trace_http_response ? {
|
||||
eprintln('< ${response_text}')
|
||||
}
|
||||
if req.on_finish != unsafe { nil } {
|
||||
req.on_finish(req, u64(response_text.len))!
|
||||
}
|
||||
return vschannel_parse_response(response_text, err_code)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
module http
|
||||
|
||||
fn test_build_request_headers_with_empty_body_adds_content_length_zero() {
|
||||
// Create a request with no data.
|
||||
mut req := Request{}
|
||||
// Build the headers for it. Ensure that Content-Length: 0 is added
|
||||
// for requests without a body, which is required by some servers.
|
||||
// We use a POST request, as it is most likely to be affected by this.
|
||||
headers := req.build_request_headers(.post, 'localhost', 80, '/')
|
||||
assert headers.contains('Content-Length: 0\r\n')
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
module chunked
|
||||
|
||||
import strings
|
||||
// See: https://en.wikipedia.org/wiki/Chunked_transfer_encoding
|
||||
// /////////////////////////////////////////////////////////////
|
||||
// The chunk size is transferred as a hexadecimal number
|
||||
// followed by \r\n as a line separator,
|
||||
// followed by a chunk of data of the given size.
|
||||
// The end is marked with a chunk with size 0.
|
||||
|
||||
struct ChunkScanner {
|
||||
mut:
|
||||
pos int
|
||||
text string
|
||||
}
|
||||
|
||||
fn (mut s ChunkScanner) read_chunk_size() u32 {
|
||||
mut n := u32(0)
|
||||
for {
|
||||
if s.pos >= s.text.len {
|
||||
break
|
||||
}
|
||||
c := s.text[s.pos]
|
||||
if !c.is_hex_digit() {
|
||||
break
|
||||
}
|
||||
n = n << 4
|
||||
n += u32(unhex(c))
|
||||
s.pos++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
fn unhex(c u8) u8 {
|
||||
if `0` <= c && c <= `9` {
|
||||
return c - `0`
|
||||
} else if `a` <= c && c <= `f` {
|
||||
return c - `a` + 10
|
||||
} else if `A` <= c && c <= `F` {
|
||||
return c - `A` + 10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fn (mut s ChunkScanner) skip_crlf() {
|
||||
s.pos += 2
|
||||
}
|
||||
|
||||
fn (mut s ChunkScanner) read_chunk(chunksize u32) !string {
|
||||
startpos := s.pos
|
||||
s.pos += int(chunksize)
|
||||
if s.pos > s.text.len {
|
||||
return error('invalid chunksize')
|
||||
}
|
||||
return s.text[startpos..s.pos]
|
||||
}
|
||||
|
||||
pub fn decode(text string) !string {
|
||||
mut sb := strings.new_builder(100)
|
||||
mut cscanner := ChunkScanner{
|
||||
pos: 0
|
||||
text: text
|
||||
}
|
||||
for {
|
||||
csize := cscanner.read_chunk_size()
|
||||
if 0 == csize {
|
||||
break
|
||||
}
|
||||
cscanner.skip_crlf()
|
||||
ch := cscanner.read_chunk(csize)!
|
||||
sb.write_string(ch)
|
||||
cscanner.skip_crlf()
|
||||
}
|
||||
cscanner.skip_crlf()
|
||||
return sb.str()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
module chunked
|
||||
|
||||
fn test_invalid_chunk() {
|
||||
mut is_failure := false
|
||||
|
||||
decode('eee') or { is_failure = true }
|
||||
|
||||
assert is_failure
|
||||
}
|
||||
|
||||
fn test_valid_chunk() {
|
||||
chunks := '4\r\nWiki\r\n7\r\npedia i\r\nB\r\nn \r\nchunks.\r\n0\r\n\r\n'
|
||||
str := decode(chunks) or { panic('uh oh') }
|
||||
|
||||
assert str == 'Wikipedia in \r\nchunks.'
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// Copyright (c) 2019 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import time
|
||||
import strings
|
||||
|
||||
pub struct Cookie {
|
||||
pub mut:
|
||||
name string
|
||||
value string
|
||||
path string // optional
|
||||
domain string // optional
|
||||
expires time.Time // optional
|
||||
raw_expires string // for reading cookies only. optional.
|
||||
// max_age=0 means no 'Max-Age' attribute specified.
|
||||
// max_age<0 means delete cookie now, equivalently 'Max-Age: 0'
|
||||
// max_age>0 means Max-Age attribute present and given in seconds
|
||||
max_age int
|
||||
secure bool
|
||||
http_only bool
|
||||
same_site SameSite
|
||||
raw string
|
||||
unparsed []string // Raw text of unparsed attribute-value pairs
|
||||
}
|
||||
|
||||
// SameSite allows a server to define a cookie attribute making it impossible for
|
||||
// the browser to send this cookie along with cross-site requests. The main
|
||||
// goal is to mitigate the risk of cross-origin information leakage, and provide
|
||||
// some protection against cross-site request forgery attacks.
|
||||
//
|
||||
// See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details.
|
||||
pub enum SameSite {
|
||||
same_site_not_set
|
||||
same_site_default_mode = 1
|
||||
same_site_lax_mode
|
||||
same_site_strict_mode
|
||||
same_site_none_mode
|
||||
}
|
||||
|
||||
// Parses all "Cookie" values from the header `h` and
|
||||
// returns the successfully parsed Cookies.
|
||||
//
|
||||
// if `filter` isn't empty, only cookies of that name are returned
|
||||
pub fn read_cookies(h Header, filter string) []&Cookie {
|
||||
// lines := h['Cookie']
|
||||
lines := h.values(.cookie) // or {
|
||||
if lines.len == 0 {
|
||||
return []
|
||||
}
|
||||
mut cookies := []&Cookie{}
|
||||
for _, line_ in lines {
|
||||
mut line := line_.trim_space()
|
||||
mut part := ''
|
||||
for line.len > 0 {
|
||||
mut semicolon_position :=
|
||||
line.index_any(';') // Store the position of the next semicolon
|
||||
if semicolon_position > 0 { // So, there is a semicolon, let's parse until that position
|
||||
line_parts :=
|
||||
line[..semicolon_position].split(';') // split the line only until that semicolon
|
||||
line = line[(semicolon_position + 1)..] // and then skip everything before the semicolon
|
||||
part = line_parts[0]
|
||||
} else {
|
||||
part = line
|
||||
line = ''
|
||||
}
|
||||
part = part.trim_space()
|
||||
if part.len == 0 {
|
||||
continue
|
||||
}
|
||||
mut name, mut val := part.split_once('=') or { part, '' }
|
||||
if !is_cookie_name_valid(name) {
|
||||
continue
|
||||
}
|
||||
if filter != '' && filter != name {
|
||||
continue
|
||||
}
|
||||
val = parse_cookie_value(val, true) or { continue }
|
||||
cookies << &Cookie{
|
||||
name: name
|
||||
value: val
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
// str returns the serialization of the cookie for use in a Cookie header
|
||||
// (if only Name and Value are set) or a Set-Cookie response
|
||||
// header (if other fields are set).
|
||||
//
|
||||
// If c.name is invalid, the empty string is returned.
|
||||
pub fn (c &Cookie) str() string {
|
||||
if !is_cookie_name_valid(c.name) {
|
||||
return ''
|
||||
}
|
||||
// extra_cookie_length derived from typical length of cookie attributes
|
||||
// see RFC 6265 Sec 4.1.
|
||||
extra_cookie_length := 110
|
||||
mut b := strings.new_builder(c.name.len + c.value.len + c.domain.len + c.path.len +
|
||||
extra_cookie_length)
|
||||
b.write_string(c.name)
|
||||
b.write_string('=')
|
||||
b.write_string(sanitize_cookie_value(c.value))
|
||||
if c.path.len > 0 {
|
||||
b.write_string('; path=')
|
||||
b.write_string(sanitize_cookie_path(c.path))
|
||||
}
|
||||
if c.domain.len > 0 {
|
||||
if valid_cookie_domain(c.domain) {
|
||||
// A `domain` containing illegal characters is not
|
||||
// sanitized but simply dropped which turns the cookie
|
||||
// into a host-only cookie. A leading dot is okay
|
||||
// but won't be sent.
|
||||
mut d := c.domain
|
||||
if d[0] == `.` {
|
||||
d = d.substr(1, d.len)
|
||||
}
|
||||
b.write_string('; domain=')
|
||||
b.write_string(d)
|
||||
} else {
|
||||
// TODO: Log invalid cookie domain warning
|
||||
}
|
||||
}
|
||||
if c.expires.year > 1600 {
|
||||
time_str := c.expires.http_header_string()
|
||||
b.write_string('; expires=')
|
||||
b.write_string(time_str)
|
||||
}
|
||||
// TODO: Fix this. Technically a max age of 0 or less should be 0
|
||||
// We need a way to not have a max age.
|
||||
if c.max_age > 0 {
|
||||
b.write_string('; Max-Age=')
|
||||
b.write_string(c.max_age.str())
|
||||
} else if c.max_age < 0 {
|
||||
b.write_string('; Max-Age=0')
|
||||
}
|
||||
if c.http_only {
|
||||
b.write_string('; HttpOnly')
|
||||
}
|
||||
if c.secure {
|
||||
b.write_string('; Secure')
|
||||
}
|
||||
match c.same_site {
|
||||
.same_site_not_set {}
|
||||
.same_site_default_mode {
|
||||
b.write_string('; SameSite')
|
||||
}
|
||||
.same_site_none_mode {
|
||||
b.write_string('; SameSite=None')
|
||||
}
|
||||
.same_site_lax_mode {
|
||||
b.write_string('; SameSite=Lax')
|
||||
}
|
||||
.same_site_strict_mode {
|
||||
b.write_string('; SameSite=Strict')
|
||||
}
|
||||
}
|
||||
|
||||
return b.str()
|
||||
}
|
||||
|
||||
fn sanitize(valid fn (u8) bool, v string) string {
|
||||
mut ok := true
|
||||
for i in 0 .. v.len {
|
||||
if valid(v[i]) {
|
||||
continue
|
||||
}
|
||||
// TODO: Warn that we're dropping the invalid byte?
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
if ok {
|
||||
return v.clone()
|
||||
}
|
||||
return v.bytes().filter(valid(it)).bytestr()
|
||||
}
|
||||
|
||||
fn sanitize_cookie_name(name string) string {
|
||||
return name.replace_each(['\n', '-', '\r', '-'])
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc6265#section-4.1.1
|
||||
// cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
|
||||
// cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
|
||||
// ; US-ASCII characters excluding CTLs,
|
||||
// ; whitespace DQUOTE, comma, semicolon,
|
||||
// ; and backslash
|
||||
// We loosen this as spaces and commas are common in cookie values
|
||||
// but we produce a quoted cookie-value in when value starts or ends
|
||||
// with a comma or space.
|
||||
pub fn sanitize_cookie_value(v string) string {
|
||||
val := sanitize(valid_cookie_value_byte, v)
|
||||
if v.len == 0 {
|
||||
return v
|
||||
}
|
||||
// Check for the existence of a space, comma or semicolon
|
||||
if val.starts_with(' ') || v.contains(';') || val.ends_with(' ') || val.starts_with(',')
|
||||
|| val.ends_with(',') {
|
||||
return '"${v}"'
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
fn sanitize_cookie_path(v string) string {
|
||||
return sanitize(valid_cookie_path_byte, v)
|
||||
}
|
||||
|
||||
fn valid_cookie_value_byte(b u8) bool {
|
||||
return 0x20 <= b && b < 0x7f && b != `"` && b != `;` && b != `\\`
|
||||
}
|
||||
|
||||
fn valid_cookie_path_byte(b u8) bool {
|
||||
return 0x20 <= b && b < 0x7f && b != `!`
|
||||
}
|
||||
|
||||
fn valid_cookie_domain(v string) bool {
|
||||
if is_cookie_domain_name(v) {
|
||||
return true
|
||||
}
|
||||
// TODO
|
||||
// valid_ip := net.parse_ip(v) or {
|
||||
// false
|
||||
// }
|
||||
// if valid_ip {
|
||||
// return true
|
||||
// }
|
||||
return false
|
||||
}
|
||||
|
||||
pub fn is_cookie_domain_name(_s string) bool {
|
||||
mut s := _s
|
||||
if s.len == 0 {
|
||||
return false
|
||||
}
|
||||
if s.len > 255 {
|
||||
return false
|
||||
}
|
||||
if s[0] == `.` {
|
||||
s = s.substr(1, s.len)
|
||||
}
|
||||
mut last := `.`
|
||||
mut ok := false
|
||||
mut part_len := 0
|
||||
for i, _ in s {
|
||||
c := s[i]
|
||||
if c.is_letter() {
|
||||
// No '_' allowed here (in contrast to package net).
|
||||
ok = true
|
||||
part_len++
|
||||
} else if `0` <= c && c <= `9` {
|
||||
// fine
|
||||
part_len++
|
||||
} else if c == `-` {
|
||||
// Byte before dash cannot be dot.
|
||||
if last == `.` {
|
||||
return false
|
||||
}
|
||||
part_len++
|
||||
} else if c == `.` {
|
||||
// Byte before dot cannot be dot, dash.
|
||||
if last == `.` || last == `-` {
|
||||
return false
|
||||
}
|
||||
if part_len > 63 || part_len == 0 {
|
||||
return false
|
||||
}
|
||||
part_len = 0
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
last = c
|
||||
}
|
||||
if last == `-` || part_len > 63 {
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
fn parse_cookie_value(_raw string, allow_double_quote bool) !string {
|
||||
mut raw := _raw
|
||||
// Strip the quotes, if present
|
||||
if allow_double_quote && raw.len > 1 && raw[0] == `"` && raw[raw.len - 1] == `"` {
|
||||
raw = raw.substr(1, raw.len - 1)
|
||||
}
|
||||
for i in 0 .. raw.len {
|
||||
if !valid_cookie_value_byte(raw[i]) {
|
||||
return error('http.cookie: invalid cookie value')
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
fn is_cookie_name_valid(name string) bool {
|
||||
if name == '' {
|
||||
return false
|
||||
}
|
||||
for b in name {
|
||||
if b < 33 || b > 126 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn parse_cookie(line string) !Cookie {
|
||||
mut parts := line.trim_space().split(';')
|
||||
if parts.len == 1 && parts[0] == '' {
|
||||
return error('malformed cookie')
|
||||
}
|
||||
parts[0] = parts[0].trim_space()
|
||||
index := parts[0].index('=') or { return error('malformed cookie') }
|
||||
name := parts[0][..index]
|
||||
raw_value := parts[0][index + 1..]
|
||||
if !is_cookie_name_valid(name) {
|
||||
return error('malformed cookie')
|
||||
}
|
||||
value := parse_cookie_value(raw_value, true) or { return error('malformed cookie') }
|
||||
mut c := Cookie{
|
||||
name: name
|
||||
value: value
|
||||
raw: line
|
||||
}
|
||||
for i, _ in parts {
|
||||
parts[i] = parts[i].trim_space()
|
||||
if parts[i].len == 0 {
|
||||
continue
|
||||
}
|
||||
mut attr := parts[i]
|
||||
mut raw_val := ''
|
||||
if ind := parts[i].index('=') {
|
||||
attr = parts[i][..ind]
|
||||
raw_val = parts[i][ind + 1..]
|
||||
}
|
||||
lower_attr := attr.to_lower()
|
||||
val := parse_cookie_value(raw_val, false) or {
|
||||
c.unparsed << parts[i]
|
||||
continue
|
||||
}
|
||||
match lower_attr {
|
||||
'samesite' {
|
||||
lower_val := val.to_lower()
|
||||
match lower_val {
|
||||
'lax' { c.same_site = .same_site_lax_mode }
|
||||
'strict' { c.same_site = .same_site_strict_mode }
|
||||
'none' { c.same_site = .same_site_none_mode }
|
||||
else { c.same_site = .same_site_default_mode }
|
||||
}
|
||||
}
|
||||
'secure' {
|
||||
c.secure = true
|
||||
continue
|
||||
}
|
||||
'httponly' {
|
||||
c.http_only = true
|
||||
continue
|
||||
}
|
||||
'domain' {
|
||||
c.domain = val
|
||||
continue
|
||||
}
|
||||
'max-age' {
|
||||
mut secs := val.int()
|
||||
if secs != 0 && val[0] != `0` {
|
||||
break
|
||||
}
|
||||
if secs <= 0 {
|
||||
secs = -1
|
||||
}
|
||||
c.max_age = secs
|
||||
continue
|
||||
}
|
||||
// TODO: Fix this once time works better
|
||||
// 'expires' {
|
||||
// c.raw_expires = val
|
||||
// mut exptime := time.parse_iso(val)
|
||||
// if exptime.year == 0 {
|
||||
// exptime = time.parse_iso('Mon, 02-Jan-2006 15:04:05 MST')
|
||||
// }
|
||||
// c.expires = exptime
|
||||
// continue
|
||||
// }
|
||||
'path' {
|
||||
c.path = val
|
||||
continue
|
||||
}
|
||||
else {
|
||||
c.unparsed << parts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import net.http
|
||||
|
||||
struct SetCookieTestCase {
|
||||
cookie &http.Cookie
|
||||
raw string
|
||||
}
|
||||
|
||||
struct ReadSetCookiesTestCase {
|
||||
header map[string][]string
|
||||
cookies []&http.Cookie
|
||||
}
|
||||
|
||||
struct AddCookieTestCase {
|
||||
cookie []&http.Cookie
|
||||
raw string
|
||||
}
|
||||
|
||||
const write_set_cookie_tests = [
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-1'
|
||||
value: 'v1'
|
||||
}
|
||||
raw: 'cookie-1=v1'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-2'
|
||||
value: 'two'
|
||||
max_age: 3600
|
||||
}
|
||||
raw: 'cookie-2=two; Max-Age=3600'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-3'
|
||||
value: 'three'
|
||||
domain: '.example.com'
|
||||
}
|
||||
raw: 'cookie-3=three; domain=example.com'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-4'
|
||||
value: 'four'
|
||||
path: '/restricted/'
|
||||
}
|
||||
raw: 'cookie-4=four; path=/restricted/'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-5'
|
||||
value: 'five'
|
||||
domain: 'wrong;bad.abc'
|
||||
}
|
||||
raw: 'cookie-5=five'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-6'
|
||||
value: 'six'
|
||||
domain: 'bad-.abc'
|
||||
}
|
||||
raw: 'cookie-6=six'
|
||||
},
|
||||
// SetCookieTestCase{
|
||||
// cookie: &http.Cookie{name: 'cookie-7', value: 'seven', domain: '127.0.0.1'},
|
||||
// raw: 'cookie-7=seven; domain=127.0.0.1'
|
||||
// },
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-8'
|
||||
value: 'eight'
|
||||
domain: '::1'
|
||||
}
|
||||
raw: 'cookie-8=eight'
|
||||
},
|
||||
// {
|
||||
// cookie: &http.Cookie{name: 'cookie-9', value: 'expiring', expires: time.unix(1257894000, 0)},
|
||||
// 'cookie-9=expiring; Expires=Tue, 10 Nov 2009 23:00:00 GMT',
|
||||
// },
|
||||
// According to IETF 6265 Section 5.1.1.5, the year cannot be less than 1601
|
||||
// SetCookieTestCase{
|
||||
// cookie: &http.Cookie{name: 'cookie-10', value: 'expiring-1601', expires: time.parse('Mon, 01 Jan 1601 01:01:01 GMT')},
|
||||
// raw: 'cookie-10=expiring-1601; Expires=Mon, 01 Jan 1601 01:01:01 GMT'
|
||||
// },
|
||||
// SetCookieTestCase{
|
||||
// cookie: &http.Cookie{name: 'cookie-11', value: 'invalid-expiry', expires: time.parse('Mon, 01 Jan 1600 01:01:01 GMT')},
|
||||
// raw: 'cookie-11=invalid-expiry'
|
||||
// },
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-12'
|
||||
value: 'samesite-default'
|
||||
same_site: .same_site_default_mode
|
||||
}
|
||||
raw: 'cookie-12=samesite-default; SameSite'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-13'
|
||||
value: 'samesite-lax'
|
||||
same_site: .same_site_lax_mode
|
||||
}
|
||||
raw: 'cookie-13=samesite-lax; SameSite=Lax'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-14'
|
||||
value: 'samesite-strict'
|
||||
same_site: .same_site_strict_mode
|
||||
}
|
||||
raw: 'cookie-14=samesite-strict; SameSite=Strict'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'cookie-15'
|
||||
value: 'samesite-none'
|
||||
same_site: .same_site_none_mode
|
||||
}
|
||||
raw: 'cookie-15=samesite-none; SameSite=None'
|
||||
},
|
||||
// The 'special' cookies have values containing commas or spaces which
|
||||
// are disallowed by RFC 6265 but are common in the wild.
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'special-1'
|
||||
value: 'a z'
|
||||
}
|
||||
raw: 'special-1=a z'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'special-2'
|
||||
value: ' z'
|
||||
}
|
||||
raw: 'special-2=" z"'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'special-3'
|
||||
value: 'a '
|
||||
}
|
||||
raw: 'special-3="a "'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'special-4'
|
||||
value: ' '
|
||||
}
|
||||
raw: 'special-4=" "'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'special-5'
|
||||
value: 'a,z'
|
||||
}
|
||||
raw: 'special-5=a,z'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'special-6'
|
||||
value: ',z'
|
||||
}
|
||||
raw: 'special-6=",z"'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'special-7'
|
||||
value: 'a,'
|
||||
}
|
||||
raw: 'special-7="a,"'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'special-8'
|
||||
value: ','
|
||||
}
|
||||
raw: 'special-8=","'
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'empty-value'
|
||||
value: ''
|
||||
}
|
||||
raw: 'empty-value='
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: ''
|
||||
}
|
||||
raw: ''
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: '\t'
|
||||
}
|
||||
raw: ''
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: '\r'
|
||||
}
|
||||
raw: ''
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'a\nb'
|
||||
value: 'v'
|
||||
}
|
||||
raw: ''
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'a\nb'
|
||||
value: 'v'
|
||||
}
|
||||
raw: ''
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'a\rb'
|
||||
value: 'v'
|
||||
}
|
||||
raw: ''
|
||||
},
|
||||
SetCookieTestCase{
|
||||
cookie: &http.Cookie{
|
||||
name: 'complex-value'
|
||||
value: 'a b,c;d'
|
||||
}
|
||||
raw: 'complex-value="a b,c;d"'
|
||||
},
|
||||
]
|
||||
const add_cookies_tests = [
|
||||
AddCookieTestCase{
|
||||
cookie: []
|
||||
raw: ''
|
||||
},
|
||||
AddCookieTestCase{
|
||||
cookie: [&http.Cookie{
|
||||
name: 'cookie-1'
|
||||
value: 'v1'
|
||||
}]
|
||||
raw: 'cookie-1=v1'
|
||||
},
|
||||
AddCookieTestCase{
|
||||
cookie: [&http.Cookie{
|
||||
name: 'cookie-1'
|
||||
value: 'v1'
|
||||
}, &http.Cookie{
|
||||
name: 'cookie-2'
|
||||
value: 'v2'
|
||||
},
|
||||
&http.Cookie{
|
||||
name: 'cookie-3'
|
||||
value: 'v3'
|
||||
}]
|
||||
raw: 'cookie-1=v1; cookie-2=v2; cookie-3=v3'
|
||||
},
|
||||
]
|
||||
|
||||
fn test_write_set_cookies() {
|
||||
for _, tt in write_set_cookie_tests {
|
||||
assert tt.cookie.str() == tt.raw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import os
|
||||
|
||||
// download_file retrieves a document from the URL `url`,
|
||||
// and saves it in the output file path `out_file_path`.
|
||||
pub fn download_file(url string, out_file_path string) ! {
|
||||
$if debug_http ? {
|
||||
println('http.download_file url=${url} out_file_path=${out_file_path}')
|
||||
}
|
||||
s := get(url) or { return err }
|
||||
if s.status() != .ok {
|
||||
return error_with_code(s.body, s.status_code)
|
||||
}
|
||||
$if debug_http ? {
|
||||
println('http.download_file saving ${s.body.len} bytes')
|
||||
}
|
||||
os.write_file(out_file_path, s.body)!
|
||||
}
|
||||
|
||||
pub fn download_file_with_cookies(url string, out_file_path string, cookies map[string]string) ! {
|
||||
$if debug_http ? {
|
||||
println('http.download_file url=${url} out_file_path=${out_file_path}')
|
||||
}
|
||||
s := fetch(method: .get, url: url, cookies: cookies) or { return err }
|
||||
if s.status() != .ok {
|
||||
return error('received http code ${s.status_code}')
|
||||
}
|
||||
$if debug_http ? {
|
||||
println('http.download_file saving ${s.body.len} bytes')
|
||||
}
|
||||
os.write_file(out_file_path, s.body)!
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
@@ -0,0 +1,87 @@
|
||||
module http
|
||||
|
||||
// Downloader is the interface that you have to implement, if you need to customise
|
||||
// how download_file_with_progress works, and what output it produces while a file
|
||||
// is downloaded.
|
||||
pub interface Downloader {
|
||||
mut:
|
||||
// Called once, at the start of the streaming download. You can do setup here,
|
||||
// like opening a target file, changing request.stop_copying_limit to a different value,
|
||||
// if you need it.
|
||||
on_start(mut request Request, path string) !
|
||||
// Called many times, once a chunk of data is received
|
||||
on_chunk(request &Request, chunk []u8, already_received u64, expected u64) !
|
||||
// Called once, at the end of the download attempt. Do cleanup here,
|
||||
// like closing a file (opened in on_start), reporting stats etc.
|
||||
// `response` will be empty when the request fails before a response is parsed.
|
||||
on_finish(request &Request, response &Response) !
|
||||
}
|
||||
|
||||
// DownloaderParams is similar to FetchConfig, but it also allows you to pass
|
||||
// a `downloader: your_downloader_instance` parameter.
|
||||
// See also http.SilentStreamingDownloader, and http.TerminalStreamingDownloader .
|
||||
@[params]
|
||||
pub struct DownloaderParams {
|
||||
FetchConfig
|
||||
pub mut:
|
||||
downloader &Downloader = &TerminalStreamingDownloader{}
|
||||
}
|
||||
|
||||
// download_file_with_progress will save the URL `url` to the filepath `path` .
|
||||
// Unlike download_file/2, it *does not* load the whole content in memory, but
|
||||
// instead streams it chunk by chunk to the target `path`, as the chunks are received
|
||||
// from the network. This makes it suitable for downloading big files, *without* increasing
|
||||
// the memory consumption of your application.
|
||||
//
|
||||
// By default, it will also show a progress line, while the download happens.
|
||||
// If you do not want a status line, you can call it like this:
|
||||
// `http.download_file_with_progress(url, path, downloader: http.SilentStreamingDownloader{})`,
|
||||
// or you can implement your own http.Downloader and pass that instead.
|
||||
//
|
||||
// Note: the returned response by this function, will have a truncated .body, after the first
|
||||
// few KBs, because it does not accumulate all its data in memory, instead relying on the
|
||||
// downloaders to save the received data chunk by chunk. You can parametrise this by
|
||||
// using `stop_copying_limit:` but you need to pass a number that is big enough to fit
|
||||
// at least all headers in the response, otherwise the parsing of the response at the end will
|
||||
// fail, despite saving all the data in the file before that. The default is 65536 bytes.
|
||||
pub fn download_file_with_progress(url string, path string, params DownloaderParams) !Response {
|
||||
mut d := unsafe { params.downloader }
|
||||
mut config := params.FetchConfig
|
||||
config.url = url
|
||||
config.user_ptr = voidptr(d)
|
||||
config.on_progress_body = download_progres_cb
|
||||
if config.stop_copying_limit == -1 {
|
||||
// leave more than enough space for potential redirect headers
|
||||
config.stop_copying_limit = 65536
|
||||
}
|
||||
mut req := prepare(config)!
|
||||
d.on_start(mut req, path)!
|
||||
mut response := Response{}
|
||||
defer {
|
||||
d.on_finish(req, response) or {}
|
||||
}
|
||||
response = req.do()!
|
||||
$if windows && !no_vschannel ? {
|
||||
// TODO: remove this, when windows supports streaming properly through vschannel
|
||||
// For now though, just ensure that the complete body is "received" in one big chunk:
|
||||
body_len := u64(response.body.len)
|
||||
d.on_chunk(req, response.body.bytes(), body_len, body_len)!
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const zz = &Downloader(unsafe { nil })
|
||||
|
||||
fn download_progres_cb(request &Request, chunk []u8, body_so_far u64, expected_size u64, status_code int) ! {
|
||||
// TODO: remove this hack, when `unsafe { &Downloader( request.user_ptr ) }` works reliably,
|
||||
// by just casting, without trying to promote the argument to the heap at all.
|
||||
mut d := unsafe { zz }
|
||||
pd := unsafe { &voidptr(&d) }
|
||||
unsafe {
|
||||
*pd = request.user_ptr
|
||||
}
|
||||
if status_code == 200 {
|
||||
// ignore redirects, we are interested in the chunks of the final file:
|
||||
d.on_chunk(request, chunk, body_so_far, expected_size)!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
module http
|
||||
|
||||
import os
|
||||
|
||||
// SilentStreamingDownloader just saves the downloaded file chunks to the given path.
|
||||
// It does *no reporting at all*.
|
||||
// Note: the folder part of the path should already exist, and has to be writable.
|
||||
pub struct SilentStreamingDownloader {
|
||||
pub mut:
|
||||
path string
|
||||
f os.File
|
||||
}
|
||||
|
||||
// on_start is called once at the start of the download.
|
||||
pub fn (mut d SilentStreamingDownloader) on_start(mut request Request, path string) ! {
|
||||
d.path = path
|
||||
d.f = os.create(path)!
|
||||
}
|
||||
|
||||
// on_chunk is called multiple times, once per chunk of received content.
|
||||
pub fn (mut d SilentStreamingDownloader) on_chunk(request &Request, chunk []u8, already_received u64, expected u64) ! {
|
||||
d.f.write(chunk)!
|
||||
}
|
||||
|
||||
// on_finish is called once at the end of the download.
|
||||
pub fn (mut d SilentStreamingDownloader) on_finish(request &Request, response &Response) ! {
|
||||
d.f.close()
|
||||
if response.status_code == 0 && d.path.len > 0 {
|
||||
os.rm(d.path) or {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
module http
|
||||
|
||||
import time
|
||||
|
||||
// TerminalStreamingDownloader is the same as http.SilentStreamingDownloader, but produces a progress line on stdout.
|
||||
pub struct TerminalStreamingDownloader {
|
||||
SilentStreamingDownloader
|
||||
mut:
|
||||
start_time time.Time
|
||||
past_time time.Time
|
||||
past_received u64
|
||||
}
|
||||
|
||||
// on_start is called once at the start of the download.
|
||||
pub fn (mut d TerminalStreamingDownloader) on_start(mut request Request, path string) ! {
|
||||
d.SilentStreamingDownloader.on_start(mut request, path)!
|
||||
d.start_time = time.now()
|
||||
d.past_time = time.now()
|
||||
}
|
||||
|
||||
// on_chunk is called multiple times, once per chunk of received content.
|
||||
pub fn (mut d TerminalStreamingDownloader) on_chunk(request &Request, chunk []u8, already_received u64,
|
||||
expected u64) ! {
|
||||
now := time.now()
|
||||
elapsed := now - d.start_time
|
||||
// delta_elapsed := now - d.past_time
|
||||
// delta_bytes := already_received - d.past_received
|
||||
d.past_time = now
|
||||
d.past_received = already_received
|
||||
ratio := f64(already_received) / f64(expected)
|
||||
res := f64(elapsed) / ratio
|
||||
mut estimated := time.Duration(max_i64)
|
||||
if f64(min_i64) < res && res < f64(max_i64) {
|
||||
estimated = i64(res)
|
||||
}
|
||||
speed := f64(time.millisecond) * f64(already_received) / f64(elapsed)
|
||||
elapsed_s := elapsed.seconds()
|
||||
estimated_s := estimated.seconds()
|
||||
eta_s := f64_max(estimated_s - elapsed_s, 0.0)
|
||||
|
||||
d.SilentStreamingDownloader.on_chunk(request, chunk, already_received, expected)!
|
||||
print('\rDownloading to `${d.path}` ${100.0 * ratio:6.2f}%, ${f64(already_received) / (1024 * 1024):7.3f}/${f64(expected) / (1024 * 1024):-7.3f}MB, ${speed:6.0f}KB/s, elapsed: ${elapsed_s:6.0f}s, eta: ${eta_s:6.0f}s')
|
||||
flush_stdout()
|
||||
}
|
||||
|
||||
// on_finish is called once at the end of the download.
|
||||
pub fn (mut d TerminalStreamingDownloader) on_finish(request &Request, response &Response) ! {
|
||||
d.SilentStreamingDownloader.on_finish(request, response)!
|
||||
println('')
|
||||
flush_stdout()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
module http
|
||||
|
||||
#flag -l urlmon
|
||||
|
||||
#include <urlmon.h>
|
||||
@@ -0,0 +1,28 @@
|
||||
module file
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
pub struct Entity {
|
||||
os.FileInfo
|
||||
path string
|
||||
mod_time time.Time
|
||||
url string
|
||||
fname string
|
||||
}
|
||||
|
||||
fn path_to_entity(path string, uri_path string) Entity {
|
||||
pinfo := os.inode(path)
|
||||
mut uri_base := ''
|
||||
if uri_path.len > 0 {
|
||||
uri_base = '/${uri_path}'
|
||||
}
|
||||
fname := os.file_name(path)
|
||||
return Entity{
|
||||
FileInfo: pinfo
|
||||
path: path
|
||||
mod_time: time.unix(pinfo.mtime)
|
||||
url: '${uri_base}/${fname}'
|
||||
fname: fname
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
module file
|
||||
|
||||
import os
|
||||
import time
|
||||
import strings
|
||||
|
||||
const myexe = os.executable()
|
||||
const myexe_prefix = os.file_name(myexe.all_before_last('.'))
|
||||
|
||||
fn get_folder_index_html(requested_file_path string, uri_path string, filter_myexe bool) string {
|
||||
sw := time.new_stopwatch()
|
||||
mut files := os.ls(requested_file_path) or { [] }
|
||||
if filter_myexe {
|
||||
files = files.filter(!it.contains(myexe_prefix))
|
||||
}
|
||||
mut sb := strings.new_builder(files.len * 200)
|
||||
write_page_header(mut sb, uri_path)
|
||||
write_page_crumbs(mut sb, uri_path)
|
||||
write_page_table(mut sb, uri_path, requested_file_path, mut files)
|
||||
sb.writeln('<p>Server time: <b>${time.now().format_ss()}</b>, generated in <b>${sw.elapsed().microseconds():6}µs</b></p>')
|
||||
write_page_footer(mut sb, uri_path)
|
||||
return sb.str()
|
||||
}
|
||||
|
||||
fn write_page_header(mut sb strings.Builder, uri_path string) {
|
||||
// html boilerplate for the header
|
||||
sb.writeln('<!DOCTYPE html>')
|
||||
sb.writeln('<html lang="en">')
|
||||
sb.writeln('<head>')
|
||||
sb.writeln('<meta charset="utf-8">')
|
||||
sb.writeln('<title>Index of local folder ${uri_path}</title>')
|
||||
sb.writeln('<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />')
|
||||
sb.writeln('</head>')
|
||||
sb.writeln('<body>')
|
||||
}
|
||||
|
||||
fn write_page_footer(mut sb strings.Builder, _uri_path string) {
|
||||
// html boilerplate for the footer
|
||||
sb.writeln('</body>')
|
||||
sb.writeln('</html>')
|
||||
}
|
||||
|
||||
fn write_page_crumbs(mut sb strings.Builder, uri_path string) {
|
||||
crumbs := uri_path.split('/')
|
||||
mut crlinks := []string{cap: crumbs.len}
|
||||
for cridx, crumb in crumbs {
|
||||
cr_so_far := crumbs#[0..cridx + 1].join('/')
|
||||
// eprintln('> cr_so_far: ${cr_so_far:20} | crumb: ${crumb:20}')
|
||||
crlinks << '<a href="/${cr_so_far}">${crumb}</a>'
|
||||
}
|
||||
crlinks_text := crlinks.join(' / ')
|
||||
sb.writeln('<h2>Index of <a href="/">/</a> ${crlinks_text} :</h2>')
|
||||
}
|
||||
|
||||
fn write_page_table(mut sb strings.Builder, uri_path string, requested_file_path string, mut files []string) {
|
||||
files.sort()
|
||||
sb.writeln('<table>')
|
||||
sb.writeln('<tr>')
|
||||
sb.writeln('<th align="left" style="width: 100px">Size</th>')
|
||||
sb.writeln('<th align="left" style="width: 200px">Last modified</th>')
|
||||
sb.writeln('<th align="left">Name</th>')
|
||||
sb.writeln('</tr>')
|
||||
if uri_path == '' {
|
||||
sb.writeln('<tr>')
|
||||
sb.writeln('<td>---</td>')
|
||||
sb.writeln('<td>---</td>')
|
||||
sb.writeln('<td>---</td>')
|
||||
sb.writeln('</tr>')
|
||||
} else {
|
||||
sb.writeln('<tr>')
|
||||
sb.writeln('<td></td>')
|
||||
sb.writeln('<td></td>')
|
||||
sb.writeln('<td><a href="/${uri_path}/..">..</td>')
|
||||
sb.writeln('</tr>')
|
||||
}
|
||||
mut entities := []Entity{cap: files.len}
|
||||
for fname in files {
|
||||
path := os.join_path(requested_file_path, fname)
|
||||
entities << path_to_entity(path, uri_path)
|
||||
}
|
||||
entities.sort_with_compare(fn (a &Entity, b &Entity) int {
|
||||
if a.typ == b.typ {
|
||||
if a.fname < b.fname {
|
||||
return -1
|
||||
}
|
||||
if a.fname > b.fname {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if a.typ == .directory {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
for entity in entities {
|
||||
if entity.typ == .directory {
|
||||
sb.writeln('<tr>')
|
||||
sb.writeln('<td></td>')
|
||||
sb.writeln('<td>${entity.mod_time.format_ss()}</td>')
|
||||
sb.writeln('<td><a href="${entity.url}">${entity.fname}/</a></td>')
|
||||
sb.writeln('</tr>')
|
||||
} else if entity.typ == .symbolic_link {
|
||||
sb.writeln('<tr>')
|
||||
sb.writeln('<td>${entity.size}</td>')
|
||||
sb.writeln('<td>${entity.mod_time.format_ss()}</td>')
|
||||
sb.writeln('<td><a href="${entity.url}">${entity.fname}@</a></td>')
|
||||
sb.writeln('</tr>')
|
||||
} else {
|
||||
sb.writeln('<tr>')
|
||||
sb.writeln('<td>${entity.size}</td>')
|
||||
sb.writeln('<td>${entity.mod_time.format_ss()}</td>')
|
||||
sb.writeln('<td><a href="${entity.url}">${entity.fname}</a></td>')
|
||||
sb.writeln('</tr>')
|
||||
}
|
||||
}
|
||||
sb.writeln('</table>')
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
module file
|
||||
|
||||
import os
|
||||
import log
|
||||
import time
|
||||
import runtime
|
||||
import net.http
|
||||
import net.http.mime
|
||||
import net.urllib
|
||||
|
||||
@[params]
|
||||
pub struct StaticServeParams {
|
||||
pub mut:
|
||||
folder string = $d('http_folder', '.') // The folder, that will be used as a base for serving all static resources; If it was /tmp, then: http://localhost:4001/x.txt => /tmp/x.txt . Customize with `-d http_folder=vlib/_docs`.
|
||||
index_file string = $d('http_index_file', 'index.html') // A request for http://localhost:4001/ will map to `index.html`, if that file is present.
|
||||
auto_index bool = $d('http_auto_index', true) // when an index_file is *not* present, a request for http://localhost:4001/ will list automatically all files in the folder.
|
||||
on string = $d('http_on', 'localhost:4001') // on which address:port to listen for http requests.
|
||||
filter_myexe bool = true // whether to filter the name of the static file executable from the automatic folder listings for / . Useful with `v -e 'import net.http.file; file.serve()'`
|
||||
workers int = runtime.nr_jobs() // how many worker threads to use for serving the responses, by default it is limited to the number of available cores; can be controlled with setting VJOBS
|
||||
shutdown_after time.Duration = time.infinite // after this time has passed, the webserver will gracefully shutdown on its own
|
||||
}
|
||||
|
||||
// serve will start a static files web server.
|
||||
//
|
||||
// The most common usage is the following: `v -e 'import net.http.file; file.serve()'`
|
||||
// will listen for http requests on port 4001 by default, and serve all the files in the current folder.
|
||||
//
|
||||
// Another example: `v -e 'import net.http.file; file.serve(folder: "/tmp")'`
|
||||
// will serve all files inside the /tmp folder.
|
||||
//
|
||||
// Another example: `v -e 'import net.http.file; file.serve(folder: "~/Projects", on: ":5002")'`
|
||||
// will expose all the files inside the ~/Projects folder, on http://localhost:5002/ .
|
||||
pub fn serve(params StaticServeParams) {
|
||||
mut nparams := params
|
||||
nparams.folder = os.norm_path(os.real_path(params.folder))
|
||||
mut server := &http.Server{
|
||||
handler: StaticHttpHandler{
|
||||
params: nparams
|
||||
}
|
||||
addr: params.on
|
||||
worker_num: params.workers
|
||||
}
|
||||
if params.shutdown_after != time.infinite {
|
||||
spawn fn (params StaticServeParams, mut server http.Server) {
|
||||
log.warn('This file server, will shutdown itself after ${params.shutdown_after}.')
|
||||
time.sleep(params.shutdown_after)
|
||||
log.warn('Graceful shutdown, because the file server started ${params.shutdown_after} ago.')
|
||||
server.stop()
|
||||
}(params, mut server)
|
||||
}
|
||||
log.warn('${@METHOD}, starting...')
|
||||
server.listen_and_serve()
|
||||
log.warn('${@METHOD}, done.')
|
||||
}
|
||||
|
||||
// implementation details:
|
||||
|
||||
struct StaticHttpHandler {
|
||||
params StaticServeParams
|
||||
}
|
||||
|
||||
const no_such_file_doc = '<!DOCTYPE html><h1>no such file</h1>'
|
||||
|
||||
fn (mut h StaticHttpHandler) handle(req http.Request) http.Response {
|
||||
mut res := http.new_response(body: '')
|
||||
sw := time.new_stopwatch()
|
||||
mut url := urllib.query_unescape(req.url) or {
|
||||
log.warn('bad request; url: ${req.url} ')
|
||||
res.set_status(.bad_request)
|
||||
res.body = '<!DOCTYPE html><h1>url decode fail</h1>'
|
||||
res.header.add(.content_type, 'text/html; charset=utf-8')
|
||||
return res
|
||||
}
|
||||
defer {
|
||||
log.info('took: ${sw.elapsed().microseconds():6}µs, status: ${res.status_code}, size: ${res.body.len:9}, url: ${url}')
|
||||
}
|
||||
mut uri_path := url.all_after_first('/').all_before('?').trim_right('/')
|
||||
requested_file_path :=
|
||||
os.norm_path(os.real_path(os.join_path_single(h.params.folder, uri_path)))
|
||||
if !requested_file_path.starts_with(h.params.folder) {
|
||||
log.warn('forbidden request; base folder: ${h.params.folder}, requested_file_path: ${requested_file_path}, ')
|
||||
res.set_status(.forbidden)
|
||||
res.body = '<h1>forbidden</h1>'
|
||||
res.header.add(.content_type, 'text/html; charset=utf-8')
|
||||
return res
|
||||
}
|
||||
if !os.exists(requested_file_path) {
|
||||
res.set_status(.not_found)
|
||||
res.body = no_such_file_doc
|
||||
res.header.add(.content_type, 'text/html; charset=utf-8')
|
||||
return res
|
||||
}
|
||||
|
||||
mut body := ''
|
||||
mut content_type := 'text/html; charset=utf-8'
|
||||
if os.is_dir(requested_file_path) {
|
||||
ipath := os.join_path_single(requested_file_path, h.params.index_file)
|
||||
if h.params.auto_index {
|
||||
if h.params.index_file == '' {
|
||||
body = get_folder_index_html(requested_file_path, uri_path, h.params.filter_myexe)
|
||||
} else {
|
||||
body = os.read_file(ipath) or {
|
||||
get_folder_index_html(requested_file_path, uri_path, h.params.filter_myexe)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body = os.read_file(ipath) or {
|
||||
res.set_status(.not_found)
|
||||
no_such_file_doc
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body = os.read_file(requested_file_path) or {
|
||||
res.set_status(.not_found)
|
||||
'not found'
|
||||
}
|
||||
mt := mime.get_mime_type(os.file_ext(requested_file_path).all_after_first('.'))
|
||||
content_type = mime.get_content_type(mt)
|
||||
}
|
||||
res.body = body
|
||||
res.header.add(.content_type, content_type)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// This file converts between net.http's Request/Response and the HTTP/2
|
||||
// client types in h2_conn.v. The actual transport wiring (ALPN negotiation on
|
||||
// the TLS socket) lives in backend.c.v; these helpers are pure and backend
|
||||
// agnostic, so they can be tested without a socket.
|
||||
|
||||
// h2_hop_by_hop are header names that must not be forwarded on HTTP/2
|
||||
// (RFC 7540 Section 8.1.2.2), plus `host` (replaced by the :authority
|
||||
// pseudo-header) and `cookie` (handled specially below).
|
||||
const h2_hop_by_hop = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade',
|
||||
'host', 'cookie']
|
||||
|
||||
// h2_authority returns the :authority value for a host and port, omitting the
|
||||
// port for the default HTTPS port.
|
||||
fn h2_authority(host string, port int) string {
|
||||
if port == 443 || port == 0 {
|
||||
return host
|
||||
}
|
||||
return '${host}:${port}'
|
||||
}
|
||||
|
||||
// to_h2_request builds an HTTP/2 request from this request. Header names are
|
||||
// lowercased, hop-by-hop headers are dropped, the Host header becomes the
|
||||
// :authority pseudo-header, and cookies are collapsed into a single field.
|
||||
fn (req &Request) to_h2_request(method Method, authority string, path string, data string, header Header) H2ClientRequest {
|
||||
// An explicit Host header overrides the URL host, matching the HTTP/1.1
|
||||
// path (used for virtual-host / host-override requests).
|
||||
mut auth := authority
|
||||
if host := header.get(.host) {
|
||||
if host != '' {
|
||||
auth = host
|
||||
}
|
||||
}
|
||||
mut extra := []H2HeaderField{}
|
||||
if !header.contains(.user_agent) && req.user_agent != '' {
|
||||
extra << H2HeaderField{'user-agent', req.user_agent}
|
||||
}
|
||||
if data.len > 0 && !header.contains(.content_length) {
|
||||
extra << H2HeaderField{'content-length', data.len.str()}
|
||||
}
|
||||
for key in header.keys() {
|
||||
lkey := key.to_lower()
|
||||
if lkey in h2_hop_by_hop {
|
||||
continue
|
||||
}
|
||||
for val in header.custom_values(key) {
|
||||
extra << H2HeaderField{lkey, val}
|
||||
}
|
||||
}
|
||||
// Cookies: the request's own cookie map plus any Cookie header values,
|
||||
// joined into one field (RFC 7540 Section 8.1.2.5 also allows splitting).
|
||||
mut cookie_parts := []string{}
|
||||
for k, v in req.cookies {
|
||||
cookie_parts << '${k}=${v}'
|
||||
}
|
||||
for cv in header.values(.cookie) {
|
||||
cookie_parts << cv
|
||||
}
|
||||
if cookie_parts.len > 0 {
|
||||
extra << H2HeaderField{'cookie', cookie_parts.join('; ')}
|
||||
}
|
||||
return H2ClientRequest{
|
||||
method: method.str()
|
||||
scheme: 'https'
|
||||
authority: auth
|
||||
path: path
|
||||
headers: extra
|
||||
body: data.bytes()
|
||||
}
|
||||
}
|
||||
|
||||
// h2_response_to_http converts an HTTP/2 response into a net.http Response,
|
||||
// decoding any Content-Encoding the same way the HTTP/1.1 path does.
|
||||
fn h2_response_to_http(h2resp H2ClientResponse) Response {
|
||||
mut h := new_header()
|
||||
for f in h2resp.headers {
|
||||
h.add_custom(f.name, f.value) or {}
|
||||
}
|
||||
body := decode_response_body(h2resp.body.bytestr(), h.get(.content_encoding) or { '' })
|
||||
status := status_from_int(h2resp.status)
|
||||
return Response{
|
||||
http_version: '2.0'
|
||||
status_code: h2resp.status
|
||||
status_msg: status.str()
|
||||
header: h
|
||||
body: body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
module http
|
||||
|
||||
// Tests for the HTTP/2 <-> net.http conversion glue (h2_client.v). The
|
||||
// request/response conversions are pure and need no socket; the end-to-end
|
||||
// fetch test is opt-in and network-dependent.
|
||||
|
||||
fn test_to_h2_request_pseudo_headers_and_body() {
|
||||
req := Request{
|
||||
user_agent: 'v.http'
|
||||
}
|
||||
h2req := req.to_h2_request(.post, 'example.com', '/p?q=1', 'hello', new_header())
|
||||
assert h2req.method == 'POST'
|
||||
assert h2req.scheme == 'https'
|
||||
assert h2req.authority == 'example.com'
|
||||
assert h2req.path == '/p?q=1'
|
||||
assert h2req.body.bytestr() == 'hello'
|
||||
// user-agent (from the request) and a synthesized content-length.
|
||||
assert h2req.headers.any(it.name == 'user-agent' && it.value == 'v.http')
|
||||
assert h2req.headers.any(it.name == 'content-length' && it.value == '5')
|
||||
}
|
||||
|
||||
fn test_to_h2_request_lowercases_and_keeps_custom_headers() {
|
||||
mut h := new_header()
|
||||
h.add_custom('Accept', 'application/json') or {}
|
||||
h.add(.content_type, 'text/plain')
|
||||
req := Request{}
|
||||
h2req := req.to_h2_request(.get, 'h.example', '/', '', h)
|
||||
assert h2req.headers.any(it.name == 'accept' && it.value == 'application/json')
|
||||
assert h2req.headers.any(it.name == 'content-type' && it.value == 'text/plain')
|
||||
}
|
||||
|
||||
fn test_to_h2_request_strips_hop_by_hop_and_host() {
|
||||
mut h := new_header()
|
||||
h.add(.connection, 'keep-alive')
|
||||
h.add(.host, 'example.com')
|
||||
h.add_custom('Transfer-Encoding', 'chunked') or {}
|
||||
req := Request{}
|
||||
h2req := req.to_h2_request(.get, 'example.com', '/', '', h)
|
||||
assert !h2req.headers.any(it.name == 'connection')
|
||||
assert !h2req.headers.any(it.name == 'host')
|
||||
assert !h2req.headers.any(it.name == 'transfer-encoding')
|
||||
}
|
||||
|
||||
fn test_to_h2_request_collapses_cookies() {
|
||||
mut h := new_header()
|
||||
h.add(.cookie, 'a=1')
|
||||
req := Request{
|
||||
cookies: {
|
||||
'sid': 'abc'
|
||||
}
|
||||
}
|
||||
h2req := req.to_h2_request(.get, 'h.example', '/', '', h)
|
||||
cookie := h2req.headers.filter(it.name == 'cookie')
|
||||
assert cookie.len == 1
|
||||
// Both the request cookie map and the Cookie header value are present.
|
||||
assert cookie[0].value.contains('sid=abc')
|
||||
assert cookie[0].value.contains('a=1')
|
||||
}
|
||||
|
||||
fn test_h2_response_to_http() {
|
||||
h2resp := H2ClientResponse{
|
||||
status: 200
|
||||
headers: [H2HeaderField{'content-type', 'text/plain'},
|
||||
H2HeaderField{'x-foo', 'bar'}]
|
||||
body: 'hi'.bytes()
|
||||
}
|
||||
resp := h2_response_to_http(h2resp)
|
||||
assert resp.status_code == 200
|
||||
assert resp.http_version == '2.0'
|
||||
assert resp.version() == .v2_0
|
||||
assert resp.body == 'hi'
|
||||
assert (resp.header.get_custom('content-type') or { '' }) == 'text/plain'
|
||||
assert (resp.header.get_custom('x-foo') or { '' }) == 'bar'
|
||||
}
|
||||
|
||||
fn test_h2_authority_omits_default_port() {
|
||||
assert h2_authority('example.com', 443) == 'example.com'
|
||||
assert h2_authority('example.com', 0) == 'example.com'
|
||||
assert h2_authority('example.com', 8443) == 'example.com:8443'
|
||||
}
|
||||
|
||||
// End-to-end test against a real HTTP/2 server. Run with `-d network`,
|
||||
// e.g. `v -d network test vlib/net/http/h2_client_test.v`.
|
||||
fn test_http2_fetch_real_server() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
// HTTP/2 is negotiated by default for https requests. On Windows this runs
|
||||
// over the SChannel backend's ALPN + HTTP/2 path (vlang/v#27383).
|
||||
resp := get('https://www.google.com/')!
|
||||
assert resp.version() == .v2_0
|
||||
assert resp.status_code == 200
|
||||
assert resp.body.len > 0
|
||||
// Opting out forces HTTP/1.1 against the same server.
|
||||
plain := fetch(url: 'https://www.google.com/', enable_http2: false)!
|
||||
assert plain.version() == .v1_1
|
||||
}
|
||||
|
||||
fn test_to_h2_request_authority_from_host_header() {
|
||||
mut h := new_header()
|
||||
h.add(.host, 'override.example:8443')
|
||||
req := Request{}
|
||||
// The URL host is origin.example, but an explicit Host header must win.
|
||||
h2req := req.to_h2_request(.get, 'origin.example', '/', '', h)
|
||||
assert h2req.authority == 'override.example:8443'
|
||||
assert !h2req.headers.any(it.name == 'host')
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// This file implements a minimal HTTP/2 client connection (RFC 7540) on top of
|
||||
// the framing and HPACK layers. It is intentionally synchronous and handles a
|
||||
// single request at a time: it sends one request, then reads frames until that
|
||||
// stream completes, servicing connection-level frames (SETTINGS, PING,
|
||||
// WINDOW_UPDATE, GOAWAY) inline. Stream multiplexing and a background reader
|
||||
// are a follow-up; this is the smallest useful, testable client.
|
||||
|
||||
// h2_client_preface is the fixed sequence a client sends to start an HTTP/2
|
||||
// connection, immediately followed by a SETTINGS frame (RFC 7540 Section 3.5).
|
||||
pub const h2_client_preface = 'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'
|
||||
|
||||
// h2_default_initial_window is the initial flow-control window for both the
|
||||
// connection and new streams (RFC 7540 Section 6.9.2).
|
||||
pub const h2_default_initial_window = u32(65535)
|
||||
|
||||
// h2_conn_read_chunk is the size of each transport read.
|
||||
const h2_conn_read_chunk = 16 * 1024
|
||||
|
||||
// H2Transport is the byte transport an H2Conn runs over: typically an
|
||||
// ALPN-negotiated `h2` TLS connection, but any reader/writer works (which makes
|
||||
// the connection testable without a socket). Its method signatures match
|
||||
// net.ssl.SSLConn, so an SSLConn satisfies it directly.
|
||||
pub interface H2Transport {
|
||||
mut:
|
||||
read(mut buf []u8) !int
|
||||
write(buf []u8) !int
|
||||
}
|
||||
|
||||
// H2PeerSettings holds the peer's SETTINGS, with HTTP/2 defaults.
|
||||
struct H2PeerSettings {
|
||||
mut:
|
||||
header_table_size u32 = 4096
|
||||
enable_push bool = true
|
||||
max_concurrent_streams u32 = max_u32
|
||||
initial_window_size u32 = 65535
|
||||
max_frame_size u32 = 16384
|
||||
max_header_list_size u32 = max_u32
|
||||
}
|
||||
|
||||
// H2DataFn is called for each DATA frame received on the response stream, with
|
||||
// the chunk's bytes, the cumulative body bytes received (including this chunk),
|
||||
// the body length from Content-Length if known (else 0), and the response
|
||||
// status code.
|
||||
pub type H2DataFn = fn (chunk []u8, body_so_far u64, body_expected u64, status int) !
|
||||
|
||||
// H2ClientRequest describes a single HTTP/2 request. Header names in `headers`
|
||||
// must be lowercase (RFC 7540 Section 8.1.2); the pseudo-headers are filled in
|
||||
// from the other fields.
|
||||
pub struct H2ClientRequest {
|
||||
pub:
|
||||
method string = 'GET'
|
||||
scheme string = 'https'
|
||||
authority string
|
||||
path string = '/'
|
||||
headers []H2HeaderField
|
||||
body []u8
|
||||
// Optional response chunk callback, called after each DATA frame's payload
|
||||
// is received. The arguments are the chunk bytes (not yet copied into the
|
||||
// response body), the cumulative body bytes received so far (including this
|
||||
// chunk), the body length from Content-Length (0 when not present), and the
|
||||
// response status code.
|
||||
on_data H2DataFn = unsafe { nil }
|
||||
// stop_copying_limit, when >= 0, caps the cumulative body bytes copied into
|
||||
// the response body; further DATA chunks are dropped but the callback keeps
|
||||
// firing and the stream is drained to completion.
|
||||
stop_copying_limit i64 = -1
|
||||
// stop_receiving_limit, when >= 0, causes the response read loop to break
|
||||
// once that many body bytes have been received. The callback fires for the
|
||||
// final chunk; no further callbacks fire after that.
|
||||
stop_receiving_limit i64 = -1
|
||||
}
|
||||
|
||||
// H2ClientResponse is the result of an HTTP/2 request.
|
||||
pub struct H2ClientResponse {
|
||||
pub mut:
|
||||
status int
|
||||
headers []H2HeaderField
|
||||
body []u8
|
||||
}
|
||||
|
||||
// H2Conn is a client-side HTTP/2 connection.
|
||||
pub struct H2Conn {
|
||||
mut:
|
||||
transport H2Transport
|
||||
encoder H2HpackEncoder
|
||||
decoder H2HpackDecoder
|
||||
peer H2PeerSettings
|
||||
rbuf []u8 // buffered bytes read from the transport, not yet consumed
|
||||
pending []H2Frame // stream frames read early (while sending), to replay
|
||||
next_stream_id u32 = 1 // clients use odd stream ids
|
||||
cur_stream_id u32 // the stream currently being driven by do()
|
||||
send_window i64 = 65535 // connection-level send flow-control window
|
||||
stream_send_window i64 // send window for cur_stream_id
|
||||
handshaked bool
|
||||
goaway bool
|
||||
// aborted is set when this connection terminated a stream early
|
||||
// (RST_STREAM sent without draining the remaining DATA). Subsequent
|
||||
// requests on the same connection must fail rather than risk being starved
|
||||
// by leftover DATA frames the peer had already sent for the cancelled
|
||||
// stream.
|
||||
aborted bool
|
||||
}
|
||||
|
||||
// new_h2_conn creates a client connection over `transport`. The HTTP/2
|
||||
// connection preface is sent lazily on the first request.
|
||||
pub fn new_h2_conn(transport H2Transport) &H2Conn {
|
||||
return &H2Conn{
|
||||
transport: transport
|
||||
}
|
||||
}
|
||||
|
||||
// do sends `req` and returns the response, after the request's stream closes.
|
||||
pub fn (mut c H2Conn) do(req H2ClientRequest) !H2ClientResponse {
|
||||
c.handshake()!
|
||||
if c.goaway {
|
||||
return error('h2: connection is shutting down (GOAWAY)')
|
||||
}
|
||||
if c.aborted {
|
||||
return error('h2: connection is no longer usable after an early stream termination')
|
||||
}
|
||||
stream_id := c.next_stream_id
|
||||
c.next_stream_id += 2
|
||||
c.cur_stream_id = stream_id
|
||||
c.stream_send_window = i64(c.peer.initial_window_size)
|
||||
|
||||
mut fields := [
|
||||
H2HeaderField{':method', req.method},
|
||||
H2HeaderField{':scheme', req.scheme},
|
||||
H2HeaderField{':authority', req.authority},
|
||||
H2HeaderField{':path', req.path},
|
||||
]
|
||||
for h in req.headers {
|
||||
fields << h
|
||||
}
|
||||
block := c.encoder.encode(fields)
|
||||
has_body := req.body.len > 0
|
||||
c.send_header_block(stream_id, block, !has_body)!
|
||||
if has_body {
|
||||
c.send_body(stream_id, req.body)!
|
||||
}
|
||||
return c.read_response(stream_id, req)!
|
||||
}
|
||||
|
||||
fn (mut c H2Conn) handshake() ! {
|
||||
if c.handshaked {
|
||||
return
|
||||
}
|
||||
c.write_all(h2_client_preface.bytes())!
|
||||
// Advertise our SETTINGS: refuse server push, and use the protocol defaults
|
||||
// otherwise. (Our decoder uses the default 4096-byte HPACK table.)
|
||||
c.send_frame(H2SettingsFrame{
|
||||
settings: [
|
||||
H2Setting{h2_settings_enable_push, 0},
|
||||
H2Setting{h2_settings_initial_window_size, h2_default_initial_window},
|
||||
H2Setting{h2_settings_max_frame_size, h2_default_max_frame_size},
|
||||
]
|
||||
})!
|
||||
c.handshaked = true
|
||||
}
|
||||
|
||||
// read_response reads frames until `stream_id` is closed, returning its
|
||||
// response and servicing connection-level frames along the way. The streaming
|
||||
// options on `req` (on_data callback and the two stop limits) are honored
|
||||
// while reading DATA frames, matching the HTTP/1.1 streaming semantics.
|
||||
fn (mut c H2Conn) read_response(stream_id u32, req H2ClientRequest) !H2ClientResponse {
|
||||
mut resp := H2ClientResponse{}
|
||||
mut got_headers := false
|
||||
mut body_so_far := u64(0)
|
||||
mut body_expected := u64(0)
|
||||
for {
|
||||
frame := c.next_frame()!
|
||||
if c.handle_conn_frame(frame)! {
|
||||
continue
|
||||
}
|
||||
match frame {
|
||||
H2HeadersFrame {
|
||||
if frame.stream_id != stream_id {
|
||||
continue
|
||||
}
|
||||
fragment := c.collect_header_block(frame.fragment, frame.end_headers, stream_id)!
|
||||
for f in c.decoder.decode(fragment)! {
|
||||
if f.name == ':status' {
|
||||
resp.status = f.value.int()
|
||||
} else if !f.name.starts_with(':') {
|
||||
resp.headers << f
|
||||
if f.name == 'content-length' {
|
||||
body_expected = f.value.u64()
|
||||
}
|
||||
}
|
||||
}
|
||||
got_headers = true
|
||||
if frame.end_stream {
|
||||
break
|
||||
}
|
||||
}
|
||||
H2DataFrame {
|
||||
if frame.stream_id != stream_id {
|
||||
continue
|
||||
}
|
||||
if frame.data.len > 0 {
|
||||
body_so_far += u64(frame.data.len)
|
||||
// Append the chunk to the response body unless the copy
|
||||
// limit has been reached; the callback still fires.
|
||||
if req.stop_copying_limit < 0
|
||||
|| i64(body_so_far) - i64(frame.data.len) < req.stop_copying_limit {
|
||||
if req.stop_copying_limit >= 0 && i64(body_so_far) > req.stop_copying_limit {
|
||||
remaining := req.stop_copying_limit - (i64(body_so_far) - i64(frame.data.len))
|
||||
if remaining > 0 {
|
||||
resp.body << frame.data[..int(remaining)]
|
||||
}
|
||||
} else {
|
||||
resp.body << frame.data
|
||||
}
|
||||
}
|
||||
if req.on_data != unsafe { nil } {
|
||||
req.on_data(frame.data, body_so_far, body_expected, resp.status)!
|
||||
}
|
||||
// Replenish flow control so the peer keeps sending.
|
||||
c.send_window_update(0, u32(frame.data.len))!
|
||||
c.send_window_update(stream_id, u32(frame.data.len))!
|
||||
}
|
||||
if frame.end_stream {
|
||||
break
|
||||
}
|
||||
if req.stop_receiving_limit >= 0 && i64(body_so_far) >= req.stop_receiving_limit {
|
||||
// Cancel the stream (RFC 7540 Section 8.1.4 / 5.4.2) so the
|
||||
// peer stops sending more DATA, and mark the connection
|
||||
// unusable: in-flight DATA frames that the peer has already
|
||||
// sent for this stream would otherwise consume the
|
||||
// connection-level receive window and block subsequent
|
||||
// requests on the same H2Conn.
|
||||
c.send_frame(H2RstStreamFrame{
|
||||
stream_id: stream_id
|
||||
error_code: u32(H2ErrorCode.cancel)
|
||||
})!
|
||||
c.aborted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
H2RstStreamFrame {
|
||||
if frame.stream_id == stream_id {
|
||||
return error('h2: stream reset by peer (${h2_error_code_name(frame.error_code)})')
|
||||
}
|
||||
}
|
||||
else {
|
||||
// PRIORITY / PUSH_PROMISE / stray CONTINUATION / unknown: ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
if !got_headers {
|
||||
return error('h2: stream closed without a response')
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// collect_header_block returns the full HPACK block for a HEADERS frame,
|
||||
// reading and concatenating CONTINUATION frames until END_HEADERS.
|
||||
fn (mut c H2Conn) collect_header_block(first []u8, end_headers bool, stream_id u32) ![]u8 {
|
||||
if end_headers {
|
||||
return first
|
||||
}
|
||||
mut fragment := first.clone()
|
||||
for {
|
||||
frame := c.read_frame()!
|
||||
if frame is H2ContinuationFrame {
|
||||
if frame.stream_id != stream_id {
|
||||
return error('h2: CONTINUATION on the wrong stream')
|
||||
}
|
||||
fragment << frame.fragment
|
||||
if frame.end_headers {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
return error('h2: expected a CONTINUATION frame')
|
||||
}
|
||||
}
|
||||
return fragment
|
||||
}
|
||||
|
||||
// handle_conn_frame services a connection-level frame, returning true if the
|
||||
// frame was fully handled here (so the caller should skip it).
|
||||
fn (mut c H2Conn) handle_conn_frame(frame H2Frame) !bool {
|
||||
match frame {
|
||||
H2SettingsFrame {
|
||||
if !frame.ack {
|
||||
c.apply_settings(frame.settings)
|
||||
c.send_frame(H2SettingsFrame{
|
||||
ack: true
|
||||
})!
|
||||
}
|
||||
return true
|
||||
}
|
||||
H2PingFrame {
|
||||
if !frame.ack {
|
||||
c.send_frame(H2PingFrame{
|
||||
ack: true
|
||||
data: frame.data
|
||||
})!
|
||||
}
|
||||
return true
|
||||
}
|
||||
H2WindowUpdateFrame {
|
||||
if frame.stream_id == 0 {
|
||||
c.send_window += i64(frame.window_size_increment)
|
||||
} else if frame.stream_id == c.cur_stream_id {
|
||||
c.stream_send_window += i64(frame.window_size_increment)
|
||||
}
|
||||
return true
|
||||
}
|
||||
H2GoawayFrame {
|
||||
c.goaway = true
|
||||
return error('h2: GOAWAY received (${h2_error_code_name(frame.error_code)})')
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2Conn) apply_settings(settings []H2Setting) {
|
||||
for s in settings {
|
||||
match s.id {
|
||||
h2_settings_header_table_size {
|
||||
c.peer.header_table_size = s.value
|
||||
}
|
||||
h2_settings_enable_push {
|
||||
c.peer.enable_push = s.value != 0
|
||||
}
|
||||
h2_settings_max_concurrent_streams {
|
||||
c.peer.max_concurrent_streams = s.value
|
||||
}
|
||||
h2_settings_initial_window_size {
|
||||
// RFC 7540 Section 6.9.2: a change to the initial window size
|
||||
// retroactively adjusts the active stream's send window by the
|
||||
// delta.
|
||||
delta := i64(s.value) - i64(c.peer.initial_window_size)
|
||||
c.peer.initial_window_size = s.value
|
||||
c.stream_send_window += delta
|
||||
}
|
||||
h2_settings_max_frame_size {
|
||||
c.peer.max_frame_size = s.value
|
||||
}
|
||||
h2_settings_max_header_list_size {
|
||||
c.peer.max_header_list_size = s.value
|
||||
}
|
||||
else {} // unknown settings are ignored (RFC 7540 Section 6.5.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send_header_block sends an HPACK header block as a HEADERS frame, splitting
|
||||
// it across CONTINUATION frames when it exceeds the peer's max frame size
|
||||
// (RFC 7540 Section 4.3). END_STREAM, when set, goes on the HEADERS frame.
|
||||
fn (mut c H2Conn) send_header_block(stream_id u32, block []u8, end_stream bool) ! {
|
||||
max := int(c.peer.max_frame_size)
|
||||
if block.len <= max {
|
||||
c.send_frame(H2HeadersFrame{
|
||||
stream_id: stream_id
|
||||
fragment: block
|
||||
end_headers: true
|
||||
end_stream: end_stream
|
||||
})!
|
||||
return
|
||||
}
|
||||
c.send_frame(H2HeadersFrame{
|
||||
stream_id: stream_id
|
||||
fragment: block[..max]
|
||||
end_headers: false
|
||||
end_stream: end_stream
|
||||
})!
|
||||
mut off := max
|
||||
for off < block.len {
|
||||
mut next := off + max
|
||||
if next > block.len {
|
||||
next = block.len
|
||||
}
|
||||
c.send_frame(H2ContinuationFrame{
|
||||
stream_id: stream_id
|
||||
fragment: block[off..next]
|
||||
end_headers: next == block.len
|
||||
})!
|
||||
off = next
|
||||
}
|
||||
}
|
||||
|
||||
// send_body writes the request body as DATA frames, chunked to the peer's max
|
||||
// frame size and bounded by both the connection and the per-stream send
|
||||
// flow-control windows (RFC 7540 Section 6.9).
|
||||
fn (mut c H2Conn) send_body(stream_id u32, body []u8) ! {
|
||||
max := int(c.peer.max_frame_size)
|
||||
mut off := 0
|
||||
for off < body.len {
|
||||
for c.send_window <= 0 || c.stream_send_window <= 0 {
|
||||
// Wait for the peer to grow a window. Connection-level frames are
|
||||
// handled; any stream frames are stashed for read_response.
|
||||
frame := c.next_frame()!
|
||||
if !c.handle_conn_frame(frame)! {
|
||||
c.pending << frame
|
||||
}
|
||||
}
|
||||
avail := if c.send_window < c.stream_send_window {
|
||||
c.send_window
|
||||
} else {
|
||||
c.stream_send_window
|
||||
}
|
||||
mut chunk := body.len - off
|
||||
if chunk > max {
|
||||
chunk = max
|
||||
}
|
||||
if i64(chunk) > avail {
|
||||
chunk = int(avail)
|
||||
}
|
||||
next := off + chunk
|
||||
c.send_frame(H2DataFrame{
|
||||
stream_id: stream_id
|
||||
data: body[off..next]
|
||||
end_stream: next == body.len
|
||||
})!
|
||||
c.send_window -= i64(chunk)
|
||||
c.stream_send_window -= i64(chunk)
|
||||
off = next
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2Conn) send_window_update(stream_id u32, inc u32) ! {
|
||||
if inc == 0 {
|
||||
return
|
||||
}
|
||||
c.send_frame(H2WindowUpdateFrame{
|
||||
stream_id: stream_id
|
||||
window_size_increment: inc
|
||||
})!
|
||||
}
|
||||
|
||||
// next_frame returns the next frame, replaying any stashed stream frames first.
|
||||
fn (mut c H2Conn) next_frame() !H2Frame {
|
||||
if c.pending.len > 0 {
|
||||
f := c.pending[0]
|
||||
c.pending.delete(0)
|
||||
return f
|
||||
}
|
||||
return c.read_frame()!
|
||||
}
|
||||
|
||||
// read_frame reads and decodes one frame from the transport, enforcing our
|
||||
// advertised max frame size.
|
||||
fn (mut c H2Conn) read_frame() !H2Frame {
|
||||
c.fill_at_least(h2_frame_header_len)!
|
||||
header := h2_parse_frame_header(c.rbuf)!
|
||||
if header.length > h2_default_max_frame_size {
|
||||
return error('h2: frame larger than SETTINGS_MAX_FRAME_SIZE (${header.length})')
|
||||
}
|
||||
total := h2_frame_header_len + int(header.length)
|
||||
c.fill_at_least(total)!
|
||||
frame := h2_parse_frame(header, c.rbuf[h2_frame_header_len..total])!
|
||||
c.rbuf = c.rbuf[total..].clone()
|
||||
return frame
|
||||
}
|
||||
|
||||
// fill_at_least reads from the transport until rbuf holds at least n bytes.
|
||||
fn (mut c H2Conn) fill_at_least(n int) ! {
|
||||
for c.rbuf.len < n {
|
||||
mut tmp := []u8{len: h2_conn_read_chunk}
|
||||
got := c.transport.read(mut tmp)!
|
||||
if got <= 0 {
|
||||
return error('h2: connection closed by peer')
|
||||
}
|
||||
c.rbuf << tmp[..got]
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2Conn) send_frame(f H2Frame) ! {
|
||||
c.write_all(f.encode())!
|
||||
}
|
||||
|
||||
fn (mut c H2Conn) write_all(data []u8) ! {
|
||||
mut sent := 0
|
||||
for sent < data.len {
|
||||
n := c.transport.write(data[sent..])!
|
||||
if n <= 0 {
|
||||
return error('h2: transport write returned ${n}')
|
||||
}
|
||||
sent += n
|
||||
}
|
||||
}
|
||||
|
||||
// h2_error_code_name renders an HTTP/2 error code, falling back to hex for
|
||||
// codes outside the defined range.
|
||||
fn h2_error_code_name(code u32) string {
|
||||
if code <= u32(H2ErrorCode.http_1_1_required) {
|
||||
return unsafe { H2ErrorCode(code) }.str()
|
||||
}
|
||||
return 'unknown(0x${code.hex()})'
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
module http
|
||||
|
||||
// Tests for the synchronous HTTP/2 client connection, driven over an in-memory
|
||||
// transport so no socket is required.
|
||||
|
||||
// MockTransport plays a fixed script of server->client bytes and records what
|
||||
// the client writes.
|
||||
struct MockTransport {
|
||||
mut:
|
||||
inbound []u8 // server -> client; the client reads these
|
||||
rpos int
|
||||
outbound []u8 // client -> server; what the client wrote
|
||||
}
|
||||
|
||||
fn (mut m MockTransport) read(mut buf []u8) !int {
|
||||
if m.rpos >= m.inbound.len {
|
||||
return error('eof')
|
||||
}
|
||||
mut n := m.inbound.len - m.rpos
|
||||
if n > buf.len {
|
||||
n = buf.len
|
||||
}
|
||||
for i in 0 .. n {
|
||||
buf[i] = m.inbound[m.rpos + i]
|
||||
}
|
||||
m.rpos += n
|
||||
return n
|
||||
}
|
||||
|
||||
fn (mut m MockTransport) write(buf []u8) !int {
|
||||
m.outbound << buf
|
||||
return buf.len
|
||||
}
|
||||
|
||||
// build_server_stream encodes a server-side response on stream 1: SETTINGS, an
|
||||
// ACK of our SETTINGS, a HEADERS frame, and optional DATA frames.
|
||||
fn build_server_stream(resp_fields []H2HeaderField, body_chunks [][]u8) []u8 {
|
||||
mut senc := H2HpackEncoder{}
|
||||
hdr := senc.encode(resp_fields)
|
||||
mut out := []u8{}
|
||||
out << H2Frame(H2SettingsFrame{}).encode()
|
||||
out << H2Frame(H2SettingsFrame{
|
||||
ack: true
|
||||
}).encode()
|
||||
last_is_headers := body_chunks.len == 0
|
||||
out << H2Frame(H2HeadersFrame{
|
||||
stream_id: 1
|
||||
fragment: hdr
|
||||
end_headers: true
|
||||
end_stream: last_is_headers
|
||||
}).encode()
|
||||
for i, chunk in body_chunks {
|
||||
out << H2Frame(H2DataFrame{
|
||||
stream_id: 1
|
||||
data: chunk
|
||||
end_stream: i == body_chunks.len - 1
|
||||
}).encode()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fn test_h2_conn_basic_get() {
|
||||
inbound := build_server_stream([
|
||||
H2HeaderField{':status', '200'},
|
||||
H2HeaderField{'content-type', 'text/plain'},
|
||||
], [' hello'.bytes()])
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
resp := c.do(H2ClientRequest{
|
||||
method: 'GET'
|
||||
authority: 'example.com'
|
||||
path: '/'
|
||||
})!
|
||||
assert resp.status == 200
|
||||
assert resp.body.bytestr() == ' hello'
|
||||
assert resp.headers.any(it.name == 'content-type' && it.value == 'text/plain')
|
||||
|
||||
// The client must open with the connection preface.
|
||||
assert t.outbound.len > h2_client_preface.len
|
||||
assert t.outbound[..h2_client_preface.len].bytestr() == h2_client_preface
|
||||
|
||||
// First client frame after the preface is its SETTINGS.
|
||||
f1, n1 := h2_read_frame(t.outbound[h2_client_preface.len..])!
|
||||
assert f1 is H2SettingsFrame
|
||||
|
||||
// Next is the request HEADERS on stream 1; decode and check pseudo-headers.
|
||||
f2, _ := h2_read_frame(t.outbound[h2_client_preface.len + n1..])!
|
||||
headers := f2 as H2HeadersFrame
|
||||
assert headers.stream_id == 1
|
||||
assert headers.end_stream // GET with no body
|
||||
mut dec := H2HpackDecoder{}
|
||||
req_fields := dec.decode(headers.fragment)!
|
||||
assert req_fields.any(it.name == ':method' && it.value == 'GET')
|
||||
assert req_fields.any(it.name == ':scheme' && it.value == 'https')
|
||||
assert req_fields.any(it.name == ':authority' && it.value == 'example.com')
|
||||
assert req_fields.any(it.name == ':path' && it.value == '/')
|
||||
}
|
||||
|
||||
fn test_h2_conn_multi_data_frames() {
|
||||
inbound := build_server_stream([H2HeaderField{':status', '200'}], [
|
||||
'foo'.bytes(),
|
||||
'bar'.bytes(),
|
||||
'baz'.bytes(),
|
||||
])
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
resp := c.do(H2ClientRequest{ authority: 'h.example' })!
|
||||
assert resp.status == 200
|
||||
assert resp.body.bytestr() == 'foobarbaz'
|
||||
}
|
||||
|
||||
fn test_h2_conn_post_body() {
|
||||
inbound := build_server_stream([H2HeaderField{':status', '201'}], [])
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
resp := c.do(H2ClientRequest{
|
||||
method: 'POST'
|
||||
authority: 'h.example'
|
||||
path: '/submit'
|
||||
body: 'payload-data'.bytes()
|
||||
})!
|
||||
assert resp.status == 201
|
||||
|
||||
// Walk the client's frames and confirm a DATA frame carried the body with
|
||||
// END_STREAM, after a HEADERS frame that did not end the stream.
|
||||
mut pos := h2_client_preface.len
|
||||
mut saw_headers_open := false
|
||||
mut data_payload := []u8{}
|
||||
mut data_end := false
|
||||
for pos < t.outbound.len {
|
||||
frame, n := h2_read_frame(t.outbound[pos..])!
|
||||
pos += n
|
||||
match frame {
|
||||
H2HeadersFrame {
|
||||
if frame.stream_id == 1 {
|
||||
assert !frame.end_stream
|
||||
saw_headers_open = true
|
||||
}
|
||||
}
|
||||
H2DataFrame {
|
||||
if frame.stream_id == 1 {
|
||||
data_payload << frame.data
|
||||
if frame.end_stream {
|
||||
data_end = true
|
||||
}
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
assert saw_headers_open
|
||||
assert data_end
|
||||
assert data_payload.bytestr() == 'payload-data'
|
||||
}
|
||||
|
||||
fn test_h2_conn_goaway_errors() {
|
||||
mut inbound := []u8{}
|
||||
inbound << H2Frame(H2SettingsFrame{}).encode()
|
||||
inbound << H2Frame(H2GoawayFrame{
|
||||
last_stream_id: 0
|
||||
error_code: u32(H2ErrorCode.protocol_error)
|
||||
}).encode()
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
c.do(H2ClientRequest{ authority: 'h.example' }) or {
|
||||
assert err.msg().contains('GOAWAY')
|
||||
assert err.msg().contains('PROTOCOL_ERROR')
|
||||
return
|
||||
}
|
||||
assert false, 'expected GOAWAY error'
|
||||
}
|
||||
|
||||
fn test_h2_conn_rst_stream_errors() {
|
||||
mut senc := H2HpackEncoder{}
|
||||
mut inbound := []u8{}
|
||||
inbound << H2Frame(H2SettingsFrame{}).encode()
|
||||
inbound << H2Frame(H2HeadersFrame{
|
||||
stream_id: 1
|
||||
fragment: senc.encode([H2HeaderField{':status', '200'}])
|
||||
end_headers: true
|
||||
}).encode()
|
||||
inbound << H2Frame(H2RstStreamFrame{
|
||||
stream_id: 1
|
||||
error_code: u32(H2ErrorCode.internal_error)
|
||||
}).encode()
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
c.do(H2ClientRequest{ authority: 'h.example' }) or {
|
||||
assert err.msg().contains('reset')
|
||||
return
|
||||
}
|
||||
assert false, 'expected RST_STREAM error'
|
||||
}
|
||||
|
||||
fn test_h2_conn_continuation() {
|
||||
// Split the response header block across HEADERS + CONTINUATION.
|
||||
mut senc := H2HpackEncoder{}
|
||||
full := senc.encode([
|
||||
H2HeaderField{':status', '200'},
|
||||
H2HeaderField{'x-test', 'continued'},
|
||||
])
|
||||
split := full.len / 2
|
||||
mut inbound := []u8{}
|
||||
inbound << H2Frame(H2SettingsFrame{}).encode()
|
||||
inbound << H2Frame(H2HeadersFrame{
|
||||
stream_id: 1
|
||||
fragment: full[..split]
|
||||
end_headers: false
|
||||
}).encode()
|
||||
inbound << H2Frame(H2ContinuationFrame{
|
||||
stream_id: 1
|
||||
fragment: full[split..]
|
||||
end_headers: true
|
||||
}).encode()
|
||||
inbound << H2Frame(H2DataFrame{
|
||||
stream_id: 1
|
||||
data: 'ok'.bytes()
|
||||
end_stream: true
|
||||
}).encode()
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
resp := c.do(H2ClientRequest{ authority: 'h.example' })!
|
||||
assert resp.status == 200
|
||||
assert resp.body.bytestr() == 'ok'
|
||||
assert resp.headers.any(it.name == 'x-test' && it.value == 'continued')
|
||||
}
|
||||
|
||||
fn test_h2_conn_ping_ack() {
|
||||
// A server PING before the response must be answered with a PING ACK.
|
||||
mut senc := H2HpackEncoder{}
|
||||
mut inbound := []u8{}
|
||||
inbound << H2Frame(H2SettingsFrame{}).encode()
|
||||
inbound << H2Frame(H2PingFrame{
|
||||
data: [u8(1), 2, 3, 4, 5, 6, 7, 8]
|
||||
}).encode()
|
||||
inbound << H2Frame(H2HeadersFrame{
|
||||
stream_id: 1
|
||||
fragment: senc.encode([H2HeaderField{':status', '204'}])
|
||||
end_headers: true
|
||||
end_stream: true
|
||||
}).encode()
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
resp := c.do(H2ClientRequest{ authority: 'h.example' })!
|
||||
assert resp.status == 204
|
||||
|
||||
// Find a PING ACK in the client's output carrying the same opaque data.
|
||||
mut pos := h2_client_preface.len
|
||||
mut saw_ping_ack := false
|
||||
for pos < t.outbound.len {
|
||||
frame, n := h2_read_frame(t.outbound[pos..])!
|
||||
pos += n
|
||||
if frame is H2PingFrame {
|
||||
if frame.ack && frame.data == [u8(1), 2, 3, 4, 5, 6, 7, 8] {
|
||||
saw_ping_ack = true
|
||||
}
|
||||
}
|
||||
}
|
||||
assert saw_ping_ack
|
||||
}
|
||||
|
||||
fn test_h2_conn_splits_large_request_headers() {
|
||||
inbound := build_server_stream([H2HeaderField{':status', '200'}], [])
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
// A header value larger than the default 16 KiB max frame size forces the
|
||||
// request header block to be split across HEADERS + CONTINUATION frames.
|
||||
big := 'x'.repeat(20000)
|
||||
resp := c.do(H2ClientRequest{
|
||||
authority: 'h.example'
|
||||
headers: [H2HeaderField{'x-big', big}]
|
||||
})!
|
||||
assert resp.status == 200
|
||||
|
||||
mut pos := h2_client_preface.len
|
||||
mut headers_open := false
|
||||
mut continuation_end := false
|
||||
mut block := []u8{}
|
||||
for pos < t.outbound.len {
|
||||
frame, n := h2_read_frame(t.outbound[pos..])!
|
||||
pos += n
|
||||
match frame {
|
||||
H2HeadersFrame {
|
||||
if frame.stream_id == 1 {
|
||||
assert !frame.end_headers // split, so END_HEADERS not on HEADERS
|
||||
headers_open = true
|
||||
block << frame.fragment
|
||||
}
|
||||
}
|
||||
H2ContinuationFrame {
|
||||
if frame.stream_id == 1 {
|
||||
block << frame.fragment
|
||||
if frame.end_headers {
|
||||
continuation_end = true
|
||||
}
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
assert headers_open
|
||||
assert continuation_end
|
||||
// The reassembled block must decode back to the original header.
|
||||
mut dec := H2HpackDecoder{}
|
||||
fields := dec.decode(block)!
|
||||
assert fields.any(it.name == 'x-big' && it.value == big)
|
||||
}
|
||||
|
||||
fn test_h2_conn_large_body_flow_control() {
|
||||
// Body larger than the initial 64 KiB window: the client sends up to the
|
||||
// window, waits, then resumes once the peer grows both the connection and
|
||||
// stream windows.
|
||||
body := 'a'.repeat(70000)
|
||||
mut inbound := []u8{}
|
||||
inbound << H2Frame(H2SettingsFrame{}).encode()
|
||||
inbound << H2Frame(H2WindowUpdateFrame{
|
||||
stream_id: 0
|
||||
window_size_increment: 70000
|
||||
}).encode()
|
||||
inbound << H2Frame(H2WindowUpdateFrame{
|
||||
stream_id: 1
|
||||
window_size_increment: 70000
|
||||
}).encode()
|
||||
mut senc := H2HpackEncoder{}
|
||||
inbound << H2Frame(H2HeadersFrame{
|
||||
stream_id: 1
|
||||
fragment: senc.encode([H2HeaderField{':status', '200'}])
|
||||
end_headers: true
|
||||
end_stream: true
|
||||
}).encode()
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
resp := c.do(H2ClientRequest{
|
||||
method: 'POST'
|
||||
authority: 'h.example'
|
||||
path: '/upload'
|
||||
body: body.bytes()
|
||||
})!
|
||||
assert resp.status == 200
|
||||
|
||||
// The client must have sent the entire body, ending with END_STREAM, and no
|
||||
// single DATA frame may exceed the max frame size.
|
||||
mut pos := h2_client_preface.len
|
||||
mut sent := 0
|
||||
mut ended := false
|
||||
for pos < t.outbound.len {
|
||||
frame, n := h2_read_frame(t.outbound[pos..])!
|
||||
pos += n
|
||||
if frame is H2DataFrame {
|
||||
if frame.stream_id == 1 {
|
||||
assert frame.data.len <= int(h2_default_max_frame_size)
|
||||
sent += frame.data.len
|
||||
if frame.end_stream {
|
||||
ended = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert sent == body.len
|
||||
assert ended
|
||||
}
|
||||
|
||||
fn test_h2_fetch_glue_roundtrip() {
|
||||
// Drive the full conversion glue (Request -> H2 -> Response) over the mock
|
||||
// transport, without a socket.
|
||||
inbound := build_server_stream([
|
||||
H2HeaderField{':status', '200'},
|
||||
H2HeaderField{'content-type', 'text/plain'},
|
||||
], [' world'.bytes()])
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
req := Request{
|
||||
user_agent: 'v.http'
|
||||
}
|
||||
h2req := req.to_h2_request(.get, 'example.com', '/', '', new_header())
|
||||
mut c := new_h2_conn(t)
|
||||
h2resp := c.do(h2req)!
|
||||
resp := h2_response_to_http(h2resp)
|
||||
assert resp.status_code == 200
|
||||
assert resp.version() == .v2_0
|
||||
assert resp.body == ' world'
|
||||
assert (resp.header.get_custom('content-type') or { '' }) == 'text/plain'
|
||||
}
|
||||
|
||||
// build_streamed_response builds a server stream that delivers the response
|
||||
// body in fixed-size chunks, useful for streaming tests.
|
||||
fn build_streamed_response(status string, content_length string, chunks [][]u8) []u8 {
|
||||
mut senc := H2HpackEncoder{}
|
||||
mut fields := [H2HeaderField{':status', status}]
|
||||
if content_length != '' {
|
||||
fields << H2HeaderField{'content-length', content_length}
|
||||
}
|
||||
hdr := senc.encode(fields)
|
||||
mut out := []u8{}
|
||||
out << H2Frame(H2SettingsFrame{}).encode()
|
||||
out << H2Frame(H2SettingsFrame{
|
||||
ack: true
|
||||
}).encode()
|
||||
last_is_headers := chunks.len == 0
|
||||
out << H2Frame(H2HeadersFrame{
|
||||
stream_id: 1
|
||||
fragment: hdr
|
||||
end_headers: true
|
||||
end_stream: last_is_headers
|
||||
}).encode()
|
||||
for i, chunk in chunks {
|
||||
out << H2Frame(H2DataFrame{
|
||||
stream_id: 1
|
||||
data: chunk
|
||||
end_stream: i == chunks.len - 1
|
||||
}).encode()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChunkCapture records on_data invocations via a reference captured by the
|
||||
// returned closure.
|
||||
struct ChunkCapture {
|
||||
mut:
|
||||
chunks [][]u8
|
||||
running []u64
|
||||
expected []u64
|
||||
status []int
|
||||
}
|
||||
|
||||
fn make_capture_fn(cap &ChunkCapture) H2DataFn {
|
||||
return fn [cap] (chunk []u8, body_so_far u64, body_expected u64, status int) ! {
|
||||
unsafe {
|
||||
cap.chunks << chunk.clone()
|
||||
cap.running << body_so_far
|
||||
cap.expected << body_expected
|
||||
cap.status << status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn test_h2_on_data_fires_per_chunk() {
|
||||
mut cap := &ChunkCapture{}
|
||||
inbound := build_streamed_response('200', '12', [
|
||||
'foo'.bytes(),
|
||||
'bar'.bytes(),
|
||||
'baz quux'.bytes(),
|
||||
])
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
resp := c.do(H2ClientRequest{
|
||||
authority: 'h.example'
|
||||
on_data: make_capture_fn(cap)
|
||||
})!
|
||||
assert resp.status == 200
|
||||
assert resp.body.bytestr() == 'foobarbaz quux'
|
||||
// Three DATA frames -> three callback invocations.
|
||||
assert cap.chunks.len == 3
|
||||
assert cap.chunks[0].bytestr() == 'foo'
|
||||
assert cap.chunks[1].bytestr() == 'bar'
|
||||
assert cap.chunks[2].bytestr() == 'baz quux'
|
||||
// body_so_far is cumulative including the current chunk.
|
||||
assert cap.running == [u64(3), u64(6), u64(14)]
|
||||
// content-length is reported.
|
||||
assert cap.expected == [u64(12), u64(12), u64(12)]
|
||||
// Status was known by the first callback (headers arrived first).
|
||||
assert cap.status == [200, 200, 200]
|
||||
}
|
||||
|
||||
fn test_h2_stop_copying_limit_caps_body_but_keeps_callback() {
|
||||
mut cap := &ChunkCapture{}
|
||||
inbound := build_streamed_response('200', '', [
|
||||
'AAAA'.bytes(),
|
||||
'BBBB'.bytes(),
|
||||
'CCCC'.bytes(),
|
||||
])
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
resp := c.do(H2ClientRequest{
|
||||
authority: 'h.example'
|
||||
on_data: make_capture_fn(cap)
|
||||
stop_copying_limit: 6
|
||||
})!
|
||||
// Body is capped at 6 bytes (4 from first chunk + 2 from second).
|
||||
assert resp.body.bytestr() == 'AAAABB'
|
||||
// All three chunks still produced callbacks.
|
||||
assert cap.chunks.len == 3
|
||||
assert cap.chunks[0].bytestr() == 'AAAA'
|
||||
assert cap.chunks[1].bytestr() == 'BBBB'
|
||||
assert cap.chunks[2].bytestr() == 'CCCC'
|
||||
// body_so_far still reports the true cumulative size.
|
||||
assert cap.running == [u64(4), u64(8), u64(12)]
|
||||
}
|
||||
|
||||
fn test_h2_stop_receiving_limit_breaks_early() {
|
||||
mut cap := &ChunkCapture{}
|
||||
inbound := build_streamed_response('200', '', [
|
||||
'XXXX'.bytes(),
|
||||
'YYYY'.bytes(),
|
||||
'ZZZZ'.bytes(), // should never be delivered
|
||||
])
|
||||
mut t := &MockTransport{
|
||||
inbound: inbound
|
||||
}
|
||||
mut c := new_h2_conn(t)
|
||||
resp := c.do(H2ClientRequest{
|
||||
authority: 'h.example'
|
||||
on_data: make_capture_fn(cap)
|
||||
stop_receiving_limit: 6
|
||||
})!
|
||||
// Loop breaks after the second DATA frame (8 bytes >= limit 6); body
|
||||
// contains both chunks delivered so far.
|
||||
assert resp.body.bytestr() == 'XXXXYYYY'
|
||||
assert cap.chunks.len == 2
|
||||
assert cap.chunks[1].bytestr() == 'YYYY'
|
||||
|
||||
// On early termination the client must send RST_STREAM(CANCEL) on the
|
||||
// stream, and the connection must refuse further requests.
|
||||
mut saw_cancel := false
|
||||
mut pos := h2_client_preface.len
|
||||
for pos < t.outbound.len {
|
||||
frame, n := h2_read_frame(t.outbound[pos..])!
|
||||
pos += n
|
||||
if frame is H2RstStreamFrame {
|
||||
if frame.stream_id == 1 && frame.error_code == u32(H2ErrorCode.cancel) {
|
||||
saw_cancel = true
|
||||
}
|
||||
}
|
||||
}
|
||||
assert saw_cancel, 'expected RST_STREAM(CANCEL) on early termination'
|
||||
c.do(H2ClientRequest{ authority: 'h.example' }) or {
|
||||
assert err.msg().contains('no longer usable')
|
||||
return
|
||||
}
|
||||
assert false, 'expected error on reuse after early termination'
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// H2ErrorCode is an HTTP/2 error code, as used in RST_STREAM and GOAWAY
|
||||
// frames (RFC 7540 Section 7).
|
||||
pub enum H2ErrorCode as u32 {
|
||||
no_error = 0x0
|
||||
protocol_error = 0x1
|
||||
internal_error = 0x2
|
||||
flow_control_error = 0x3
|
||||
settings_timeout = 0x4
|
||||
stream_closed = 0x5
|
||||
frame_size_error = 0x6
|
||||
refused_stream = 0x7
|
||||
cancel = 0x8
|
||||
compression_error = 0x9
|
||||
connect_error = 0xa
|
||||
enhance_your_calm = 0xb
|
||||
inadequate_security = 0xc
|
||||
http_1_1_required = 0xd
|
||||
}
|
||||
|
||||
// str returns the RFC name of the error code.
|
||||
pub fn (e H2ErrorCode) str() string {
|
||||
return match e {
|
||||
.no_error { 'NO_ERROR' }
|
||||
.protocol_error { 'PROTOCOL_ERROR' }
|
||||
.internal_error { 'INTERNAL_ERROR' }
|
||||
.flow_control_error { 'FLOW_CONTROL_ERROR' }
|
||||
.settings_timeout { 'SETTINGS_TIMEOUT' }
|
||||
.stream_closed { 'STREAM_CLOSED' }
|
||||
.frame_size_error { 'FRAME_SIZE_ERROR' }
|
||||
.refused_stream { 'REFUSED_STREAM' }
|
||||
.cancel { 'CANCEL' }
|
||||
.compression_error { 'COMPRESSION_ERROR' }
|
||||
.connect_error { 'CONNECT_ERROR' }
|
||||
.enhance_your_calm { 'ENHANCE_YOUR_CALM' }
|
||||
.inadequate_security { 'INADEQUATE_SECURITY' }
|
||||
.http_1_1_required { 'HTTP_1_1_REQUIRED' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// This file implements the HTTP/2 binary framing layer (RFC 7540 Sections 4
|
||||
// and 6). It is self-contained and does not touch the rest of net.http; the
|
||||
// connection layer (added separately) drives it.
|
||||
|
||||
// HTTP/2 frame types (RFC 7540 Section 6).
|
||||
pub const h2_frame_data = u8(0x0)
|
||||
pub const h2_frame_headers = u8(0x1)
|
||||
pub const h2_frame_priority = u8(0x2)
|
||||
pub const h2_frame_rst_stream = u8(0x3)
|
||||
pub const h2_frame_settings = u8(0x4)
|
||||
pub const h2_frame_push_promise = u8(0x5)
|
||||
pub const h2_frame_ping = u8(0x6)
|
||||
pub const h2_frame_goaway = u8(0x7)
|
||||
pub const h2_frame_window_update = u8(0x8)
|
||||
pub const h2_frame_continuation = u8(0x9)
|
||||
|
||||
// HTTP/2 frame flags (RFC 7540 Section 6). The same bit is reused across
|
||||
// frame types with different meanings.
|
||||
pub const h2_flag_end_stream = u8(0x1)
|
||||
pub const h2_flag_ack = u8(0x1)
|
||||
pub const h2_flag_end_headers = u8(0x4)
|
||||
pub const h2_flag_padded = u8(0x8)
|
||||
pub const h2_flag_priority = u8(0x20)
|
||||
|
||||
// h2_frame_header_len is the fixed size of a frame header in bytes.
|
||||
pub const h2_frame_header_len = 9
|
||||
|
||||
// h2_default_max_frame_size is the initial SETTINGS_MAX_FRAME_SIZE
|
||||
// (RFC 7540 Section 6.5.2), and the smallest value a peer may set it to.
|
||||
pub const h2_default_max_frame_size = u32(16384)
|
||||
|
||||
// h2_max_max_frame_size is the largest permitted SETTINGS_MAX_FRAME_SIZE
|
||||
// (RFC 7540 Section 6.5.2): 2^24-1 bytes.
|
||||
pub const h2_max_max_frame_size = u32(16777215)
|
||||
|
||||
// HTTP/2 setting identifiers (RFC 7540 Section 6.5.2).
|
||||
pub const h2_settings_header_table_size = u16(0x1)
|
||||
pub const h2_settings_enable_push = u16(0x2)
|
||||
pub const h2_settings_max_concurrent_streams = u16(0x3)
|
||||
pub const h2_settings_initial_window_size = u16(0x4)
|
||||
pub const h2_settings_max_frame_size = u16(0x5)
|
||||
pub const h2_settings_max_header_list_size = u16(0x6)
|
||||
|
||||
// H2FrameHeader is the 9-byte header that precedes every HTTP/2 frame.
|
||||
pub struct H2FrameHeader {
|
||||
pub:
|
||||
length u32 // 24-bit payload length
|
||||
typ u8
|
||||
flags u8
|
||||
stream_id u32 // 31-bit; the reserved bit is ignored on read
|
||||
}
|
||||
|
||||
pub struct H2DataFrame {
|
||||
pub:
|
||||
stream_id u32
|
||||
data []u8
|
||||
end_stream bool
|
||||
}
|
||||
|
||||
pub struct H2HeadersFrame {
|
||||
pub:
|
||||
stream_id u32
|
||||
fragment []u8 // HPACK header block fragment
|
||||
end_stream bool
|
||||
end_headers bool
|
||||
// Priority information, present only when the PRIORITY flag is set.
|
||||
has_priority bool
|
||||
exclusive bool
|
||||
stream_dep u32
|
||||
weight u8
|
||||
}
|
||||
|
||||
pub struct H2PriorityFrame {
|
||||
pub:
|
||||
stream_id u32
|
||||
exclusive bool
|
||||
stream_dep u32
|
||||
weight u8
|
||||
}
|
||||
|
||||
pub struct H2RstStreamFrame {
|
||||
pub:
|
||||
stream_id u32
|
||||
error_code u32
|
||||
}
|
||||
|
||||
pub struct H2Setting {
|
||||
pub:
|
||||
id u16
|
||||
value u32
|
||||
}
|
||||
|
||||
pub struct H2SettingsFrame {
|
||||
pub:
|
||||
ack bool
|
||||
settings []H2Setting
|
||||
}
|
||||
|
||||
pub struct H2PushPromiseFrame {
|
||||
pub:
|
||||
stream_id u32
|
||||
promised_stream_id u32
|
||||
fragment []u8
|
||||
end_headers bool
|
||||
}
|
||||
|
||||
pub struct H2PingFrame {
|
||||
pub:
|
||||
ack bool
|
||||
data []u8 // 8 opaque bytes
|
||||
}
|
||||
|
||||
pub struct H2GoawayFrame {
|
||||
pub:
|
||||
last_stream_id u32
|
||||
error_code u32
|
||||
debug_data []u8
|
||||
}
|
||||
|
||||
pub struct H2WindowUpdateFrame {
|
||||
pub:
|
||||
stream_id u32
|
||||
window_size_increment u32
|
||||
}
|
||||
|
||||
pub struct H2ContinuationFrame {
|
||||
pub:
|
||||
stream_id u32
|
||||
fragment []u8
|
||||
end_headers bool
|
||||
}
|
||||
|
||||
// H2UnknownFrame preserves a frame of an unrecognised type, which receivers
|
||||
// must ignore (RFC 7540 Section 4.1) but may want to inspect or forward.
|
||||
pub struct H2UnknownFrame {
|
||||
pub:
|
||||
header H2FrameHeader
|
||||
payload []u8
|
||||
}
|
||||
|
||||
// H2Frame is any HTTP/2 frame.
|
||||
pub type H2Frame = H2ContinuationFrame
|
||||
| H2DataFrame
|
||||
| H2GoawayFrame
|
||||
| H2HeadersFrame
|
||||
| H2PingFrame
|
||||
| H2PriorityFrame
|
||||
| H2PushPromiseFrame
|
||||
| H2RstStreamFrame
|
||||
| H2SettingsFrame
|
||||
| H2UnknownFrame
|
||||
| H2WindowUpdateFrame
|
||||
|
||||
// --- Big-endian helpers ---
|
||||
|
||||
fn h2_be_u16(b []u8, o int) u16 {
|
||||
return (u16(b[o]) << 8) | u16(b[o + 1])
|
||||
}
|
||||
|
||||
fn h2_be_u24(b []u8, o int) u32 {
|
||||
return (u32(b[o]) << 16) | (u32(b[o + 1]) << 8) | u32(b[o + 2])
|
||||
}
|
||||
|
||||
fn h2_be_u32(b []u8, o int) u32 {
|
||||
return (u32(b[o]) << 24) | (u32(b[o + 1]) << 16) | (u32(b[o + 2]) << 8) | u32(b[o + 3])
|
||||
}
|
||||
|
||||
fn h2_put_u16(mut b []u8, v u16) {
|
||||
b << u8(v >> 8)
|
||||
b << u8(v)
|
||||
}
|
||||
|
||||
fn h2_put_u24(mut b []u8, v u32) {
|
||||
b << u8(v >> 16)
|
||||
b << u8(v >> 8)
|
||||
b << u8(v)
|
||||
}
|
||||
|
||||
fn h2_put_u32(mut b []u8, v u32) {
|
||||
b << u8(v >> 24)
|
||||
b << u8(v >> 16)
|
||||
b << u8(v >> 8)
|
||||
b << u8(v)
|
||||
}
|
||||
|
||||
// --- Frame header ---
|
||||
|
||||
// h2_parse_frame_header parses the 9-byte frame header at the start of `buf`.
|
||||
pub fn h2_parse_frame_header(buf []u8) !H2FrameHeader {
|
||||
if buf.len < h2_frame_header_len {
|
||||
return error('h2: frame header truncated')
|
||||
}
|
||||
return H2FrameHeader{
|
||||
length: h2_be_u24(buf, 0)
|
||||
typ: buf[3]
|
||||
flags: buf[4]
|
||||
stream_id: h2_be_u32(buf, 5) & 0x7fff_ffff
|
||||
}
|
||||
}
|
||||
|
||||
// --- Decoding ---
|
||||
|
||||
// h2_read_frame parses one frame (header + payload) from the start of `buf`,
|
||||
// returning the frame and the number of bytes consumed. The caller is
|
||||
// responsible for enforcing the negotiated SETTINGS_MAX_FRAME_SIZE.
|
||||
pub fn h2_read_frame(buf []u8) !(H2Frame, int) {
|
||||
header := h2_parse_frame_header(buf)!
|
||||
total := h2_frame_header_len + int(header.length)
|
||||
if buf.len < total {
|
||||
return error('h2: frame payload truncated (need ${total}, have ${buf.len})')
|
||||
}
|
||||
payload := buf[h2_frame_header_len..total]
|
||||
frame := h2_parse_frame(header, payload)!
|
||||
return frame, total
|
||||
}
|
||||
|
||||
// h2_strip_padding removes the optional pad-length prefix byte and the
|
||||
// trailing padding from a frame payload (RFC 7540 Section 6.1).
|
||||
fn h2_strip_padding(payload []u8, padded bool) ![]u8 {
|
||||
if !padded {
|
||||
return payload
|
||||
}
|
||||
if payload.len < 1 {
|
||||
return error('h2: padded frame missing pad length')
|
||||
}
|
||||
pad_len := int(payload[0])
|
||||
if 1 + pad_len > payload.len {
|
||||
return error('h2: pad length exceeds frame payload')
|
||||
}
|
||||
return payload[1..payload.len - pad_len]
|
||||
}
|
||||
|
||||
// h2_parse_frame decodes a frame from an already-parsed header and its payload.
|
||||
pub fn h2_parse_frame(header H2FrameHeader, payload []u8) !H2Frame {
|
||||
match header.typ {
|
||||
h2_frame_data {
|
||||
if header.stream_id == 0 {
|
||||
return error('h2: DATA frame on stream 0')
|
||||
}
|
||||
body := h2_strip_padding(payload, header.flags & h2_flag_padded != 0)!
|
||||
return H2DataFrame{
|
||||
stream_id: header.stream_id
|
||||
data: body.clone()
|
||||
end_stream: header.flags & h2_flag_end_stream != 0
|
||||
}
|
||||
}
|
||||
h2_frame_headers {
|
||||
if header.stream_id == 0 {
|
||||
return error('h2: HEADERS frame on stream 0')
|
||||
}
|
||||
mut body := h2_strip_padding(payload, header.flags & h2_flag_padded != 0)!
|
||||
mut has_priority := false
|
||||
mut exclusive := false
|
||||
mut stream_dep := u32(0)
|
||||
mut weight := u8(0)
|
||||
if header.flags & h2_flag_priority != 0 {
|
||||
if body.len < 5 {
|
||||
return error('h2: HEADERS priority section truncated')
|
||||
}
|
||||
dep := h2_be_u32(body, 0)
|
||||
has_priority = true
|
||||
exclusive = dep & 0x8000_0000 != 0
|
||||
stream_dep = dep & 0x7fff_ffff
|
||||
weight = body[4]
|
||||
body = unsafe { body[5..] }
|
||||
}
|
||||
return H2HeadersFrame{
|
||||
stream_id: header.stream_id
|
||||
fragment: body.clone()
|
||||
end_stream: header.flags & h2_flag_end_stream != 0
|
||||
end_headers: header.flags & h2_flag_end_headers != 0
|
||||
has_priority: has_priority
|
||||
exclusive: exclusive
|
||||
stream_dep: stream_dep
|
||||
weight: weight
|
||||
}
|
||||
}
|
||||
h2_frame_priority {
|
||||
if header.stream_id == 0 {
|
||||
return error('h2: PRIORITY frame on stream 0')
|
||||
}
|
||||
if payload.len != 5 {
|
||||
return error('h2: PRIORITY frame must be 5 bytes')
|
||||
}
|
||||
// Note: a stream depending on itself (stream_dep == stream_id) is a
|
||||
// stream error (RFC 7540 Section 5.3.1), and a zero stream
|
||||
// dependency is otherwise valid. These are semantic checks left to
|
||||
// the connection layer, which must respond with RST_STREAM on the
|
||||
// affected stream rather than tearing down the whole connection.
|
||||
dep := h2_be_u32(payload, 0)
|
||||
return H2PriorityFrame{
|
||||
stream_id: header.stream_id
|
||||
exclusive: dep & 0x8000_0000 != 0
|
||||
stream_dep: dep & 0x7fff_ffff
|
||||
weight: payload[4]
|
||||
}
|
||||
}
|
||||
h2_frame_rst_stream {
|
||||
if header.stream_id == 0 {
|
||||
return error('h2: RST_STREAM frame on stream 0')
|
||||
}
|
||||
if payload.len != 4 {
|
||||
return error('h2: RST_STREAM frame must be 4 bytes')
|
||||
}
|
||||
return H2RstStreamFrame{
|
||||
stream_id: header.stream_id
|
||||
error_code: h2_be_u32(payload, 0)
|
||||
}
|
||||
}
|
||||
h2_frame_settings {
|
||||
if header.stream_id != 0 {
|
||||
return error('h2: SETTINGS frame on non-zero stream')
|
||||
}
|
||||
ack := header.flags & h2_flag_ack != 0
|
||||
if ack {
|
||||
if payload.len != 0 {
|
||||
return error('h2: SETTINGS ACK must have empty payload')
|
||||
}
|
||||
return H2SettingsFrame{
|
||||
ack: true
|
||||
}
|
||||
}
|
||||
if payload.len % 6 != 0 {
|
||||
return error('h2: SETTINGS payload not a multiple of 6')
|
||||
}
|
||||
mut settings := []H2Setting{cap: payload.len / 6}
|
||||
for i := 0; i < payload.len; i += 6 {
|
||||
settings << H2Setting{
|
||||
id: h2_be_u16(payload, i)
|
||||
value: h2_be_u32(payload, i + 2)
|
||||
}
|
||||
}
|
||||
return H2SettingsFrame{
|
||||
ack: false
|
||||
settings: settings
|
||||
}
|
||||
}
|
||||
h2_frame_push_promise {
|
||||
if header.stream_id == 0 {
|
||||
return error('h2: PUSH_PROMISE frame on stream 0')
|
||||
}
|
||||
mut body := h2_strip_padding(payload, header.flags & h2_flag_padded != 0)!
|
||||
if body.len < 4 {
|
||||
return error('h2: PUSH_PROMISE missing promised stream id')
|
||||
}
|
||||
promised := h2_be_u32(body, 0) & 0x7fff_ffff
|
||||
return H2PushPromiseFrame{
|
||||
stream_id: header.stream_id
|
||||
promised_stream_id: promised
|
||||
fragment: body[4..].clone()
|
||||
end_headers: header.flags & h2_flag_end_headers != 0
|
||||
}
|
||||
}
|
||||
h2_frame_ping {
|
||||
if header.stream_id != 0 {
|
||||
return error('h2: PING frame on non-zero stream')
|
||||
}
|
||||
if payload.len != 8 {
|
||||
return error('h2: PING frame must be 8 bytes')
|
||||
}
|
||||
return H2PingFrame{
|
||||
ack: header.flags & h2_flag_ack != 0
|
||||
data: payload.clone()
|
||||
}
|
||||
}
|
||||
h2_frame_goaway {
|
||||
if header.stream_id != 0 {
|
||||
return error('h2: GOAWAY frame on non-zero stream')
|
||||
}
|
||||
if payload.len < 8 {
|
||||
return error('h2: GOAWAY frame too short')
|
||||
}
|
||||
return H2GoawayFrame{
|
||||
last_stream_id: h2_be_u32(payload, 0) & 0x7fff_ffff
|
||||
error_code: h2_be_u32(payload, 4)
|
||||
debug_data: payload[8..].clone()
|
||||
}
|
||||
}
|
||||
h2_frame_window_update {
|
||||
if payload.len != 4 {
|
||||
return error('h2: WINDOW_UPDATE frame must be 4 bytes')
|
||||
}
|
||||
// Note: a zero increment is an error (RFC 7540 Section 6.9) — a
|
||||
// stream error on a stream, but a connection error on stream 0.
|
||||
// That stream-vs-connection distinction is the connection layer's
|
||||
// responsibility, so it is not rejected here.
|
||||
return H2WindowUpdateFrame{
|
||||
stream_id: header.stream_id
|
||||
window_size_increment: h2_be_u32(payload, 0) & 0x7fff_ffff
|
||||
}
|
||||
}
|
||||
h2_frame_continuation {
|
||||
if header.stream_id == 0 {
|
||||
return error('h2: CONTINUATION frame on stream 0')
|
||||
}
|
||||
return H2ContinuationFrame{
|
||||
stream_id: header.stream_id
|
||||
fragment: payload.clone()
|
||||
end_headers: header.flags & h2_flag_end_headers != 0
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Unknown frame types must be ignored (RFC 7540 Section 4.1);
|
||||
// preserve them so the caller can decide.
|
||||
return H2UnknownFrame{
|
||||
header: header
|
||||
payload: payload.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Encoding ---
|
||||
|
||||
// h2_frame_bytes builds a complete frame from its parts.
|
||||
fn h2_frame_bytes(typ u8, flags u8, stream_id u32, payload []u8) []u8 {
|
||||
mut b := []u8{cap: h2_frame_header_len + payload.len}
|
||||
h2_put_u24(mut b, u32(payload.len))
|
||||
b << typ
|
||||
b << flags
|
||||
h2_put_u32(mut b, stream_id & 0x7fff_ffff)
|
||||
b << payload
|
||||
return b
|
||||
}
|
||||
|
||||
// encode serialises a frame to its on-the-wire bytes. The encoder never emits
|
||||
// padding.
|
||||
pub fn (f H2Frame) encode() []u8 {
|
||||
match f {
|
||||
H2DataFrame {
|
||||
flags := if f.end_stream { h2_flag_end_stream } else { u8(0) }
|
||||
return h2_frame_bytes(h2_frame_data, flags, f.stream_id, f.data)
|
||||
}
|
||||
H2HeadersFrame {
|
||||
mut flags := u8(0)
|
||||
if f.end_stream {
|
||||
flags |= h2_flag_end_stream
|
||||
}
|
||||
if f.end_headers {
|
||||
flags |= h2_flag_end_headers
|
||||
}
|
||||
mut payload := []u8{}
|
||||
if f.has_priority {
|
||||
flags |= h2_flag_priority
|
||||
mut dep := f.stream_dep & 0x7fff_ffff
|
||||
if f.exclusive {
|
||||
dep |= 0x8000_0000
|
||||
}
|
||||
h2_put_u32(mut payload, dep)
|
||||
payload << f.weight
|
||||
}
|
||||
payload << f.fragment
|
||||
return h2_frame_bytes(h2_frame_headers, flags, f.stream_id, payload)
|
||||
}
|
||||
H2PriorityFrame {
|
||||
mut payload := []u8{cap: 5}
|
||||
mut dep := f.stream_dep & 0x7fff_ffff
|
||||
if f.exclusive {
|
||||
dep |= 0x8000_0000
|
||||
}
|
||||
h2_put_u32(mut payload, dep)
|
||||
payload << f.weight
|
||||
return h2_frame_bytes(h2_frame_priority, 0, f.stream_id, payload)
|
||||
}
|
||||
H2RstStreamFrame {
|
||||
mut payload := []u8{cap: 4}
|
||||
h2_put_u32(mut payload, f.error_code)
|
||||
return h2_frame_bytes(h2_frame_rst_stream, 0, f.stream_id, payload)
|
||||
}
|
||||
H2SettingsFrame {
|
||||
if f.ack {
|
||||
return h2_frame_bytes(h2_frame_settings, h2_flag_ack, 0, [])
|
||||
}
|
||||
mut payload := []u8{cap: f.settings.len * 6}
|
||||
for s in f.settings {
|
||||
h2_put_u16(mut payload, s.id)
|
||||
h2_put_u32(mut payload, s.value)
|
||||
}
|
||||
return h2_frame_bytes(h2_frame_settings, 0, 0, payload)
|
||||
}
|
||||
H2PushPromiseFrame {
|
||||
mut flags := u8(0)
|
||||
if f.end_headers {
|
||||
flags |= h2_flag_end_headers
|
||||
}
|
||||
mut payload := []u8{cap: 4 + f.fragment.len}
|
||||
h2_put_u32(mut payload, f.promised_stream_id & 0x7fff_ffff)
|
||||
payload << f.fragment
|
||||
return h2_frame_bytes(h2_frame_push_promise, flags, f.stream_id, payload)
|
||||
}
|
||||
H2PingFrame {
|
||||
flags := if f.ack { h2_flag_ack } else { u8(0) }
|
||||
mut payload := []u8{len: 8}
|
||||
for i in 0 .. 8 {
|
||||
payload[i] = if i < f.data.len { f.data[i] } else { u8(0) }
|
||||
}
|
||||
return h2_frame_bytes(h2_frame_ping, flags, 0, payload)
|
||||
}
|
||||
H2GoawayFrame {
|
||||
mut payload := []u8{cap: 8 + f.debug_data.len}
|
||||
h2_put_u32(mut payload, f.last_stream_id & 0x7fff_ffff)
|
||||
h2_put_u32(mut payload, f.error_code)
|
||||
payload << f.debug_data
|
||||
return h2_frame_bytes(h2_frame_goaway, 0, 0, payload)
|
||||
}
|
||||
H2WindowUpdateFrame {
|
||||
mut payload := []u8{cap: 4}
|
||||
h2_put_u32(mut payload, f.window_size_increment & 0x7fff_ffff)
|
||||
return h2_frame_bytes(h2_frame_window_update, 0, f.stream_id, payload)
|
||||
}
|
||||
H2ContinuationFrame {
|
||||
flags := if f.end_headers { h2_flag_end_headers } else { u8(0) }
|
||||
return h2_frame_bytes(h2_frame_continuation, flags, f.stream_id, f.fragment)
|
||||
}
|
||||
H2UnknownFrame {
|
||||
return h2_frame_bytes(f.header.typ, f.header.flags, f.header.stream_id, f.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
module http
|
||||
|
||||
// Tests for the HTTP/2 framing layer (RFC 7540 Sections 4 and 6).
|
||||
|
||||
fn roundtrip(f H2Frame) !H2Frame {
|
||||
encoded := f.encode()
|
||||
decoded, consumed := h2_read_frame(encoded)!
|
||||
assert consumed == encoded.len, 'consumed ${consumed} != encoded ${encoded.len}'
|
||||
return decoded
|
||||
}
|
||||
|
||||
// --- Frame header ---
|
||||
|
||||
fn test_frame_header_layout() {
|
||||
// DATA frame, length 5, flags END_STREAM, stream 1, payload "hello".
|
||||
f := H2Frame(H2DataFrame{
|
||||
stream_id: 1
|
||||
data: 'hello'.bytes()
|
||||
end_stream: true
|
||||
})
|
||||
enc := f.encode()
|
||||
// 9-byte header: length(3) type(1) flags(1) stream_id(4)
|
||||
assert enc[0] == 0 && enc[1] == 0 && enc[2] == 5 // length = 5
|
||||
assert enc[3] == h2_frame_data
|
||||
assert enc[4] == h2_flag_end_stream
|
||||
assert enc[5] == 0 && enc[6] == 0 && enc[7] == 0 && enc[8] == 1 // stream 1
|
||||
assert enc[9..] == 'hello'.bytes()
|
||||
|
||||
h := h2_parse_frame_header(enc)!
|
||||
assert h.length == 5
|
||||
assert h.typ == h2_frame_data
|
||||
assert h.flags == h2_flag_end_stream
|
||||
assert h.stream_id == 1
|
||||
}
|
||||
|
||||
fn test_frame_header_clears_reserved_bit() {
|
||||
// The reserved high bit of the stream id must be ignored on read.
|
||||
raw := [u8(0), 0, 0, h2_frame_window_update, 0, 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 1]
|
||||
h := h2_parse_frame_header(raw)!
|
||||
assert h.stream_id == 0x7fff_ffff
|
||||
}
|
||||
|
||||
// --- Round-trips for each frame type ---
|
||||
|
||||
fn test_roundtrip_data() {
|
||||
got := roundtrip(H2DataFrame{
|
||||
stream_id: 3
|
||||
data: 'abc'.bytes()
|
||||
end_stream: true
|
||||
})!
|
||||
d := got as H2DataFrame
|
||||
assert d.stream_id == 3
|
||||
assert d.data == 'abc'.bytes()
|
||||
assert d.end_stream
|
||||
}
|
||||
|
||||
fn test_roundtrip_headers_plain() {
|
||||
got := roundtrip(H2HeadersFrame{
|
||||
stream_id: 1
|
||||
fragment: [u8(0x82), 0x86, 0x84]
|
||||
end_stream: true
|
||||
end_headers: true
|
||||
})!
|
||||
h := got as H2HeadersFrame
|
||||
assert h.stream_id == 1
|
||||
assert h.fragment == [u8(0x82), 0x86, 0x84]
|
||||
assert h.end_stream
|
||||
assert h.end_headers
|
||||
assert !h.has_priority
|
||||
}
|
||||
|
||||
fn test_roundtrip_headers_with_priority() {
|
||||
got := roundtrip(H2HeadersFrame{
|
||||
stream_id: 5
|
||||
fragment: [u8(0x88)]
|
||||
end_headers: true
|
||||
has_priority: true
|
||||
exclusive: true
|
||||
stream_dep: 3
|
||||
weight: 201
|
||||
})!
|
||||
h := got as H2HeadersFrame
|
||||
assert h.has_priority
|
||||
assert h.exclusive
|
||||
assert h.stream_dep == 3
|
||||
assert h.weight == 201
|
||||
assert h.fragment == [u8(0x88)]
|
||||
}
|
||||
|
||||
fn test_roundtrip_priority() {
|
||||
got := roundtrip(H2PriorityFrame{
|
||||
stream_id: 7
|
||||
exclusive: false
|
||||
stream_dep: 1
|
||||
weight: 16
|
||||
})!
|
||||
p := got as H2PriorityFrame
|
||||
assert p.stream_id == 7
|
||||
assert p.stream_dep == 1
|
||||
assert p.weight == 16
|
||||
assert !p.exclusive
|
||||
}
|
||||
|
||||
fn test_roundtrip_rst_stream() {
|
||||
got := roundtrip(H2RstStreamFrame{
|
||||
stream_id: 9
|
||||
error_code: u32(H2ErrorCode.cancel)
|
||||
})!
|
||||
r := got as H2RstStreamFrame
|
||||
assert r.stream_id == 9
|
||||
assert r.error_code == u32(H2ErrorCode.cancel)
|
||||
}
|
||||
|
||||
fn test_roundtrip_settings() {
|
||||
got := roundtrip(H2SettingsFrame{
|
||||
settings: [
|
||||
H2Setting{h2_settings_header_table_size, 4096},
|
||||
H2Setting{h2_settings_enable_push, 0},
|
||||
H2Setting{h2_settings_initial_window_size, 65535},
|
||||
]
|
||||
})!
|
||||
s := got as H2SettingsFrame
|
||||
assert !s.ack
|
||||
assert s.settings.len == 3
|
||||
assert s.settings[0].id == h2_settings_header_table_size
|
||||
assert s.settings[0].value == 4096
|
||||
assert s.settings[1].id == h2_settings_enable_push
|
||||
assert s.settings[1].value == 0
|
||||
assert s.settings[2].value == 65535
|
||||
}
|
||||
|
||||
fn test_roundtrip_settings_ack() {
|
||||
got := roundtrip(H2SettingsFrame{
|
||||
ack: true
|
||||
})!
|
||||
s := got as H2SettingsFrame
|
||||
assert s.ack
|
||||
assert s.settings.len == 0
|
||||
}
|
||||
|
||||
fn test_roundtrip_push_promise() {
|
||||
got := roundtrip(H2PushPromiseFrame{
|
||||
stream_id: 1
|
||||
promised_stream_id: 2
|
||||
fragment: [u8(0x82), 0x84]
|
||||
end_headers: true
|
||||
})!
|
||||
p := got as H2PushPromiseFrame
|
||||
assert p.stream_id == 1
|
||||
assert p.promised_stream_id == 2
|
||||
assert p.fragment == [u8(0x82), 0x84]
|
||||
assert p.end_headers
|
||||
}
|
||||
|
||||
fn test_roundtrip_ping() {
|
||||
got := roundtrip(H2PingFrame{
|
||||
ack: true
|
||||
data: [u8(1), 2, 3, 4, 5, 6, 7, 8]
|
||||
})!
|
||||
p := got as H2PingFrame
|
||||
assert p.ack
|
||||
assert p.data == [u8(1), 2, 3, 4, 5, 6, 7, 8]
|
||||
}
|
||||
|
||||
fn test_roundtrip_goaway() {
|
||||
got := roundtrip(H2GoawayFrame{
|
||||
last_stream_id: 7
|
||||
error_code: u32(H2ErrorCode.protocol_error)
|
||||
debug_data: 'oops'.bytes()
|
||||
})!
|
||||
g := got as H2GoawayFrame
|
||||
assert g.last_stream_id == 7
|
||||
assert g.error_code == u32(H2ErrorCode.protocol_error)
|
||||
assert g.debug_data == 'oops'.bytes()
|
||||
}
|
||||
|
||||
fn test_roundtrip_window_update() {
|
||||
got := roundtrip(H2WindowUpdateFrame{
|
||||
stream_id: 0
|
||||
window_size_increment: 65535
|
||||
})!
|
||||
w := got as H2WindowUpdateFrame
|
||||
assert w.stream_id == 0
|
||||
assert w.window_size_increment == 65535
|
||||
}
|
||||
|
||||
fn test_roundtrip_continuation() {
|
||||
got := roundtrip(H2ContinuationFrame{
|
||||
stream_id: 3
|
||||
fragment: [u8(0x40), 0x88]
|
||||
end_headers: true
|
||||
})!
|
||||
c := got as H2ContinuationFrame
|
||||
assert c.stream_id == 3
|
||||
assert c.fragment == [u8(0x40), 0x88]
|
||||
assert c.end_headers
|
||||
}
|
||||
|
||||
// --- Padding ---
|
||||
|
||||
fn test_data_padding_decode() {
|
||||
// Hand-built padded DATA frame: pad length 3, data "hi", 3 pad bytes.
|
||||
mut payload := [u8(3)] // pad length
|
||||
payload << 'hi'.bytes()
|
||||
payload << [u8(0), 0, 0] // padding
|
||||
raw := h2_frame_bytes(h2_frame_data, h2_flag_padded, 1, payload)
|
||||
frame, _ := h2_read_frame(raw)!
|
||||
d := frame as H2DataFrame
|
||||
assert d.data == 'hi'.bytes()
|
||||
}
|
||||
|
||||
fn test_padding_length_exceeds_frame_rejected() {
|
||||
// pad length 5 but only 2 bytes of payload after it -> error.
|
||||
payload := [u8(5), 0x61, 0x62]
|
||||
raw := h2_frame_bytes(h2_frame_data, h2_flag_padded, 1, payload)
|
||||
h2_read_frame(raw) or { return }
|
||||
assert false, 'expected pad-length error'
|
||||
}
|
||||
|
||||
// --- Unknown frames ---
|
||||
|
||||
fn test_unknown_frame_preserved() {
|
||||
// Type 0x16 is not defined; it must be preserved, not rejected.
|
||||
raw := h2_frame_bytes(0x16, 0x0, 0, [u8(0xde), 0xad, 0xbe, 0xef])
|
||||
frame, consumed := h2_read_frame(raw)!
|
||||
assert consumed == raw.len
|
||||
u := frame as H2UnknownFrame
|
||||
assert u.header.typ == 0x16
|
||||
assert u.payload == [u8(0xde), 0xad, 0xbe, 0xef]
|
||||
// And it round-trips back to the same bytes.
|
||||
assert frame.encode() == raw
|
||||
}
|
||||
|
||||
// --- Structural validation ---
|
||||
|
||||
fn test_read_frame_truncated_payload() {
|
||||
// Header claims 10 bytes of payload but none follow.
|
||||
raw := [u8(0), 0, 10, h2_frame_data, 0, 0, 0, 0, 1]
|
||||
h2_read_frame(raw) or { return }
|
||||
assert false, 'expected truncated payload error'
|
||||
}
|
||||
|
||||
fn test_settings_bad_length_rejected() {
|
||||
// SETTINGS payload not a multiple of 6.
|
||||
raw := h2_frame_bytes(h2_frame_settings, 0, 0, [u8(0), 1, 0, 0, 0])
|
||||
h2_read_frame(raw) or { return }
|
||||
assert false, 'expected SETTINGS length error'
|
||||
}
|
||||
|
||||
fn test_settings_ack_with_payload_rejected() {
|
||||
raw := h2_frame_bytes(h2_frame_settings, h2_flag_ack, 0, [u8(0), 1, 0, 0, 0x10, 0])
|
||||
h2_read_frame(raw) or { return }
|
||||
assert false, 'expected SETTINGS ACK payload error'
|
||||
}
|
||||
|
||||
fn test_window_update_bad_length_rejected() {
|
||||
raw := h2_frame_bytes(h2_frame_window_update, 0, 0, [u8(0), 0, 1])
|
||||
h2_read_frame(raw) or { return }
|
||||
assert false, 'expected WINDOW_UPDATE length error'
|
||||
}
|
||||
|
||||
fn test_ping_bad_length_rejected() {
|
||||
raw := h2_frame_bytes(h2_frame_ping, 0, 0, [u8(1), 2, 3, 4])
|
||||
h2_read_frame(raw) or { return }
|
||||
assert false, 'expected PING length error'
|
||||
}
|
||||
|
||||
fn test_data_on_stream_zero_rejected() {
|
||||
raw := h2_frame_bytes(h2_frame_data, 0, 0, 'x'.bytes())
|
||||
h2_read_frame(raw) or { return }
|
||||
assert false, 'expected DATA-on-stream-0 error'
|
||||
}
|
||||
|
||||
fn test_settings_on_nonzero_stream_rejected() {
|
||||
raw := h2_frame_bytes(h2_frame_settings, 0, 1, [])
|
||||
h2_read_frame(raw) or { return }
|
||||
assert false, 'expected SETTINGS-on-stream-1 error'
|
||||
}
|
||||
|
||||
// --- Multiple frames back to back ---
|
||||
|
||||
fn test_read_consecutive_frames() {
|
||||
mut buf := []u8{}
|
||||
buf << H2Frame(H2SettingsFrame{}).encode()
|
||||
buf << H2Frame(H2DataFrame{
|
||||
stream_id: 1
|
||||
data: 'one'.bytes()
|
||||
}).encode()
|
||||
buf << H2Frame(H2PingFrame{
|
||||
data: [u8(0), 0, 0, 0, 0, 0, 0, 9]
|
||||
}).encode()
|
||||
|
||||
mut pos := 0
|
||||
f1, c1 := h2_read_frame(buf[pos..])!
|
||||
pos += c1
|
||||
f2, c2 := h2_read_frame(buf[pos..])!
|
||||
pos += c2
|
||||
f3, c3 := h2_read_frame(buf[pos..])!
|
||||
pos += c3
|
||||
assert pos == buf.len
|
||||
assert f1 is H2SettingsFrame
|
||||
d := f2 as H2DataFrame
|
||||
assert d.data == 'one'.bytes()
|
||||
p := f3 as H2PingFrame
|
||||
assert p.data[7] == 9
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// This file implements HPACK header compression (RFC 7541), used by HTTP/2.
|
||||
// It is self-contained: it only depends on the static and Huffman tables in
|
||||
// the sibling h2_hpack_*.v files, and does not touch the rest of net.http.
|
||||
|
||||
// h2_hpack_default_table_size is the default maximum size of the HPACK
|
||||
// dynamic table, in bytes (RFC 7541 Section 4.2, and the default value of
|
||||
// SETTINGS_HEADER_TABLE_SIZE).
|
||||
pub const h2_hpack_default_table_size = 4096
|
||||
|
||||
// H2HeaderField is a single HTTP/2 header field (a name/value pair).
|
||||
// Field names are expected to be lowercase, as required by RFC 7540.
|
||||
pub struct H2HeaderField {
|
||||
pub:
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
// H2DynEntry is one entry in the HPACK dynamic table.
|
||||
struct H2DynEntry {
|
||||
name string
|
||||
value string
|
||||
size int // name.len + value.len + 32 (RFC 7541 Section 4.1)
|
||||
}
|
||||
|
||||
// H2DynTable is the HPACK dynamic table: a size-bounded FIFO of header fields.
|
||||
// entries[0] is always the most recently added entry, which corresponds to the
|
||||
// lowest dynamic index in the HPACK index space.
|
||||
struct H2DynTable {
|
||||
mut:
|
||||
entries []H2DynEntry
|
||||
cur_size int
|
||||
max_size int = h2_hpack_default_table_size
|
||||
}
|
||||
|
||||
// add inserts a new entry at the front of the table, evicting the oldest
|
||||
// entries until it fits. Per RFC 7541 Section 4.4, an entry larger than the
|
||||
// whole table empties it and is not added.
|
||||
fn (mut t H2DynTable) add(name string, value string) {
|
||||
sz := name.len + value.len + 32
|
||||
for t.entries.len > 0 && t.cur_size + sz > t.max_size {
|
||||
t.cur_size -= t.entries.last().size
|
||||
t.entries.delete_last()
|
||||
}
|
||||
if sz > t.max_size {
|
||||
return
|
||||
}
|
||||
t.entries.insert(0, H2DynEntry{
|
||||
name: name
|
||||
value: value
|
||||
size: sz
|
||||
})
|
||||
t.cur_size += sz
|
||||
}
|
||||
|
||||
// set_max_size updates the maximum table size, evicting oldest entries as
|
||||
// needed to respect the new limit.
|
||||
fn (mut t H2DynTable) set_max_size(n int) {
|
||||
t.max_size = n
|
||||
for t.entries.len > 0 && t.cur_size > t.max_size {
|
||||
t.cur_size -= t.entries.last().size
|
||||
t.entries.delete_last()
|
||||
}
|
||||
}
|
||||
|
||||
// get returns the dynamic-table entry at 1-based dynamic index `i`
|
||||
// (i.e. i==1 is the newest entry), or none if out of range.
|
||||
fn (t &H2DynTable) get(i int) ?H2HeaderField {
|
||||
if i < 1 || i > t.entries.len {
|
||||
return none
|
||||
}
|
||||
e := t.entries[i - 1]
|
||||
return H2HeaderField{
|
||||
name: e.name
|
||||
value: e.value
|
||||
}
|
||||
}
|
||||
|
||||
// H2HpackReader is a cursor over an HPACK header block being decoded.
|
||||
struct H2HpackReader {
|
||||
buf []u8
|
||||
mut:
|
||||
pos int
|
||||
}
|
||||
|
||||
// read_int reads an HPACK variable-length integer with a prefix of
|
||||
// `prefix_bits` bits at the current position, advancing past it
|
||||
// (RFC 7541 Section 5.1).
|
||||
fn (mut r H2HpackReader) read_int(prefix_bits int) !u64 {
|
||||
if r.pos >= r.buf.len {
|
||||
return error('hpack: integer truncated')
|
||||
}
|
||||
max_prefix := u64((1 << prefix_bits) - 1)
|
||||
mut value := u64(r.buf[r.pos]) & max_prefix
|
||||
r.pos++
|
||||
if value < max_prefix {
|
||||
return value
|
||||
}
|
||||
mut m := 0
|
||||
for {
|
||||
if r.pos >= r.buf.len {
|
||||
return error('hpack: integer continuation truncated')
|
||||
}
|
||||
b := r.buf[r.pos]
|
||||
r.pos++
|
||||
value += u64(b & 0x7f) << m
|
||||
m += 7
|
||||
// A 64-bit value never needs more than 9 continuation bytes; reject
|
||||
// pathological inputs early (RFC 7541 Section 5.1 security note).
|
||||
if m > 63 {
|
||||
return error('hpack: integer overflow')
|
||||
}
|
||||
if b & 0x80 == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// read_string reads an HPACK string literal at the current position,
|
||||
// advancing past it and decoding Huffman coding when the H bit is set.
|
||||
fn (mut r H2HpackReader) read_string() !string {
|
||||
if r.pos >= r.buf.len {
|
||||
return error('hpack: string truncated')
|
||||
}
|
||||
huffman := (r.buf[r.pos] & 0x80) != 0
|
||||
length := r.read_int(7)!
|
||||
// Compare in u64 space, before narrowing to int, so an oversized length
|
||||
// from a malicious peer cannot truncate past this bounds check.
|
||||
if length > u64(r.buf.len - r.pos) {
|
||||
return error('hpack: string length exceeds buffer')
|
||||
}
|
||||
n := int(length)
|
||||
raw := r.buf[r.pos..r.pos + n]
|
||||
r.pos += n
|
||||
if huffman {
|
||||
return h2_huffman_decode(raw)!.bytestr()
|
||||
}
|
||||
return raw.bytestr()
|
||||
}
|
||||
|
||||
// h2_hpack_write_int appends an HPACK variable-length integer to `out`, using
|
||||
// a prefix of `prefix_bits` bits whose high bits are set from `high_bits`.
|
||||
fn h2_hpack_write_int(mut out []u8, value u64, prefix_bits int, high_bits u8) {
|
||||
max_prefix := u64((1 << prefix_bits) - 1)
|
||||
if value < max_prefix {
|
||||
out << high_bits | u8(value)
|
||||
return
|
||||
}
|
||||
out << high_bits | u8(max_prefix)
|
||||
mut v := value - max_prefix
|
||||
for v >= 0x80 {
|
||||
out << u8((v & 0x7f) | 0x80)
|
||||
v >>= 7
|
||||
}
|
||||
out << u8(v)
|
||||
}
|
||||
|
||||
// h2_encode_string appends an HPACK string literal to `out`, choosing Huffman
|
||||
// coding when it is shorter than the raw bytes (RFC 7541 Section 5.2).
|
||||
fn h2_encode_string(mut out []u8, s string) {
|
||||
raw := s.bytes()
|
||||
huff := h2_huffman_encode(raw)
|
||||
if huff.len < raw.len {
|
||||
h2_hpack_write_int(mut out, u64(huff.len), 7, 0x80) // H = 1
|
||||
out << huff
|
||||
} else {
|
||||
h2_hpack_write_int(mut out, u64(raw.len), 7, 0x00) // H = 0
|
||||
out << raw
|
||||
}
|
||||
}
|
||||
|
||||
// h2_is_sensitive reports whether a header should never be added to the HPACK
|
||||
// dynamic table (encoded as "never indexed"), to avoid CRIME-style attacks.
|
||||
fn h2_is_sensitive(name string) bool {
|
||||
return name == 'cookie' || name == 'authorization' || name == 'proxy-authorization'
|
||||
}
|
||||
|
||||
// h2_hpack_find_static searches the static table for `name`/`value`.
|
||||
// It returns the 1-based HPACK index and whether the value also matched.
|
||||
// The index is 0 when the name is not present at all.
|
||||
fn h2_hpack_find_static(name string, value string) (int, bool) {
|
||||
mut name_idx := 0
|
||||
for i, f in h2_hpack_static_table {
|
||||
if f.name == name {
|
||||
if f.value == value {
|
||||
return i + 1, true
|
||||
}
|
||||
if name_idx == 0 {
|
||||
name_idx = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return name_idx, false
|
||||
}
|
||||
|
||||
// H2HpackEncoder encodes header field lists into HPACK header blocks.
|
||||
// This encoder never adds entries to the dynamic table: it uses indexed
|
||||
// representations for static-table hits and literal-without-indexing (or
|
||||
// never-indexed, for sensitive headers) otherwise. That keeps encoder and
|
||||
// decoder state trivially in sync while remaining fully interoperable.
|
||||
pub struct H2HpackEncoder {
|
||||
pub mut:
|
||||
dyn_table H2DynTable
|
||||
}
|
||||
|
||||
// encode returns the HPACK header block for `fields`.
|
||||
pub fn (mut e H2HpackEncoder) encode(fields []H2HeaderField) []u8 {
|
||||
mut out := []u8{}
|
||||
for f in fields {
|
||||
e.encode_field(mut out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fn (mut e H2HpackEncoder) encode_field(mut out []u8, f H2HeaderField) {
|
||||
sensitive := h2_is_sensitive(f.name)
|
||||
idx, exact := h2_hpack_find_static(f.name, f.value)
|
||||
if exact && !sensitive {
|
||||
// Indexed Header Field (RFC 7541 Section 6.1).
|
||||
h2_hpack_write_int(mut out, u64(idx), 7, 0x80)
|
||||
return
|
||||
}
|
||||
// Literal Header Field without Indexing (6.2.2) or Never Indexed (6.2.3).
|
||||
pattern := if sensitive { u8(0x10) } else { u8(0x00) }
|
||||
if idx != 0 {
|
||||
h2_hpack_write_int(mut out, u64(idx), 4, pattern)
|
||||
} else {
|
||||
h2_hpack_write_int(mut out, 0, 4, pattern)
|
||||
h2_encode_string(mut out, f.name)
|
||||
}
|
||||
h2_encode_string(mut out, f.value)
|
||||
}
|
||||
|
||||
// H2HpackDecoder decodes HPACK header blocks into header field lists,
|
||||
// maintaining the dynamic table across calls on the same connection.
|
||||
pub struct H2HpackDecoder {
|
||||
pub mut:
|
||||
dyn_table H2DynTable
|
||||
max_dynamic_size int = h2_hpack_default_table_size // upper bound we advertised to the peer
|
||||
}
|
||||
|
||||
// lookup resolves a 1-based HPACK index against the static and dynamic tables.
|
||||
fn (d &H2HpackDecoder) lookup(idx u64) !H2HeaderField {
|
||||
if idx == 0 {
|
||||
return error('hpack: index 0 is not valid')
|
||||
}
|
||||
if idx <= u64(h2_hpack_static_len) {
|
||||
return h2_hpack_static_table[idx - 1]
|
||||
}
|
||||
// Validate the dynamic index in u64 space before narrowing to int, so an
|
||||
// out-of-range index from a malicious peer cannot truncate onto a valid
|
||||
// dynamic entry.
|
||||
dyn_idx := idx - u64(h2_hpack_static_len)
|
||||
if dyn_idx > u64(d.dyn_table.entries.len) {
|
||||
return error('hpack: index ${idx} out of range')
|
||||
}
|
||||
return d.dyn_table.get(int(dyn_idx)) or { error('hpack: index ${idx} out of range') }
|
||||
}
|
||||
|
||||
fn (d &H2HpackDecoder) read_literal(mut r H2HpackReader, prefix_bits int) !H2HeaderField {
|
||||
name_index := r.read_int(prefix_bits)!
|
||||
name := if name_index == 0 {
|
||||
r.read_string()!
|
||||
} else {
|
||||
d.lookup(name_index)!.name
|
||||
}
|
||||
value := r.read_string()!
|
||||
return H2HeaderField{
|
||||
name: name
|
||||
value: value
|
||||
}
|
||||
}
|
||||
|
||||
// decode parses one HPACK header block and returns its header fields,
|
||||
// updating the dynamic table as instructed by the block.
|
||||
pub fn (mut d H2HpackDecoder) decode(block []u8) ![]H2HeaderField {
|
||||
mut out := []H2HeaderField{}
|
||||
mut r := H2HpackReader{
|
||||
buf: block
|
||||
}
|
||||
mut seen_field := false
|
||||
for r.pos < block.len {
|
||||
b := block[r.pos]
|
||||
if b & 0x80 != 0 {
|
||||
// Indexed Header Field (RFC 7541 Section 6.1).
|
||||
idx := r.read_int(7)!
|
||||
out << d.lookup(idx)!
|
||||
seen_field = true
|
||||
} else if b & 0x40 != 0 {
|
||||
// Literal Header Field with Incremental Indexing (6.2.1).
|
||||
f := d.read_literal(mut r, 6)!
|
||||
d.dyn_table.add(f.name, f.value)
|
||||
out << f
|
||||
seen_field = true
|
||||
} else if b & 0x20 != 0 {
|
||||
// Dynamic Table Size Update (6.3). Must precede any header field.
|
||||
if seen_field {
|
||||
return error('hpack: dynamic table size update after a header field')
|
||||
}
|
||||
new_size := r.read_int(5)!
|
||||
if int(new_size) > d.max_dynamic_size {
|
||||
return error('hpack: dynamic table size update ${new_size} exceeds limit ${d.max_dynamic_size}')
|
||||
}
|
||||
d.dyn_table.set_max_size(int(new_size))
|
||||
} else {
|
||||
// Literal without Indexing (6.2.2) or Never Indexed (6.2.3);
|
||||
// neither updates the dynamic table.
|
||||
f := d.read_literal(mut r, 4)!
|
||||
out << f
|
||||
seen_field = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// set_max_dynamic_size updates the maximum dynamic-table size the decoder will
|
||||
// accept (i.e. the SETTINGS_HEADER_TABLE_SIZE value advertised to the peer),
|
||||
// shrinking the table immediately if needed.
|
||||
pub fn (mut d H2HpackDecoder) set_max_dynamic_size(n int) {
|
||||
d.max_dynamic_size = n
|
||||
if d.dyn_table.max_size > n {
|
||||
d.dyn_table.set_max_size(n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import hash.huffman
|
||||
|
||||
// h2_huffman_eos is the index of the EOS (end-of-string) symbol in the
|
||||
// HPACK Huffman table (RFC 7541 Appendix B).
|
||||
const h2_huffman_eos = 256
|
||||
|
||||
// h2_max_code_bits is the longest HPACK Huffman code length (RFC 7541
|
||||
// Appendix B). 30 bits is too wide for a flat decode table, so decoding goes
|
||||
// bit-at-a-time against h2_huffman_decode_map.
|
||||
const h2_max_code_bits = 30
|
||||
|
||||
// h2_huffman_table holds the canonical HPACK Huffman codes, rebuilt at startup
|
||||
// from the per-symbol bit lengths via the shared hash.huffman builder. The
|
||||
// codes are MSB-first (the order they appear on the wire).
|
||||
const h2_huffman_table = build_h2_huffman_table()
|
||||
|
||||
// h2_huffman_decode_map maps a (bit_length, code) pair, packed as
|
||||
// `(bit_length << 32) | code`, to its symbol. It is built once at startup
|
||||
// from the canonical code table and is read-only afterwards.
|
||||
const h2_huffman_decode_map = h2_huffman_table.decode_map() or { panic('hpack: ${err}') }
|
||||
|
||||
fn build_h2_huffman_table() huffman.Table {
|
||||
return huffman.build(
|
||||
lengths: h2_huffman_code_lens[..].map(int(it))
|
||||
max_bits: h2_max_code_bits
|
||||
bit_order: .msb_first
|
||||
) or { panic('hpack: ${err}') }
|
||||
}
|
||||
|
||||
// h2_huffman_encode returns the HPACK Huffman encoding of `input`
|
||||
// (RFC 7541 Section 5.2). The final byte is padded with the most significant
|
||||
// bits of the EOS code, i.e. all ones.
|
||||
fn h2_huffman_encode(input []u8) []u8 {
|
||||
mut out := []u8{cap: input.len}
|
||||
mut acc := u64(0)
|
||||
mut nbits := 0
|
||||
for b in input {
|
||||
code := u64(h2_huffman_table.codes[b])
|
||||
clen := h2_huffman_table.lengths[b]
|
||||
acc = (acc << clen) | code
|
||||
nbits += clen
|
||||
for nbits >= 8 {
|
||||
nbits -= 8
|
||||
out << u8((acc >> nbits) & 0xff)
|
||||
}
|
||||
// Keep only the still-buffered low bits, so acc stays bounded.
|
||||
acc = if nbits == 0 { u64(0) } else { acc & ((u64(1) << nbits) - 1) }
|
||||
}
|
||||
if nbits > 0 {
|
||||
pad := 8 - nbits
|
||||
out << u8(((acc << pad) | ((u64(1) << pad) - 1)) & 0xff)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// h2_huffman_decode reverses h2_huffman_encode. It returns an error for the
|
||||
// invalid cases called out by RFC 7541 Section 5.2: an explicit EOS symbol,
|
||||
// padding longer than 7 bits, or padding that is not all ones.
|
||||
fn h2_huffman_decode(input []u8) ![]u8 {
|
||||
mut out := []u8{cap: input.len + input.len / 2}
|
||||
mut cur := u64(0)
|
||||
mut cur_len := 0
|
||||
for b in input {
|
||||
for bit := 7; bit >= 0; bit-- {
|
||||
cur = (cur << 1) | u64((b >> u8(bit)) & 1)
|
||||
cur_len++
|
||||
if cur_len > h2_max_code_bits {
|
||||
return error('hpack: invalid huffman code (no symbol within ${h2_max_code_bits} bits)')
|
||||
}
|
||||
if sym := h2_huffman_decode_map[(u64(cur_len) << 32) | cur] {
|
||||
if sym == h2_huffman_eos {
|
||||
return error('hpack: EOS symbol encountered in huffman string')
|
||||
}
|
||||
out << u8(sym)
|
||||
cur = 0
|
||||
cur_len = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur_len >= 8 {
|
||||
return error('hpack: invalid huffman padding (more than 7 bits left)')
|
||||
}
|
||||
if cur_len > 0 {
|
||||
mask := (u64(1) << cur_len) - 1
|
||||
if cur & mask != mask {
|
||||
return error('hpack: invalid huffman padding (not all ones)')
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Code generated from RFC 7541 Appendix B (HPACK Huffman code). DO NOT EDIT.
|
||||
// Source: HTTP/2 (RFC 7541) static Huffman table, 256 byte symbols + EOS (257).
|
||||
module http
|
||||
|
||||
// h2_huffman_code_lens holds the bit length of each HPACK symbol's Huffman
|
||||
// code (1..30 bits). Index 0..255 are byte values; index 256 is the EOS
|
||||
// symbol. The codes themselves are canonical, so they are not stored: they are
|
||||
// rebuilt from these lengths at startup via hash.huffman (see h2_huffman_table
|
||||
// in h2_hpack_huffman.v).
|
||||
const h2_huffman_code_lens = [
|
||||
u8(13),
|
||||
23,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
24,
|
||||
30,
|
||||
28,
|
||||
28,
|
||||
30,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
30,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
28,
|
||||
6,
|
||||
10,
|
||||
10,
|
||||
12,
|
||||
13,
|
||||
6,
|
||||
8,
|
||||
11,
|
||||
10,
|
||||
10,
|
||||
8,
|
||||
11,
|
||||
8,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
15,
|
||||
6,
|
||||
12,
|
||||
10,
|
||||
13,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
8,
|
||||
7,
|
||||
8,
|
||||
13,
|
||||
19,
|
||||
13,
|
||||
14,
|
||||
6,
|
||||
15,
|
||||
5,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
5,
|
||||
7,
|
||||
7,
|
||||
6,
|
||||
6,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
6,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
15,
|
||||
11,
|
||||
14,
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
22,
|
||||
20,
|
||||
20,
|
||||
22,
|
||||
22,
|
||||
22,
|
||||
23,
|
||||
22,
|
||||
23,
|
||||
23,
|
||||
23,
|
||||
23,
|
||||
23,
|
||||
24,
|
||||
23,
|
||||
24,
|
||||
24,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
23,
|
||||
23,
|
||||
23,
|
||||
23,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
22,
|
||||
23,
|
||||
23,
|
||||
24,
|
||||
22,
|
||||
21,
|
||||
20,
|
||||
22,
|
||||
22,
|
||||
23,
|
||||
23,
|
||||
21,
|
||||
23,
|
||||
22,
|
||||
22,
|
||||
24,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
23,
|
||||
21,
|
||||
21,
|
||||
22,
|
||||
21,
|
||||
23,
|
||||
22,
|
||||
23,
|
||||
23,
|
||||
20,
|
||||
22,
|
||||
22,
|
||||
22,
|
||||
23,
|
||||
22,
|
||||
22,
|
||||
23,
|
||||
26,
|
||||
26,
|
||||
20,
|
||||
19,
|
||||
22,
|
||||
23,
|
||||
22,
|
||||
25,
|
||||
26,
|
||||
26,
|
||||
26,
|
||||
27,
|
||||
27,
|
||||
26,
|
||||
24,
|
||||
25,
|
||||
19,
|
||||
21,
|
||||
26,
|
||||
27,
|
||||
27,
|
||||
26,
|
||||
27,
|
||||
24,
|
||||
21,
|
||||
21,
|
||||
26,
|
||||
26,
|
||||
28,
|
||||
27,
|
||||
27,
|
||||
27,
|
||||
20,
|
||||
24,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
21,
|
||||
21,
|
||||
23,
|
||||
22,
|
||||
22,
|
||||
25,
|
||||
25,
|
||||
24,
|
||||
24,
|
||||
26,
|
||||
23,
|
||||
26,
|
||||
27,
|
||||
26,
|
||||
26,
|
||||
27,
|
||||
27,
|
||||
27,
|
||||
27,
|
||||
27,
|
||||
28,
|
||||
27,
|
||||
27,
|
||||
27,
|
||||
27,
|
||||
27,
|
||||
26,
|
||||
30,
|
||||
]!
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// h2_hpack_static_table is the HPACK static table (RFC 7541 Appendix A).
|
||||
// HPACK indexes into it are 1-based, so entry N is at index N-1 here.
|
||||
const h2_hpack_static_table = [
|
||||
H2HeaderField{':authority', ''},
|
||||
H2HeaderField{':method', 'GET'},
|
||||
H2HeaderField{':method', 'POST'},
|
||||
H2HeaderField{':path', '/'},
|
||||
H2HeaderField{':path', '/index.html'},
|
||||
H2HeaderField{':scheme', 'http'},
|
||||
H2HeaderField{':scheme', 'https'},
|
||||
H2HeaderField{':status', '200'},
|
||||
H2HeaderField{':status', '204'},
|
||||
H2HeaderField{':status', '206'},
|
||||
H2HeaderField{':status', '304'},
|
||||
H2HeaderField{':status', '400'},
|
||||
H2HeaderField{':status', '404'},
|
||||
H2HeaderField{':status', '500'},
|
||||
H2HeaderField{'accept-charset', ''},
|
||||
H2HeaderField{'accept-encoding', 'gzip, deflate'},
|
||||
H2HeaderField{'accept-language', ''},
|
||||
H2HeaderField{'accept-ranges', ''},
|
||||
H2HeaderField{'accept', ''},
|
||||
H2HeaderField{'access-control-allow-origin', ''},
|
||||
H2HeaderField{'age', ''},
|
||||
H2HeaderField{'allow', ''},
|
||||
H2HeaderField{'authorization', ''},
|
||||
H2HeaderField{'cache-control', ''},
|
||||
H2HeaderField{'content-disposition', ''},
|
||||
H2HeaderField{'content-encoding', ''},
|
||||
H2HeaderField{'content-language', ''},
|
||||
H2HeaderField{'content-length', ''},
|
||||
H2HeaderField{'content-location', ''},
|
||||
H2HeaderField{'content-range', ''},
|
||||
H2HeaderField{'content-type', ''},
|
||||
H2HeaderField{'cookie', ''},
|
||||
H2HeaderField{'date', ''},
|
||||
H2HeaderField{'etag', ''},
|
||||
H2HeaderField{'expect', ''},
|
||||
H2HeaderField{'expires', ''},
|
||||
H2HeaderField{'from', ''},
|
||||
H2HeaderField{'host', ''},
|
||||
H2HeaderField{'if-match', ''},
|
||||
H2HeaderField{'if-modified-since', ''},
|
||||
H2HeaderField{'if-none-match', ''},
|
||||
H2HeaderField{'if-range', ''},
|
||||
H2HeaderField{'if-unmodified-since', ''},
|
||||
H2HeaderField{'last-modified', ''},
|
||||
H2HeaderField{'link', ''},
|
||||
H2HeaderField{'location', ''},
|
||||
H2HeaderField{'max-forwards', ''},
|
||||
H2HeaderField{'proxy-authenticate', ''},
|
||||
H2HeaderField{'proxy-authorization', ''},
|
||||
H2HeaderField{'range', ''},
|
||||
H2HeaderField{'referer', ''},
|
||||
H2HeaderField{'refresh', ''},
|
||||
H2HeaderField{'retry-after', ''},
|
||||
H2HeaderField{'server', ''},
|
||||
H2HeaderField{'set-cookie', ''},
|
||||
H2HeaderField{'strict-transport-security', ''},
|
||||
H2HeaderField{'transfer-encoding', ''},
|
||||
H2HeaderField{'user-agent', ''},
|
||||
H2HeaderField{'vary', ''},
|
||||
H2HeaderField{'via', ''},
|
||||
H2HeaderField{'www-authenticate', ''},
|
||||
]!
|
||||
|
||||
// h2_hpack_static_len is the number of entries in the HPACK static table.
|
||||
const h2_hpack_static_len = h2_hpack_static_table.len
|
||||
@@ -0,0 +1,335 @@
|
||||
module http
|
||||
|
||||
// Tests for the HPACK implementation (RFC 7541). The byte sequences below are
|
||||
// the worked examples from RFC 7541 Appendix C; decoding them is the canonical
|
||||
// correctness check for an HPACK decoder.
|
||||
|
||||
// hexb parses a hex string (spaces allowed) into bytes.
|
||||
fn hexb(s string) []u8 {
|
||||
clean := s.replace(' ', '').replace('\n', '')
|
||||
mut out := []u8{cap: clean.len / 2}
|
||||
for i := 0; i + 1 < clean.len; i += 2 {
|
||||
hi := hex_nibble(clean[i])
|
||||
lo := hex_nibble(clean[i + 1])
|
||||
out << u8((hi << 4) | lo)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fn hex_nibble(c u8) u8 {
|
||||
return match c {
|
||||
`0`...`9` { c - `0` }
|
||||
`a`...`f` { c - `a` + 10 }
|
||||
`A`...`F` { c - `A` + 10 }
|
||||
else { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_fields(got []H2HeaderField, want [][]string) {
|
||||
assert got.len == want.len, 'field count: got ${got.len}, want ${want.len}'
|
||||
for i, w in want {
|
||||
assert got[i].name == w[0], 'field ${i} name: got "${got[i].name}", want "${w[0]}"'
|
||||
assert got[i].value == w[1], 'field ${i} value: got "${got[i].value}", want "${w[1]}"'
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integer representation (RFC 7541 Section 5.1) ---
|
||||
|
||||
fn test_hpack_integer_examples() {
|
||||
// C.1.1: encode 10 with a 5-bit prefix -> single byte 0x0a.
|
||||
mut b := []u8{}
|
||||
h2_hpack_write_int(mut b, 10, 5, 0)
|
||||
assert b == [u8(0x0a)]
|
||||
mut r := H2HpackReader{
|
||||
buf: b
|
||||
}
|
||||
assert r.read_int(5)! == 10
|
||||
|
||||
// C.1.2: encode 1337 with a 5-bit prefix -> 0x1f 0x9a 0x0a.
|
||||
b = []u8{}
|
||||
h2_hpack_write_int(mut b, 1337, 5, 0)
|
||||
assert b == [u8(0x1f), 0x9a, 0x0a]
|
||||
r = H2HpackReader{
|
||||
buf: b
|
||||
}
|
||||
assert r.read_int(5)! == 1337
|
||||
|
||||
// C.1.3: encode 42 with an 8-bit prefix -> single byte 0x2a.
|
||||
b = []u8{}
|
||||
h2_hpack_write_int(mut b, 42, 8, 0)
|
||||
assert b == [u8(0x2a)]
|
||||
r = H2HpackReader{
|
||||
buf: b
|
||||
}
|
||||
assert r.read_int(8)! == 42
|
||||
}
|
||||
|
||||
fn test_hpack_integer_overflow_rejected() {
|
||||
// A run of continuation bytes with the high bit always set must not loop
|
||||
// forever or overflow; it should error.
|
||||
bad := [u8(0x1f), 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]
|
||||
mut r := H2HpackReader{
|
||||
buf: bad
|
||||
}
|
||||
r.read_int(5) or { return }
|
||||
assert false, 'expected integer overflow error'
|
||||
}
|
||||
|
||||
// --- Huffman coding (RFC 7541 Section 5.2) ---
|
||||
|
||||
fn test_huffman_roundtrip() {
|
||||
samples := ['', 'www.example.com', 'no-cache', 'custom-value', '/sample/path',
|
||||
'Mon, 21 Oct 2013 20:13:21 GMT', 'https://www.example.com', 'private']
|
||||
for s in samples {
|
||||
enc := h2_huffman_encode(s.bytes())
|
||||
dec := h2_huffman_decode(enc)!
|
||||
assert dec.bytestr() == s, 'huffman roundtrip failed for "${s}"'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_huffman_known_vector() {
|
||||
// "www.example.com" Huffman-encoded, from RFC 7541 C.4.1.
|
||||
enc := hexb('f1e3c2e5f23a6ba0ab90f4ff')
|
||||
dec := h2_huffman_decode(enc)!
|
||||
assert dec.bytestr() == 'www.example.com'
|
||||
}
|
||||
|
||||
fn test_huffman_codes_rebuilt_from_lengths() {
|
||||
// The HPACK table now ships only the bit lengths; the canonical codes are
|
||||
// rebuilt at startup via hash.huffman. Pin a few known codes from RFC 7541
|
||||
// Appendix B so a bad rebuild (or a future builder change) is caught.
|
||||
assert h2_huffman_table.codes.len == 257
|
||||
assert h2_huffman_table.lengths.len == 257
|
||||
// symbol 0 (NUL): 0x1ff8 / 13 bits
|
||||
assert h2_huffman_table.codes[0] == 0x1ff8
|
||||
assert h2_huffman_table.lengths[0] == 13
|
||||
// '0' (0x30): 0x0 / 5 bits, '1': 0x1 / 5 bits, 'a' (0x61): 0x3 / 5 bits
|
||||
assert h2_huffman_table.codes[0x30] == 0x0
|
||||
assert h2_huffman_table.lengths[0x30] == 5
|
||||
assert h2_huffman_table.codes[0x31] == 0x1
|
||||
assert h2_huffman_table.codes[0x61] == 0x3
|
||||
// EOS (256): 0x3fffffff / 30 bits
|
||||
assert h2_huffman_table.codes[256] == 0x3fffffff
|
||||
assert h2_huffman_table.lengths[256] == 30
|
||||
}
|
||||
|
||||
fn test_huffman_rejects_padding_not_all_ones() {
|
||||
// Valid encoding of "0" is 5 bits (00000); pad the rest of the byte with
|
||||
// zeros instead of ones -> invalid per RFC 7541 Section 5.2.
|
||||
bad := [u8(0x00)] // '0' code is 00000, then 000 padding (not all ones)
|
||||
h2_huffman_decode(bad) or { return }
|
||||
assert false, 'expected invalid huffman padding error'
|
||||
}
|
||||
|
||||
// --- C.2: Header field representations ---
|
||||
|
||||
fn test_hpack_c_2_1_literal_incremental() {
|
||||
mut d := H2HpackDecoder{}
|
||||
fields := d.decode(hexb('400a 6375 7374 6f6d 2d6b 6579 0d63 7573 746f 6d2d 6865 6164 6572'))!
|
||||
assert_fields(fields, [['custom-key', 'custom-header']])
|
||||
// Added to the dynamic table: size = 10 + 13 + 32 = 55.
|
||||
assert d.dyn_table.entries.len == 1
|
||||
assert d.dyn_table.cur_size == 55
|
||||
assert d.dyn_table.entries[0].name == 'custom-key'
|
||||
}
|
||||
|
||||
fn test_hpack_c_2_2_literal_without_indexing() {
|
||||
mut d := H2HpackDecoder{}
|
||||
fields := d.decode(hexb('040c 2f73 616d 706c 652f 7061 7468'))!
|
||||
assert_fields(fields, [[':path', '/sample/path']])
|
||||
assert d.dyn_table.entries.len == 0
|
||||
}
|
||||
|
||||
fn test_hpack_c_2_3_never_indexed() {
|
||||
mut d := H2HpackDecoder{}
|
||||
fields := d.decode(hexb('1008 7061 7373 776f 7264 0673 6563 7265 74'))!
|
||||
assert_fields(fields, [['password', 'secret']])
|
||||
assert d.dyn_table.entries.len == 0
|
||||
}
|
||||
|
||||
fn test_hpack_c_2_4_indexed() {
|
||||
mut d := H2HpackDecoder{}
|
||||
fields := d.decode(hexb('82'))!
|
||||
assert_fields(fields, [[':method', 'GET']])
|
||||
}
|
||||
|
||||
// --- C.3: Request sequence without Huffman, shared decoder ---
|
||||
|
||||
fn test_hpack_c_3_request_sequence() {
|
||||
mut d := H2HpackDecoder{}
|
||||
|
||||
f1 := d.decode(hexb('8286 8441 0f77 7777 2e65 7861 6d70 6c65 2e63 6f6d'))!
|
||||
assert_fields(f1, [[':method', 'GET'], [':scheme', 'http'],
|
||||
[':path', '/'], [':authority', 'www.example.com']])
|
||||
assert d.dyn_table.cur_size == 57
|
||||
|
||||
f2 := d.decode(hexb('8286 84be 5808 6e6f 2d63 6163 6865'))!
|
||||
assert_fields(f2, [[':method', 'GET'], [':scheme', 'http'],
|
||||
[':path', '/'], [':authority', 'www.example.com'], ['cache-control', 'no-cache']])
|
||||
|
||||
f3 :=
|
||||
d.decode(hexb('8287 85bf 400a 6375 7374 6f6d 2d6b 6579 0c63 7573 746f 6d2d 7661 6c75 65'))!
|
||||
assert_fields(f3, [[':method', 'GET'], [':scheme', 'https'],
|
||||
[':path', '/index.html'], [':authority', 'www.example.com'],
|
||||
['custom-key', 'custom-value']])
|
||||
}
|
||||
|
||||
// --- C.4: Request sequence with Huffman, shared decoder ---
|
||||
|
||||
fn test_hpack_c_4_request_sequence_huffman() {
|
||||
mut d := H2HpackDecoder{}
|
||||
|
||||
f1 := d.decode(hexb('8286 8441 8cf1 e3c2 e5f2 3a6b a0ab 90f4 ff'))!
|
||||
assert_fields(f1, [[':method', 'GET'], [':scheme', 'http'],
|
||||
[':path', '/'], [':authority', 'www.example.com']])
|
||||
|
||||
f2 := d.decode(hexb('8286 84be 5886 a8eb 1064 9cbf'))!
|
||||
assert_fields(f2, [[':method', 'GET'], [':scheme', 'http'],
|
||||
[':path', '/'], [':authority', 'www.example.com'], ['cache-control', 'no-cache']])
|
||||
|
||||
f3 := d.decode(hexb('8287 85bf 4088 25a8 49e9 5ba9 7d7f 8925 a849 e95b b8e8 b4bf'))!
|
||||
assert_fields(f3, [[':method', 'GET'], [':scheme', 'https'],
|
||||
[':path', '/index.html'], [':authority', 'www.example.com'],
|
||||
['custom-key', 'custom-value']])
|
||||
}
|
||||
|
||||
// --- Dynamic table eviction (RFC 7541 Sections 4.3, 4.4) ---
|
||||
|
||||
fn test_dyn_table_eviction_on_add() {
|
||||
// Mirrors python-hpack's eviction test: a 66-byte table holds only one of
|
||||
// these two entries at a time.
|
||||
mut t := H2DynTable{
|
||||
max_size: 66
|
||||
}
|
||||
t.add('a', 'b') // size = 1 + 1 + 32 = 34
|
||||
assert t.entries.len == 1
|
||||
assert t.cur_size == 34
|
||||
t.add('long-custom-header', 'longish value') // size = 18 + 13 + 32 = 63
|
||||
assert t.entries.len == 1
|
||||
assert t.entries[0].name == 'long-custom-header'
|
||||
assert t.cur_size == 63
|
||||
}
|
||||
|
||||
fn test_dyn_table_oversized_entry_empties_table() {
|
||||
mut t := H2DynTable{
|
||||
max_size: 64
|
||||
}
|
||||
t.add('a', 'b')
|
||||
assert t.entries.len == 1
|
||||
// An entry larger than the whole table empties it and is not added.
|
||||
t.add('x'.repeat(100), '')
|
||||
assert t.entries.len == 0
|
||||
assert t.cur_size == 0
|
||||
}
|
||||
|
||||
fn test_dyn_table_resize_evicts() {
|
||||
mut t := H2DynTable{}
|
||||
t.add('a', 'b')
|
||||
t.add('c', 'd')
|
||||
assert t.entries.len == 2
|
||||
t.set_max_size(34) // room for exactly one 34-byte entry (the newest)
|
||||
assert t.entries.len == 1
|
||||
assert t.entries[0].name == 'c'
|
||||
t.set_max_size(0)
|
||||
assert t.entries.len == 0
|
||||
assert t.cur_size == 0
|
||||
}
|
||||
|
||||
// A "size update then re-add" sequence exercised through the decoder: an
|
||||
// indexed reference to an evicted entry must fail.
|
||||
fn test_decoder_dynamic_indexing_and_eviction() {
|
||||
mut d := H2HpackDecoder{}
|
||||
// Literal incremental indexing of custom-key: custom-header (size 55).
|
||||
_ := d.decode(hexb('400a 6375 7374 6f6d 2d6b 6579 0d63 7573 746f 6d2d 6865 6164 6572'))!
|
||||
assert d.dyn_table.entries.len == 1
|
||||
// Index 62 now refers to that entry.
|
||||
f := d.decode([u8(0xbe)])!
|
||||
assert_fields(f, [['custom-key', 'custom-header']])
|
||||
// Shrinking the table to 0 evicts it; index 62 is then out of range.
|
||||
d.decode([u8(0x20)])! // dynamic table size update to 0
|
||||
assert d.dyn_table.entries.len == 0
|
||||
d.decode([u8(0xbe)]) or { return }
|
||||
assert false, 'expected out-of-range error after eviction'
|
||||
}
|
||||
|
||||
// --- Encoder + round-trip ---
|
||||
|
||||
fn test_hpack_encode_indexed_static() {
|
||||
mut e := H2HpackEncoder{}
|
||||
// :method GET is static index 2 -> single byte 0x82.
|
||||
out := e.encode([H2HeaderField{':method', 'GET'}])
|
||||
assert out == [u8(0x82)]
|
||||
}
|
||||
|
||||
fn test_hpack_roundtrip() {
|
||||
fields := [
|
||||
H2HeaderField{':method', 'GET'},
|
||||
H2HeaderField{':scheme', 'https'},
|
||||
H2HeaderField{':authority', 'example.com'},
|
||||
H2HeaderField{':path', '/index.html'},
|
||||
H2HeaderField{'user-agent', 'v.http/0.1'},
|
||||
H2HeaderField{'accept', '*/*'},
|
||||
H2HeaderField{'cookie', 'session=abc123'},
|
||||
]
|
||||
mut e := H2HpackEncoder{}
|
||||
mut d := H2HpackDecoder{}
|
||||
encoded := e.encode(fields)
|
||||
decoded := d.decode(encoded)!
|
||||
assert_fields(decoded, [[':method', 'GET'], [':scheme', 'https'],
|
||||
[':authority', 'example.com'], [':path', '/index.html'],
|
||||
['user-agent', 'v.http/0.1'], ['accept', '*/*'], ['cookie', 'session=abc123']])
|
||||
}
|
||||
|
||||
// --- Decoder error handling ---
|
||||
|
||||
fn test_hpack_rejects_zero_index() {
|
||||
mut d := H2HpackDecoder{}
|
||||
d.decode([u8(0x80)]) or { return } // indexed header field, index 0
|
||||
assert false, 'expected error for index 0'
|
||||
}
|
||||
|
||||
fn test_hpack_rejects_out_of_range_index() {
|
||||
mut d := H2HpackDecoder{}
|
||||
d.decode([u8(0xff), 0x00]) or { return } // index 62, dynamic table empty
|
||||
assert false, 'expected error for out-of-range index'
|
||||
}
|
||||
|
||||
fn test_hpack_rejects_size_update_after_field() {
|
||||
mut d := H2HpackDecoder{}
|
||||
// Indexed field (0x82) followed by a dynamic table size update (0x20).
|
||||
d.decode([u8(0x82), 0x20]) or { return }
|
||||
assert false, 'expected error for size update after field'
|
||||
}
|
||||
|
||||
fn test_hpack_rejects_size_update_over_limit() {
|
||||
mut d := H2HpackDecoder{}
|
||||
d.set_max_dynamic_size(4096)
|
||||
// 0x3f e0 0f = dynamic table size update to 4096+... well over 4096.
|
||||
// Dynamic table size update to 8192 (> 4096 limit).
|
||||
d.decode([u8(0x3f), 0xe1, 0x3f]) or { return }
|
||||
assert false, 'expected error for size update over limit'
|
||||
}
|
||||
|
||||
fn test_hpack_rejects_truncating_index() {
|
||||
mut d := H2HpackDecoder{}
|
||||
// Insert one dynamic entry, so dynamic index 1 (HPACK index 62) is valid.
|
||||
_ := d.decode(hexb('400a 6375 7374 6f6d 2d6b 6579 0d63 7573 746f 6d2d 6865 6164 6572'))!
|
||||
// Indexed representation with idx = 2^32 + 62: it truncates to 62 (a valid
|
||||
// dynamic index) when narrowed to a 32-bit int, but must be rejected.
|
||||
mut block := []u8{}
|
||||
h2_hpack_write_int(mut block, u64(0x1_0000_0000) + 62, 7, 0x80)
|
||||
d.decode(block) or { return }
|
||||
assert false, 'expected out-of-range error for truncating index'
|
||||
}
|
||||
|
||||
fn test_hpack_rejects_truncating_string_length() {
|
||||
mut d := H2HpackDecoder{}
|
||||
mut block := []u8{}
|
||||
block << 0x00 // literal without indexing, name index 0
|
||||
block << 0x00 // empty name (H=0, length 0)
|
||||
// Value string length = 2^32 + 5, which truncates to 5 in a 32-bit int.
|
||||
h2_hpack_write_int(mut block, u64(0x1_0000_0000) + 5, 7, 0x00)
|
||||
// No value bytes follow; the oversized length must be rejected cleanly.
|
||||
d.decode(block) or { return }
|
||||
assert false, 'expected length-exceeds-buffer error for truncating string length'
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// This file implements a minimal server-side HTTP/2 connection driver on top
|
||||
// of the existing framing (h2_frame.v) and HPACK (h2_hpack.v) layers. It is
|
||||
// intentionally serial: SETTINGS_MAX_CONCURRENT_STREAMS is advertised as 1,
|
||||
// so the peer sends one request at a time. Concurrent stream handling and
|
||||
// background writers are a planned follow-up.
|
||||
|
||||
// h2_server_max_concurrent_streams is the value advertised in our SETTINGS;
|
||||
// the peer may not open more streams than this at once.
|
||||
const h2_server_max_concurrent_streams = u32(1)
|
||||
|
||||
// h2_server_default_window is the default initial flow-control window
|
||||
// (RFC 7540 Section 6.9.2).
|
||||
const h2_server_default_window = u32(65535)
|
||||
|
||||
// h2_server_max_request_body caps the in-memory request body the server will
|
||||
// accept before responding with a stream error. Bodies larger than this are
|
||||
// rejected with RST_STREAM(REFUSED_STREAM).
|
||||
const h2_server_max_request_body = 8 * 1024 * 1024
|
||||
|
||||
// H2ServerStream holds the state of one in-flight server-side stream.
|
||||
struct H2ServerStream {
|
||||
mut:
|
||||
id u32
|
||||
hpack_block []u8 // assembled HEADERS + CONTINUATION fragments
|
||||
headers []H2HeaderField
|
||||
body []u8
|
||||
end_headers bool
|
||||
end_stream bool
|
||||
headers_done bool // set once we've decoded the HPACK block
|
||||
send_window i64
|
||||
}
|
||||
|
||||
// H2ServerConn drives one server-side HTTP/2 connection over a transport.
|
||||
struct H2ServerConn {
|
||||
mut:
|
||||
transport H2Transport
|
||||
encoder H2HpackEncoder
|
||||
decoder H2HpackDecoder
|
||||
peer H2PeerSettings
|
||||
rbuf []u8
|
||||
send_window i64 = i64(h2_server_default_window)
|
||||
streams map[u32]&H2ServerStream
|
||||
last_stream_id u32
|
||||
awaiting_cont u32 // non-zero when mid-CONTINUATION on this stream
|
||||
closing bool
|
||||
idle_conns &TlsIdleConnTracker = unsafe { nil }
|
||||
idle_handle int
|
||||
}
|
||||
|
||||
// serve_h2_conn drives a single HTTP/2 server-side connection until the
|
||||
// transport closes or a protocol error forces a GOAWAY. `handler` is invoked
|
||||
// once per fully-received request stream.
|
||||
fn serve_h2_conn(mut transport H2Transport, mut handler Handler) ! {
|
||||
serve_h2_conn_with_idle_tracker(mut transport, mut handler, unsafe { nil }, 0)!
|
||||
}
|
||||
|
||||
fn serve_h2_conn_with_idle_tracker(mut transport H2Transport, mut handler Handler, idle_conns &TlsIdleConnTracker, idle_handle int) ! {
|
||||
mut c := &H2ServerConn{
|
||||
transport: transport
|
||||
idle_conns: idle_conns
|
||||
idle_handle: idle_handle
|
||||
}
|
||||
c.serve(mut handler) or {
|
||||
// Best-effort GOAWAY before bailing. Skip if one was already sent by
|
||||
// the error path (e.g. apply_settings sends FLOW_CONTROL_ERROR).
|
||||
if !c.closing {
|
||||
c.send_goaway(.protocol_error, err.msg()) or {}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) serve(mut handler Handler) ! {
|
||||
// Register the connection with the idle tracker once for its whole
|
||||
// lifetime, instead of around every frame read. The reader thread spends
|
||||
// nearly all of its time blocked in a frame read, so per-frame mark/unmark
|
||||
// only added shared-lock contention (and an O(n) list scan) on the hot
|
||||
// path. On shutdown, close_idle still interrupts the reader by shutting the
|
||||
// fd down; an h2 request in flight when the server stops is interrupted —
|
||||
// including response writes, which may be truncated mid-DATA-frame. This
|
||||
// is acceptable at shutdown and is not relied on by any caller (the
|
||||
// graceful "wait for active request" guarantee is HTTP/1.1-only).
|
||||
tracked := c.should_track_idle_read()
|
||||
if tracked && !c.idle_conns.mark_idle(c.idle_handle) {
|
||||
// The server is already shutting down; do not start serving.
|
||||
return
|
||||
}
|
||||
defer {
|
||||
if tracked {
|
||||
c.idle_conns.unmark_idle(c.idle_handle)
|
||||
}
|
||||
}
|
||||
c.read_client_preface()!
|
||||
c.send_initial_settings()!
|
||||
for !c.closing {
|
||||
frame := c.read_frame() or {
|
||||
// Treat a clean transport close as end of session.
|
||||
return
|
||||
}
|
||||
c.dispatch_frame(frame, mut handler)!
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) should_track_idle_read() bool {
|
||||
return c.idle_handle > 0 && c.idle_conns != unsafe { nil }
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) read_client_preface() ! {
|
||||
c.fill_at_least(h2_client_preface.len)!
|
||||
got := c.rbuf[..h2_client_preface.len].bytestr()
|
||||
if got != h2_client_preface {
|
||||
return error('h2 server: invalid connection preface')
|
||||
}
|
||||
c.rbuf = c.rbuf[h2_client_preface.len..].clone()
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) send_initial_settings() ! {
|
||||
c.send_frame(H2SettingsFrame{
|
||||
settings: [
|
||||
H2Setting{h2_settings_enable_push, 0},
|
||||
H2Setting{h2_settings_max_concurrent_streams, h2_server_max_concurrent_streams},
|
||||
H2Setting{h2_settings_initial_window_size, h2_server_default_window},
|
||||
H2Setting{h2_settings_max_frame_size, h2_default_max_frame_size},
|
||||
]
|
||||
})!
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) dispatch_frame(frame H2Frame, mut handler Handler) ! {
|
||||
// RFC 7540 §6.10: once a HEADERS or PUSH_PROMISE without END_HEADERS is
|
||||
// seen, the next frame must be a CONTINUATION on the same stream.
|
||||
if c.awaiting_cont != 0 {
|
||||
if frame is H2ContinuationFrame {
|
||||
if frame.stream_id != c.awaiting_cont {
|
||||
return error('h2 server: CONTINUATION on the wrong stream')
|
||||
}
|
||||
} else {
|
||||
return error('h2 server: expected CONTINUATION after HEADERS without END_HEADERS')
|
||||
}
|
||||
}
|
||||
match frame {
|
||||
H2SettingsFrame, H2PingFrame, H2WindowUpdateFrame {
|
||||
c.handle_control_frame(frame)!
|
||||
}
|
||||
H2GoawayFrame {
|
||||
c.closing = true
|
||||
}
|
||||
H2RstStreamFrame {
|
||||
c.streams.delete(frame.stream_id)
|
||||
}
|
||||
H2PriorityFrame {
|
||||
// Priority is advisory; ignore.
|
||||
}
|
||||
H2HeadersFrame {
|
||||
c.on_headers(frame, mut handler)!
|
||||
}
|
||||
H2ContinuationFrame {
|
||||
c.on_continuation(frame, mut handler)!
|
||||
}
|
||||
H2DataFrame {
|
||||
c.on_data(frame, mut handler)!
|
||||
}
|
||||
H2PushPromiseFrame {
|
||||
// Clients should not push. Treat as a protocol error.
|
||||
return error('h2 server: PUSH_PROMISE from client')
|
||||
}
|
||||
H2UnknownFrame {
|
||||
// RFC 7540 §4.1: ignore unknown frame types.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle_control_frame services SETTINGS, PING, and WINDOW_UPDATE frames that
|
||||
// may arrive at any point in the session, including while a response write is
|
||||
// blocked in send_body waiting for flow-control credit. Both dispatch_frame and
|
||||
// pump_for_window delegate to this function so the logic — and any validation
|
||||
// errors — exist in exactly one place.
|
||||
fn (mut c H2ServerConn) handle_control_frame(frame H2Frame) ! {
|
||||
match frame {
|
||||
H2SettingsFrame {
|
||||
if !frame.ack {
|
||||
c.apply_settings(frame.settings)!
|
||||
c.send_frame(H2SettingsFrame{
|
||||
ack: true
|
||||
})!
|
||||
}
|
||||
}
|
||||
H2PingFrame {
|
||||
if !frame.ack {
|
||||
c.send_frame(H2PingFrame{
|
||||
ack: true
|
||||
data: frame.data
|
||||
})!
|
||||
}
|
||||
}
|
||||
H2WindowUpdateFrame {
|
||||
if frame.stream_id == 0 {
|
||||
c.send_window += i64(frame.window_size_increment)
|
||||
} else if mut s := c.streams[frame.stream_id] {
|
||||
s.send_window += i64(frame.window_size_increment)
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) apply_settings(settings []H2Setting) ! {
|
||||
for s in settings {
|
||||
match s.id {
|
||||
h2_settings_header_table_size {
|
||||
c.peer.header_table_size = s.value
|
||||
}
|
||||
h2_settings_enable_push {
|
||||
c.peer.enable_push = s.value != 0
|
||||
}
|
||||
h2_settings_max_concurrent_streams {
|
||||
c.peer.max_concurrent_streams = s.value
|
||||
}
|
||||
h2_settings_initial_window_size {
|
||||
// RFC 7540 §6.5.3: values above 2^31-1 are a FLOW_CONTROL_ERROR,
|
||||
// not a PROTOCOL_ERROR; send the correct code before unwinding.
|
||||
if s.value > u32(0x7fff_ffff) {
|
||||
c.send_goaway(.flow_control_error,
|
||||
'SETTINGS_INITIAL_WINDOW_SIZE ${s.value} exceeds 2^31-1') or {}
|
||||
return error('h2 server: SETTINGS_INITIAL_WINDOW_SIZE ${s.value} exceeds 2^31-1 (FLOW_CONTROL_ERROR)')
|
||||
}
|
||||
// RFC 7540 §6.9.2: a change to the initial window size adjusts
|
||||
// the send window of every active stream by the delta.
|
||||
delta := i64(s.value) - i64(c.peer.initial_window_size)
|
||||
c.peer.initial_window_size = s.value
|
||||
for _, mut st in c.streams {
|
||||
st.send_window += delta
|
||||
}
|
||||
}
|
||||
h2_settings_max_frame_size {
|
||||
// RFC 7540 §6.5.2: valid range is 2^14 (16384)..2^24-1 inclusive.
|
||||
if s.value < h2_default_max_frame_size || s.value > h2_max_max_frame_size {
|
||||
return error('h2 server: SETTINGS_MAX_FRAME_SIZE ${s.value} out of range [16384, 16777215]')
|
||||
}
|
||||
c.peer.max_frame_size = s.value
|
||||
}
|
||||
h2_settings_max_header_list_size {
|
||||
c.peer.max_header_list_size = s.value
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) on_headers(frame H2HeadersFrame, mut handler Handler) ! {
|
||||
// Stream ids from the client must be odd and strictly increasing.
|
||||
if frame.stream_id & 1 == 0 || frame.stream_id <= c.last_stream_id {
|
||||
return error('h2 server: invalid client stream id ${frame.stream_id}')
|
||||
}
|
||||
c.last_stream_id = frame.stream_id
|
||||
mut s := &H2ServerStream{
|
||||
id: frame.stream_id
|
||||
hpack_block: frame.fragment.clone()
|
||||
end_headers: frame.end_headers
|
||||
end_stream: frame.end_stream
|
||||
send_window: i64(c.peer.initial_window_size)
|
||||
}
|
||||
c.streams[frame.stream_id] = s
|
||||
if !frame.end_headers {
|
||||
c.awaiting_cont = frame.stream_id
|
||||
return
|
||||
}
|
||||
c.finalize_headers(mut s, mut handler)!
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) on_continuation(frame H2ContinuationFrame, mut handler Handler) ! {
|
||||
mut s := c.streams[frame.stream_id] or {
|
||||
return error('h2 server: CONTINUATION for unknown stream ${frame.stream_id}')
|
||||
}
|
||||
s.hpack_block << frame.fragment
|
||||
if frame.end_headers {
|
||||
s.end_headers = true
|
||||
c.awaiting_cont = 0
|
||||
c.finalize_headers(mut s, mut handler)!
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) finalize_headers(mut s H2ServerStream, mut handler Handler) ! {
|
||||
s.headers = c.decoder.decode(s.hpack_block) or {
|
||||
c.send_rst_stream(s.id, .compression_error)!
|
||||
c.streams.delete(s.id)
|
||||
return
|
||||
}
|
||||
s.headers_done = true
|
||||
if s.end_stream {
|
||||
c.run_request(mut s, mut handler)!
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) on_data(frame H2DataFrame, mut handler Handler) ! {
|
||||
mut s := c.streams[frame.stream_id] or {
|
||||
// DATA for an unknown stream (likely already RST'd); just drop and
|
||||
// keep flow control consistent.
|
||||
if frame.data.len > 0 {
|
||||
c.send_window_update(0, u32(frame.data.len))!
|
||||
}
|
||||
return
|
||||
}
|
||||
if !s.headers_done {
|
||||
return error('h2 server: DATA before END_HEADERS')
|
||||
}
|
||||
if frame.data.len > 0 {
|
||||
if s.body.len + frame.data.len > h2_server_max_request_body {
|
||||
c.send_rst_stream(s.id, .refused_stream)!
|
||||
c.streams.delete(s.id)
|
||||
return
|
||||
}
|
||||
s.body << frame.data
|
||||
// Replenish the connection window; per-stream we replenish on
|
||||
// completion since we hold the body in memory.
|
||||
c.send_window_update(0, u32(frame.data.len))!
|
||||
c.send_window_update(s.id, u32(frame.data.len))!
|
||||
}
|
||||
if frame.end_stream {
|
||||
s.end_stream = true
|
||||
c.run_request(mut s, mut handler)!
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) run_request(mut s H2ServerStream, mut handler Handler) ! {
|
||||
req := c.build_request(s) or {
|
||||
c.send_rst_stream(s.id, .protocol_error)!
|
||||
c.streams.delete(s.id)
|
||||
return
|
||||
}
|
||||
resp := handler.handle(req)
|
||||
c.send_response(s.id, resp)!
|
||||
c.streams.delete(s.id)
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) build_request(s &H2ServerStream) !Request {
|
||||
mut req := Request{
|
||||
version: .v2_0
|
||||
header: new_header()
|
||||
}
|
||||
mut method := ''
|
||||
mut path := ''
|
||||
mut authority := ''
|
||||
mut scheme := 'https'
|
||||
for f in s.headers {
|
||||
match f.name {
|
||||
':method' {
|
||||
method = f.value
|
||||
}
|
||||
':path' {
|
||||
path = f.value
|
||||
}
|
||||
':authority' {
|
||||
authority = f.value
|
||||
}
|
||||
':scheme' {
|
||||
scheme = f.value
|
||||
}
|
||||
else {
|
||||
if f.name.starts_with(':') {
|
||||
return error('h2 server: unknown pseudo-header ${f.name}')
|
||||
}
|
||||
req.header.add_custom(f.name, f.value) or {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if method == '' || path == '' {
|
||||
return error('h2 server: missing :method or :path')
|
||||
}
|
||||
req.method = method_from_str(method)
|
||||
if authority != '' && !req.header.contains(.host) {
|
||||
req.header.add(.host, authority)
|
||||
}
|
||||
// Match the HTTP/1.1 path: req.url is the request-target (the :path
|
||||
// pseudo-header), so handlers see the same shape on both transports.
|
||||
_ = scheme // :scheme is parsed and discarded; handlers infer it from Host
|
||||
req.url = path
|
||||
req.data = s.body.bytestr()
|
||||
req.host = authority
|
||||
return req
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) send_response(stream_id u32, resp Response) ! {
|
||||
status := if resp.status_code == 0 { 200 } else { resp.status_code }
|
||||
mut fields := [H2HeaderField{':status', status.str()}]
|
||||
for key in resp.header.keys() {
|
||||
lkey := key.to_lower()
|
||||
// Drop hop-by-hop headers; HTTP/2 forbids them (RFC 7540 §8.1.2.2).
|
||||
if lkey in ['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'proxy-connection'] {
|
||||
continue
|
||||
}
|
||||
for val in resp.header.custom_values(key) {
|
||||
fields << H2HeaderField{lkey, val}
|
||||
}
|
||||
}
|
||||
body := resp.body.bytes()
|
||||
has_body := body.len > 0
|
||||
block := c.encoder.encode(fields)
|
||||
c.send_header_block(stream_id, block, !has_body)!
|
||||
if has_body {
|
||||
c.send_body(stream_id, body)!
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) send_header_block(stream_id u32, block []u8, end_stream bool) ! {
|
||||
max := int(c.peer.max_frame_size)
|
||||
if block.len <= max {
|
||||
c.send_frame(H2HeadersFrame{
|
||||
stream_id: stream_id
|
||||
fragment: block
|
||||
end_headers: true
|
||||
end_stream: end_stream
|
||||
})!
|
||||
return
|
||||
}
|
||||
c.send_frame(H2HeadersFrame{
|
||||
stream_id: stream_id
|
||||
fragment: block[..max]
|
||||
end_headers: false
|
||||
end_stream: end_stream
|
||||
})!
|
||||
mut off := max
|
||||
for off < block.len {
|
||||
mut next := off + max
|
||||
if next > block.len {
|
||||
next = block.len
|
||||
}
|
||||
c.send_frame(H2ContinuationFrame{
|
||||
stream_id: stream_id
|
||||
fragment: block[off..next]
|
||||
end_headers: next == block.len
|
||||
})!
|
||||
off = next
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) send_body(stream_id u32, body []u8) ! {
|
||||
max := int(c.peer.max_frame_size)
|
||||
mut off := 0
|
||||
for off < body.len {
|
||||
// Respect both the connection and per-stream send windows
|
||||
// (RFC 7540 Section 6.9). When either is exhausted, read frames until
|
||||
// the peer grows a window with WINDOW_UPDATE.
|
||||
for c.send_window <= 0 || c.stream_send_window(stream_id) <= 0 {
|
||||
c.pump_for_window(stream_id)!
|
||||
}
|
||||
avail := if c.send_window < c.stream_send_window(stream_id) {
|
||||
c.send_window
|
||||
} else {
|
||||
c.stream_send_window(stream_id)
|
||||
}
|
||||
mut chunk := body.len - off
|
||||
if chunk > max {
|
||||
chunk = max
|
||||
}
|
||||
if i64(chunk) > avail {
|
||||
chunk = int(avail)
|
||||
}
|
||||
next := off + chunk
|
||||
c.send_frame(H2DataFrame{
|
||||
stream_id: stream_id
|
||||
data: body[off..next]
|
||||
end_stream: next == body.len
|
||||
})!
|
||||
c.send_window -= i64(chunk)
|
||||
if mut s := c.streams[stream_id] {
|
||||
s.send_window -= i64(chunk)
|
||||
}
|
||||
off = next
|
||||
}
|
||||
}
|
||||
|
||||
// stream_send_window returns the current per-stream send window, or 0 if the
|
||||
// stream is gone.
|
||||
fn (c &H2ServerConn) stream_send_window(stream_id u32) i64 {
|
||||
if s := c.streams[stream_id] {
|
||||
return s.send_window
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// pump_for_window reads one frame while a response is blocked on flow control,
|
||||
// servicing control frames (SETTINGS / PING / WINDOW_UPDATE) via
|
||||
// handle_control_frame and aborting on RST_STREAM for the active stream.
|
||||
fn (mut c H2ServerConn) pump_for_window(stream_id u32) ! {
|
||||
frame := c.read_frame()!
|
||||
c.handle_control_frame(frame)!
|
||||
if frame is H2RstStreamFrame {
|
||||
if frame.stream_id == stream_id {
|
||||
return error('h2 server: stream reset by peer while writing response')
|
||||
}
|
||||
}
|
||||
// With SETTINGS_MAX_CONCURRENT_STREAMS=1 no other stream frames are
|
||||
// expected mid-response; ignore anything else defensively.
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) send_window_update(stream_id u32, inc u32) ! {
|
||||
if inc == 0 {
|
||||
return
|
||||
}
|
||||
c.send_frame(H2WindowUpdateFrame{
|
||||
stream_id: stream_id
|
||||
window_size_increment: inc
|
||||
})!
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) send_rst_stream(stream_id u32, code H2ErrorCode) ! {
|
||||
c.send_frame(H2RstStreamFrame{
|
||||
stream_id: stream_id
|
||||
error_code: u32(code)
|
||||
})!
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) send_goaway(code H2ErrorCode, msg string) ! {
|
||||
c.send_frame(H2GoawayFrame{
|
||||
last_stream_id: c.last_stream_id
|
||||
error_code: u32(code)
|
||||
debug_data: msg.bytes()
|
||||
})!
|
||||
c.closing = true
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) read_frame() !H2Frame {
|
||||
c.fill_at_least(h2_frame_header_len)!
|
||||
header := h2_parse_frame_header(c.rbuf)!
|
||||
if header.length > h2_default_max_frame_size {
|
||||
return error('h2 server: frame larger than SETTINGS_MAX_FRAME_SIZE (${header.length})')
|
||||
}
|
||||
total := h2_frame_header_len + int(header.length)
|
||||
c.fill_at_least(total)!
|
||||
frame := h2_parse_frame(header, c.rbuf[h2_frame_header_len..total])!
|
||||
c.rbuf = c.rbuf[total..].clone()
|
||||
return frame
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) fill_at_least(n int) ! {
|
||||
for c.rbuf.len < n {
|
||||
mut tmp := []u8{len: h2_conn_read_chunk}
|
||||
got := c.transport.read(mut tmp)!
|
||||
if got <= 0 {
|
||||
return error('h2 server: connection closed by peer')
|
||||
}
|
||||
c.rbuf << tmp[..got]
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) send_frame(f H2Frame) ! {
|
||||
c.write_all(f.encode())!
|
||||
}
|
||||
|
||||
fn (mut c H2ServerConn) write_all(data []u8) ! {
|
||||
mut sent := 0
|
||||
for sent < data.len {
|
||||
n := c.transport.write(data[sent..])!
|
||||
if n <= 0 {
|
||||
return error('h2 server: transport write returned ${n}')
|
||||
}
|
||||
sent += n
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Hermetic round-trip test for the server-side HTTP/2 driver. Uses an
|
||||
// in-memory blocking ReadWriter pair so client and server share the same
|
||||
// address space and no socket is required.
|
||||
module http
|
||||
|
||||
import sync
|
||||
import time
|
||||
|
||||
// PipeBuf is a one-way in-memory FIFO between a writer and a reader.
|
||||
// `read` blocks (poll-with-sleep) until data is available, mirroring socket
|
||||
// semantics so the h2 client and server can drive each other to completion.
|
||||
struct PipeBuf {
|
||||
mut:
|
||||
mu &sync.Mutex = sync.new_mutex()
|
||||
data []u8
|
||||
closed bool
|
||||
}
|
||||
|
||||
fn (mut p PipeBuf) write(buf []u8) !int {
|
||||
p.mu.lock()
|
||||
defer {
|
||||
p.mu.unlock()
|
||||
}
|
||||
if p.closed {
|
||||
return error('pipe: write to closed pipe')
|
||||
}
|
||||
p.data << buf
|
||||
return buf.len
|
||||
}
|
||||
|
||||
fn (mut p PipeBuf) read(mut buf []u8) !int {
|
||||
for {
|
||||
p.mu.lock()
|
||||
if p.data.len > 0 {
|
||||
mut n := p.data.len
|
||||
if n > buf.len {
|
||||
n = buf.len
|
||||
}
|
||||
for i in 0 .. n {
|
||||
buf[i] = p.data[i]
|
||||
}
|
||||
p.data = p.data[n..].clone()
|
||||
p.mu.unlock()
|
||||
return n
|
||||
}
|
||||
if p.closed {
|
||||
p.mu.unlock()
|
||||
return error('eof')
|
||||
}
|
||||
p.mu.unlock()
|
||||
time.sleep(time.millisecond)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// PipeEnd is one half of a bidirectional pipe: reads come from `incoming`,
|
||||
// writes go to `outgoing`.
|
||||
struct PipeEnd {
|
||||
mut:
|
||||
incoming &PipeBuf
|
||||
outgoing &PipeBuf
|
||||
}
|
||||
|
||||
fn (mut p PipeEnd) read(mut buf []u8) !int {
|
||||
return p.incoming.read(mut buf)!
|
||||
}
|
||||
|
||||
fn (mut p PipeEnd) write(buf []u8) !int {
|
||||
return p.outgoing.write(buf)!
|
||||
}
|
||||
|
||||
fn new_pipe() (&PipeEnd, &PipeEnd) {
|
||||
mut a := &PipeBuf{}
|
||||
mut b := &PipeBuf{}
|
||||
client := &PipeEnd{
|
||||
incoming: b
|
||||
outgoing: a
|
||||
}
|
||||
server := &PipeEnd{
|
||||
incoming: a
|
||||
outgoing: b
|
||||
}
|
||||
return client, server
|
||||
}
|
||||
|
||||
struct ServerEchoHandler {
|
||||
mut:
|
||||
last_method Method
|
||||
last_url string
|
||||
last_body string
|
||||
}
|
||||
|
||||
fn (mut h ServerEchoHandler) handle(req Request) Response {
|
||||
h.last_method = req.method
|
||||
h.last_url = req.url
|
||||
h.last_body = req.data
|
||||
mut resp_header := new_header()
|
||||
resp_header.add_custom('content-type', 'text/plain') or {}
|
||||
// Echo the URL and body back so callers can verify end-to-end delivery
|
||||
// without depending on Handler-state mutation across goroutines.
|
||||
return Response{
|
||||
status_code: 200
|
||||
header: resp_header
|
||||
body: 'echo: ${req.url} body=${req.data}'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_h2_server_basic_request() {
|
||||
mut client_end, mut server_end := new_pipe()
|
||||
|
||||
// Spawn the server-side h2 driver against the server end of the pipe.
|
||||
mut handler := ServerEchoHandler{}
|
||||
mut handler_iface := Handler(handler)
|
||||
spawn fn [mut server_end, mut handler_iface] () {
|
||||
mut transport := H2Transport(server_end)
|
||||
serve_h2_conn(mut transport, mut handler_iface) or {}
|
||||
}()
|
||||
|
||||
// Drive a client-side request through the same pipe.
|
||||
mut conn := new_h2_conn(client_end)
|
||||
resp := conn.do(H2ClientRequest{
|
||||
method: 'GET'
|
||||
authority: 'example.com'
|
||||
path: '/hello'
|
||||
}) or {
|
||||
assert false, 'client do() failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status == 200
|
||||
assert resp.body.bytestr() == 'echo: /hello body='
|
||||
assert resp.headers.any(it.name == 'content-type' && it.value == 'text/plain')
|
||||
}
|
||||
|
||||
fn test_h2_server_post_with_body() {
|
||||
mut client_end, mut server_end := new_pipe()
|
||||
mut handler := ServerEchoHandler{}
|
||||
mut handler_iface := Handler(handler)
|
||||
spawn fn [mut server_end, mut handler_iface] () {
|
||||
mut transport := H2Transport(server_end)
|
||||
serve_h2_conn(mut transport, mut handler_iface) or {}
|
||||
}()
|
||||
|
||||
mut conn := new_h2_conn(client_end)
|
||||
resp := conn.do(H2ClientRequest{
|
||||
method: 'POST'
|
||||
authority: 'svc.example'
|
||||
path: '/upload'
|
||||
body: 'hello world'.bytes()
|
||||
}) or {
|
||||
assert false, 'client do() failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status == 200
|
||||
assert resp.body.bytestr() == 'echo: /upload body=hello world'
|
||||
}
|
||||
|
||||
struct StatusHandler {
|
||||
code int
|
||||
}
|
||||
|
||||
fn (mut h StatusHandler) handle(req Request) Response {
|
||||
return Response{
|
||||
status_code: h.code
|
||||
body: 'status ${h.code}'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_h2_server_non_200_status() {
|
||||
mut client_end, mut server_end := new_pipe()
|
||||
mut handler := StatusHandler{
|
||||
code: 404
|
||||
}
|
||||
mut handler_iface := Handler(handler)
|
||||
spawn fn [mut server_end, mut handler_iface] () {
|
||||
mut transport := H2Transport(server_end)
|
||||
serve_h2_conn(mut transport, mut handler_iface) or {}
|
||||
}()
|
||||
|
||||
mut conn := new_h2_conn(client_end)
|
||||
resp := conn.do(H2ClientRequest{ authority: 'h.example' }) or {
|
||||
assert false, 'client do() failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status == 404
|
||||
assert resp.body.bytestr() == 'status 404'
|
||||
}
|
||||
|
||||
struct BigBodyHandler {
|
||||
size int
|
||||
}
|
||||
|
||||
fn (mut h BigBodyHandler) handle(req Request) Response {
|
||||
return Response{
|
||||
status_code: 200
|
||||
body: 'x'.repeat(h.size)
|
||||
}
|
||||
}
|
||||
|
||||
// FrameReader reads HTTP/2 frames one at a time off a PipeEnd, buffering any
|
||||
// leftover bytes between frames.
|
||||
struct FrameReader {
|
||||
mut:
|
||||
end &PipeEnd
|
||||
buf []u8
|
||||
}
|
||||
|
||||
fn (mut r FrameReader) next() !H2Frame {
|
||||
for {
|
||||
if r.buf.len >= h2_frame_header_len {
|
||||
hdr := h2_parse_frame_header(r.buf)!
|
||||
total := h2_frame_header_len + int(hdr.length)
|
||||
if r.buf.len >= total {
|
||||
f := h2_parse_frame(hdr, r.buf[h2_frame_header_len..total])!
|
||||
r.buf = r.buf[total..].clone()
|
||||
return f
|
||||
}
|
||||
}
|
||||
mut tmp := []u8{len: 4096}
|
||||
n := r.end.read(mut tmp)!
|
||||
r.buf << tmp[..n]
|
||||
}
|
||||
return error('unreachable')
|
||||
}
|
||||
|
||||
fn test_h2_server_respects_send_window() {
|
||||
mut client_end, mut server_end := new_pipe()
|
||||
mut handler_iface := Handler(BigBodyHandler{
|
||||
size: 100
|
||||
})
|
||||
spawn fn [mut server_end, mut handler_iface] () {
|
||||
mut transport := H2Transport(server_end)
|
||||
serve_h2_conn(mut transport, mut handler_iface) or {}
|
||||
}()
|
||||
|
||||
// Raw client: preface + SETTINGS(initial_window_size = 10) + a GET, then a
|
||||
// WINDOW_UPDATE(stream 1, +1000). The server must send at most 10 body
|
||||
// bytes before consuming the WINDOW_UPDATE, then deliver the rest.
|
||||
mut enc := H2HpackEncoder{}
|
||||
block := enc.encode([
|
||||
H2HeaderField{':method', 'GET'},
|
||||
H2HeaderField{':scheme', 'https'},
|
||||
H2HeaderField{':authority', 'h.example'},
|
||||
H2HeaderField{':path', '/big'},
|
||||
])
|
||||
mut out := []u8{}
|
||||
out << h2_client_preface.bytes()
|
||||
out << H2Frame(H2SettingsFrame{
|
||||
settings: [H2Setting{h2_settings_initial_window_size, 10}]
|
||||
}).encode()
|
||||
out << H2Frame(H2HeadersFrame{
|
||||
stream_id: 1
|
||||
fragment: block
|
||||
end_headers: true
|
||||
end_stream: true
|
||||
}).encode()
|
||||
out << H2Frame(H2WindowUpdateFrame{
|
||||
stream_id: 1
|
||||
window_size_increment: 1000
|
||||
}).encode()
|
||||
client_end.write(out) or {
|
||||
assert false, 'client write failed: ${err}'
|
||||
return
|
||||
}
|
||||
|
||||
mut fr := FrameReader{
|
||||
end: client_end
|
||||
}
|
||||
mut body := []u8{}
|
||||
mut status := 0
|
||||
mut first_data_len := -1
|
||||
mut got_end := false
|
||||
mut dec := H2HpackDecoder{}
|
||||
for !got_end {
|
||||
f := fr.next() or {
|
||||
assert false, 'frame read failed: ${err}'
|
||||
return
|
||||
}
|
||||
match f {
|
||||
H2HeadersFrame {
|
||||
for hf in dec.decode(f.fragment) or { []H2HeaderField{} } {
|
||||
if hf.name == ':status' {
|
||||
status = hf.value.int()
|
||||
}
|
||||
}
|
||||
if f.end_stream {
|
||||
got_end = true
|
||||
}
|
||||
}
|
||||
H2DataFrame {
|
||||
if first_data_len < 0 {
|
||||
first_data_len = f.data.len
|
||||
}
|
||||
body << f.data
|
||||
if f.end_stream {
|
||||
got_end = true
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
assert status == 200
|
||||
assert body.len == 100
|
||||
// The first DATA frame must not exceed the initial 10-byte window.
|
||||
assert first_data_len <= 10
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
module http
|
||||
|
||||
import net
|
||||
|
||||
// These tests cover RFC 7230 §3.3.3 / RFC 9112 §6.2: HEAD responses, and
|
||||
// 1xx/204/304 status responses, must not carry a body. Before the fix, the
|
||||
// receive loop in `receive_all_data_from_cb_in_builder` waited for
|
||||
// `Content-Length` body bytes that the server never sends, then surfaced
|
||||
// either `response body ended early` (when the connection closed) or a
|
||||
// read timeout (when the server kept the socket open).
|
||||
|
||||
const head_resp = 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 1234\r\nConnection: close\r\n\r\n'
|
||||
|
||||
const status_204_resp = 'HTTP/1.1 204 No Content\r\nContent-Length: 5\r\nConnection: close\r\n\r\n'
|
||||
|
||||
const status_304_resp = 'HTTP/1.1 304 Not Modified\r\nContent-Length: 100\r\nConnection: close\r\n\r\n'
|
||||
|
||||
fn serve_once(mut listener net.TcpListener, response string) {
|
||||
mut conn := listener.accept() or { return }
|
||||
defer {
|
||||
conn.close() or {}
|
||||
}
|
||||
mut buf := []u8{len: 4096}
|
||||
for {
|
||||
n := conn.read(mut buf) or { return }
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
if buf[..n].bytestr().contains('\r\n\r\n') {
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.write(response.bytes()) or {}
|
||||
}
|
||||
|
||||
fn start_server(response string) !int {
|
||||
mut listener := net.listen_tcp(.ip, '127.0.0.1:0')!
|
||||
addr := listener.addr()!
|
||||
port := addr.port()!
|
||||
spawn fn [mut listener, response] () {
|
||||
serve_once(mut listener, response)
|
||||
}()
|
||||
return port
|
||||
}
|
||||
|
||||
fn test_head_does_not_wait_for_body_when_content_length_is_set() {
|
||||
port := start_server(head_resp) or {
|
||||
assert false, 'failed to start server: ${err}'
|
||||
return
|
||||
}
|
||||
resp := head('http://127.0.0.1:${port}/') or {
|
||||
assert false, 'HEAD should not error, got: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == ''
|
||||
cl := resp.header.get(.content_length) or { '' }
|
||||
assert cl == '1234', 'Content-Length header should still be exposed to the caller'
|
||||
}
|
||||
|
||||
fn test_get_204_no_content_does_not_wait_for_body() {
|
||||
port := start_server(status_204_resp) or {
|
||||
assert false, 'failed to start server: ${err}'
|
||||
return
|
||||
}
|
||||
resp := get('http://127.0.0.1:${port}/') or {
|
||||
assert false, 'GET 204 should not error, got: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 204
|
||||
assert resp.body == ''
|
||||
}
|
||||
|
||||
fn test_get_304_not_modified_does_not_wait_for_body() {
|
||||
port := start_server(status_304_resp) or {
|
||||
assert false, 'failed to start server: ${err}'
|
||||
return
|
||||
}
|
||||
resp := get('http://127.0.0.1:${port}/') or {
|
||||
assert false, 'GET 304 should not error, got: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 304
|
||||
assert resp.body == ''
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import strings
|
||||
import arrays
|
||||
|
||||
struct HeaderKV {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
pub const max_headers = 50
|
||||
|
||||
// Header represents the key-value pairs in an HTTP header
|
||||
pub struct Header {
|
||||
pub mut:
|
||||
// data map[string][]string
|
||||
data [max_headers]HeaderKV
|
||||
mut:
|
||||
cur_pos int
|
||||
// map of lowercase header keys to their original keys
|
||||
// in order of appearance
|
||||
// keys map[string][]string
|
||||
}
|
||||
|
||||
// CommonHeader is an enum of the most common HTTP headers
|
||||
pub enum CommonHeader {
|
||||
accept
|
||||
accept_ch
|
||||
accept_charset
|
||||
accept_ch_lifetime
|
||||
accept_encoding
|
||||
accept_language
|
||||
accept_patch
|
||||
accept_post
|
||||
accept_ranges
|
||||
access_control_allow_credentials
|
||||
access_control_allow_headers
|
||||
access_control_allow_methods
|
||||
access_control_allow_origin
|
||||
access_control_expose_headers
|
||||
access_control_max_age
|
||||
access_control_request_headers
|
||||
access_control_request_method
|
||||
age
|
||||
allow
|
||||
alt_svc
|
||||
authorization
|
||||
authority
|
||||
cache_control
|
||||
clear_site_data
|
||||
connection
|
||||
content_disposition
|
||||
content_encoding
|
||||
content_language
|
||||
content_length
|
||||
content_location
|
||||
content_range
|
||||
content_security_policy
|
||||
content_security_policy_report_only
|
||||
content_type
|
||||
cookie
|
||||
cross_origin_embedder_policy
|
||||
cross_origin_opener_policy
|
||||
cross_origin_resource_policy
|
||||
date
|
||||
device_memory
|
||||
digest
|
||||
dnt
|
||||
early_data
|
||||
etag
|
||||
expect
|
||||
expect_ct
|
||||
expires
|
||||
feature_policy
|
||||
forwarded
|
||||
from
|
||||
host
|
||||
if_match
|
||||
if_modified_since
|
||||
if_none_match
|
||||
if_range
|
||||
if_unmodified_since
|
||||
index
|
||||
keep_alive
|
||||
large_allocation
|
||||
last_modified
|
||||
link
|
||||
location
|
||||
nel
|
||||
origin
|
||||
pragma
|
||||
proxy_authenticate
|
||||
proxy_authorization
|
||||
range
|
||||
referer
|
||||
referrer_policy
|
||||
retry_after
|
||||
save_data
|
||||
sec_fetch_dest
|
||||
sec_fetch_mode
|
||||
sec_fetch_site
|
||||
sec_fetch_user
|
||||
sec_websocket_accept
|
||||
sec_websocket_key
|
||||
server
|
||||
server_timing
|
||||
set_cookie
|
||||
sourcemap
|
||||
strict_transport_security
|
||||
te
|
||||
timing_allow_origin
|
||||
tk
|
||||
trailer
|
||||
transfer_encoding
|
||||
upgrade
|
||||
upgrade_insecure_requests
|
||||
user_agent
|
||||
vary
|
||||
via
|
||||
want_digest
|
||||
warning
|
||||
www_authenticate
|
||||
x_content_type_options
|
||||
x_dns_prefetch_control
|
||||
x_forwarded_for
|
||||
x_forwarded_host
|
||||
x_forwarded_proto
|
||||
x_frame_options
|
||||
x_xss_protection
|
||||
}
|
||||
|
||||
pub fn (h CommonHeader) str() string {
|
||||
return match h {
|
||||
.accept { 'Accept' }
|
||||
.accept_ch { 'Accept-CH' }
|
||||
.accept_charset { 'Accept-Charset' }
|
||||
.accept_ch_lifetime { 'Accept-CH-Lifetime' }
|
||||
.accept_encoding { 'Accept-Encoding' }
|
||||
.accept_language { 'Accept-Language' }
|
||||
.accept_patch { 'Accept-Patch' }
|
||||
.accept_post { 'Accept-Post' }
|
||||
.accept_ranges { 'Accept-Ranges' }
|
||||
.access_control_allow_credentials { 'Access-Control-Allow-Credentials' }
|
||||
.access_control_allow_headers { 'Access-Control-Allow-Headers' }
|
||||
.access_control_allow_methods { 'Access-Control-Allow-Methods' }
|
||||
.access_control_allow_origin { 'Access-Control-Allow-Origin' }
|
||||
.access_control_expose_headers { 'Access-Control-Expose-Headers' }
|
||||
.access_control_max_age { 'Access-Control-Max-Age' }
|
||||
.access_control_request_headers { 'Access-Control-Request-Headers' }
|
||||
.access_control_request_method { 'Access-Control-Request-Method' }
|
||||
.age { 'Age' }
|
||||
.allow { 'Allow' }
|
||||
.alt_svc { 'Alt-Svc' }
|
||||
.authorization { 'Authorization' }
|
||||
.authority { 'Authority' }
|
||||
.cache_control { 'Cache-Control' }
|
||||
.clear_site_data { 'Clear-Site-Data' }
|
||||
.connection { 'Connection' }
|
||||
.content_disposition { 'Content-Disposition' }
|
||||
.content_encoding { 'Content-Encoding' }
|
||||
.content_language { 'Content-Language' }
|
||||
.content_length { 'Content-Length' }
|
||||
.content_location { 'Content-Location' }
|
||||
.content_range { 'Content-Range' }
|
||||
.content_security_policy { 'Content-Security-Policy' }
|
||||
.content_security_policy_report_only { 'Content-Security-Policy-Report-Only' }
|
||||
.content_type { 'Content-Type' }
|
||||
.cookie { 'Cookie' }
|
||||
.cross_origin_embedder_policy { 'Cross-Origin-Embedder-Policy' }
|
||||
.cross_origin_opener_policy { 'Cross-Origin-Opener-Policy' }
|
||||
.cross_origin_resource_policy { 'Cross-Origin-Resource-Policy' }
|
||||
.date { 'Date' }
|
||||
.device_memory { 'Device-Memory' }
|
||||
.digest { 'Digest' }
|
||||
.dnt { 'DNT' }
|
||||
.early_data { 'Early-Data' }
|
||||
.etag { 'ETag' }
|
||||
.expect { 'Expect' }
|
||||
.expect_ct { 'Expect-CT' }
|
||||
.expires { 'Expires' }
|
||||
.feature_policy { 'Feature-Policy' }
|
||||
.forwarded { 'Forwarded' }
|
||||
.from { 'From' }
|
||||
.host { 'Host' }
|
||||
.if_match { 'If-Match' }
|
||||
.if_modified_since { 'If-Modified-Since' }
|
||||
.if_none_match { 'If-None-Match' }
|
||||
.if_range { 'If-Range' }
|
||||
.if_unmodified_since { 'If-Unmodified-Since' }
|
||||
.index { 'Index' }
|
||||
.keep_alive { 'Keep-Alive' }
|
||||
.large_allocation { 'Large-Allocation' }
|
||||
.last_modified { 'Last-Modified' }
|
||||
.link { 'Link' }
|
||||
.location { 'Location' }
|
||||
.nel { 'NEL' }
|
||||
.origin { 'Origin' }
|
||||
.pragma { 'Pragma' }
|
||||
.proxy_authenticate { 'Proxy-Authenticate' }
|
||||
.proxy_authorization { 'Proxy-Authorization' }
|
||||
.range { 'Range' }
|
||||
.referer { 'Referer' }
|
||||
.referrer_policy { 'Referrer-Policy' }
|
||||
.retry_after { 'Retry-After' }
|
||||
.save_data { 'Save-Data' }
|
||||
.sec_fetch_dest { 'Sec-Fetch-Dest' }
|
||||
.sec_fetch_mode { 'Sec-Fetch-Mode' }
|
||||
.sec_fetch_site { 'Sec-Fetch-Site' }
|
||||
.sec_fetch_user { 'Sec-Fetch-User' }
|
||||
.sec_websocket_accept { 'Sec-WebSocket-Accept' }
|
||||
.sec_websocket_key { 'Sec-WebSocket-Key' }
|
||||
.server { 'Server' }
|
||||
.server_timing { 'Server-Timing' }
|
||||
.set_cookie { 'Set-Cookie' }
|
||||
.sourcemap { 'SourceMap' }
|
||||
.strict_transport_security { 'Strict-Transport-Security' }
|
||||
.te { 'TE' }
|
||||
.timing_allow_origin { 'Timing-Allow-Origin' }
|
||||
.tk { 'Tk' }
|
||||
.trailer { 'Trailer' }
|
||||
.transfer_encoding { 'Transfer-Encoding' }
|
||||
.upgrade { 'Upgrade' }
|
||||
.upgrade_insecure_requests { 'Upgrade-Insecure-Requests' }
|
||||
.user_agent { 'User-Agent' }
|
||||
.vary { 'Vary' }
|
||||
.via { 'Via' }
|
||||
.want_digest { 'Want-Digest' }
|
||||
.warning { 'Warning' }
|
||||
.www_authenticate { 'WWW-Authenticate' }
|
||||
.x_content_type_options { 'X-Content-Type-Options' }
|
||||
.x_dns_prefetch_control { 'X-DNS-Prefetch-Control' }
|
||||
.x_forwarded_for { 'X-Forwarded-For' }
|
||||
.x_forwarded_host { 'X-Forwarded-Host' }
|
||||
.x_forwarded_proto { 'X-Forwarded-Proto' }
|
||||
.x_frame_options { 'X-Frame-Options' }
|
||||
.x_xss_protection { 'X-XSS-Protection' }
|
||||
}
|
||||
}
|
||||
|
||||
const common_header_map = {
|
||||
'accept': CommonHeader.accept
|
||||
'accept-ch': .accept_ch
|
||||
'accept-charset': .accept_charset
|
||||
'accept-ch-lifetime': .accept_ch_lifetime
|
||||
'accept-encoding': .accept_encoding
|
||||
'accept-language': .accept_language
|
||||
'accept-patch': .accept_patch
|
||||
'accept-post': .accept_post
|
||||
'accept-ranges': .accept_ranges
|
||||
'access-control-allow-credentials': .access_control_allow_credentials
|
||||
'access-control-allow-headers': .access_control_allow_headers
|
||||
'access-control-allow-methods': .access_control_allow_methods
|
||||
'access-control-allow-origin': .access_control_allow_origin
|
||||
'access-control-expose-headers': .access_control_expose_headers
|
||||
'access-control-max-age': .access_control_max_age
|
||||
'access-control-request-headers': .access_control_request_headers
|
||||
'access-control-request-method': .access_control_request_method
|
||||
'age': .age
|
||||
'allow': .allow
|
||||
'alt-svc': .alt_svc
|
||||
'authorization': .authorization
|
||||
'cache-control': .cache_control
|
||||
'clear-site-data': .clear_site_data
|
||||
'connection': .connection
|
||||
'content-disposition': .content_disposition
|
||||
'content-encoding': .content_encoding
|
||||
'content-language': .content_language
|
||||
'content-length': .content_length
|
||||
'content-location': .content_location
|
||||
'content-range': .content_range
|
||||
'content-security-policy': .content_security_policy
|
||||
'content-security-policy-report-only': .content_security_policy_report_only
|
||||
'content-type': .content_type
|
||||
'cookie': .cookie
|
||||
'cross-origin-embedder-policy': .cross_origin_embedder_policy
|
||||
'cross-origin-opener-policy': .cross_origin_opener_policy
|
||||
'cross-origin-resource-policy': .cross_origin_resource_policy
|
||||
'date': .date
|
||||
'device-memory': .device_memory
|
||||
'digest': .digest
|
||||
'dnt': .dnt
|
||||
'early-data': .early_data
|
||||
'etag': .etag
|
||||
'expect': .expect
|
||||
'expect-ct': .expect_ct
|
||||
'expires': .expires
|
||||
'feature-policy': .feature_policy
|
||||
'forwarded': .forwarded
|
||||
'from': .from
|
||||
'host': .host
|
||||
'if-match': .if_match
|
||||
'if-modified-since': .if_modified_since
|
||||
'if-none-match': .if_none_match
|
||||
'if-range': .if_range
|
||||
'if-unmodified-since': .if_unmodified_since
|
||||
'index': .index
|
||||
'keep-alive': .keep_alive
|
||||
'large-allocation': .large_allocation
|
||||
'last-modified': .last_modified
|
||||
'link': .link
|
||||
'location': .location
|
||||
'nel': .nel
|
||||
'origin': .origin
|
||||
'pragma': .pragma
|
||||
'proxy-authenticate': .proxy_authenticate
|
||||
'proxy-authorization': .proxy_authorization
|
||||
'range': .range
|
||||
'referer': .referer
|
||||
'referrer-policy': .referrer_policy
|
||||
'retry-after': .retry_after
|
||||
'save-data': .save_data
|
||||
'sec-fetch-dest': .sec_fetch_dest
|
||||
'sec-fetch-mode': .sec_fetch_mode
|
||||
'sec-fetch-site': .sec_fetch_site
|
||||
'sec-fetch-user': .sec_fetch_user
|
||||
'sec-websocket-accept': .sec_websocket_accept
|
||||
'sec_websocket_key': .sec_websocket_key
|
||||
'server': .server
|
||||
'server-timing': .server_timing
|
||||
'set-cookie': .set_cookie
|
||||
'sourcemap': .sourcemap
|
||||
'strict-transport-security': .strict_transport_security
|
||||
'te': .te
|
||||
'timing-allow-origin': .timing_allow_origin
|
||||
'tk': .tk
|
||||
'trailer': .trailer
|
||||
'transfer-encoding': .transfer_encoding
|
||||
'upgrade': .upgrade
|
||||
'upgrade-insecure-requests': .upgrade_insecure_requests
|
||||
'user-agent': .user_agent
|
||||
'vary': .vary
|
||||
'via': .via
|
||||
'want-digest': .want_digest
|
||||
'warning': .warning
|
||||
'www-authenticate': .www_authenticate
|
||||
'x-content-type-options': .x_content_type_options
|
||||
'x-dns-prefetch-control': .x_dns_prefetch_control
|
||||
'x-forwarded-for': .x_forwarded_for
|
||||
'x-forwarded-host': .x_forwarded_host
|
||||
'x-forwarded-proto': .x_forwarded_proto
|
||||
'x-frame-options': .x_frame_options
|
||||
'x-xss-protection': .x_xss_protection
|
||||
}
|
||||
|
||||
pub fn (mut h Header) free() {
|
||||
unsafe {
|
||||
// h.data.free()
|
||||
// h.keys.free()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HeaderConfig {
|
||||
pub:
|
||||
key CommonHeader
|
||||
value string
|
||||
}
|
||||
|
||||
@[inline]
|
||||
fn header_lower_ascii_byte(b u8) u8 {
|
||||
if b >= `A` && b <= `Z` {
|
||||
return b + 32
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
fn header_key_eq(a string, b string) bool {
|
||||
if a.len != b.len {
|
||||
return false
|
||||
}
|
||||
for i in 0 .. a.len {
|
||||
if header_lower_ascii_byte(a[i]) != header_lower_ascii_byte(b[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Create a new Header object
|
||||
pub fn new_header(kvs ...HeaderConfig) Header {
|
||||
mut h := Header{
|
||||
// data: map[string][]string{}
|
||||
}
|
||||
for i, kv in kvs {
|
||||
h.data[i] = HeaderKV{kv.key.str(), kv.value}
|
||||
// h.add(kv.key, kv.value)
|
||||
}
|
||||
h.cur_pos = kvs.len
|
||||
return h
|
||||
}
|
||||
|
||||
// new_header_from_map creates a Header from key value pairs
|
||||
pub fn new_header_from_map(kvs map[CommonHeader]string) Header {
|
||||
mut h := new_header()
|
||||
h.add_map(kvs)
|
||||
return h
|
||||
}
|
||||
|
||||
// new_custom_header_from_map creates a Header from string key value pairs
|
||||
pub fn new_custom_header_from_map(kvs map[string]string) !Header {
|
||||
mut h := new_header()
|
||||
h.add_custom_map(kvs)!
|
||||
return h
|
||||
}
|
||||
|
||||
// add appends a value to the header key.
|
||||
pub fn (mut h Header) add(key CommonHeader, value string) {
|
||||
k := key.str()
|
||||
// h.data[k] << value
|
||||
h.data[h.cur_pos] = HeaderKV{k, value}
|
||||
h.cur_pos++
|
||||
// h.add_key(k)
|
||||
}
|
||||
|
||||
// add_custom appends a value to a custom header key. This function will
|
||||
// return an error if the key contains invalid header characters.
|
||||
pub fn (mut h Header) add_custom(key string, value string) ! {
|
||||
is_valid(key)!
|
||||
// h.data[key] << value
|
||||
h.data[h.cur_pos] = HeaderKV{key, value}
|
||||
h.cur_pos++
|
||||
// h.add_key(key)
|
||||
}
|
||||
|
||||
// add_map appends the value for each header key.
|
||||
pub fn (mut h Header) add_map(kvs map[CommonHeader]string) {
|
||||
for k, v in kvs {
|
||||
h.add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// add_custom_map appends the value for each custom header key.
|
||||
pub fn (mut h Header) add_custom_map(kvs map[string]string) ! {
|
||||
for k, v in kvs {
|
||||
h.add_custom(k, v)!
|
||||
}
|
||||
}
|
||||
|
||||
// set sets the key-value pair. This function will clear any other values
|
||||
// that exist for the CommonHeader.
|
||||
pub fn (mut h Header) set(key CommonHeader, value string) {
|
||||
key_str := key.str()
|
||||
|
||||
// for i, kv in h.data {
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
if h.data[i].key == key_str {
|
||||
h.data[i] = HeaderKV{key_str, value}
|
||||
return
|
||||
}
|
||||
}
|
||||
// Not updated, add a new one
|
||||
h.data[h.cur_pos] = HeaderKV{key_str, value}
|
||||
h.cur_pos++
|
||||
|
||||
// h.data[k] = [value]
|
||||
// h.add_key(k)
|
||||
}
|
||||
|
||||
// set_custom sets the key-value pair for a custom header key. This
|
||||
// function will clear any other values that exist for the header. This
|
||||
// function will return an error if the key contains invalid header
|
||||
// characters.
|
||||
pub fn (mut h Header) set_custom(key string, value string) ! {
|
||||
is_valid(key)!
|
||||
mut set := false
|
||||
for i, kv in h.data {
|
||||
if kv.key == key {
|
||||
if !set {
|
||||
h.data[i] = HeaderKV{key, value}
|
||||
set = true
|
||||
} else {
|
||||
// Remove old duplicates
|
||||
h.data[i] = HeaderKV{key, ''}
|
||||
}
|
||||
// return
|
||||
}
|
||||
}
|
||||
if set {
|
||||
return
|
||||
}
|
||||
// Not updated, add a new one
|
||||
h.data[h.cur_pos] = HeaderKV{key, value}
|
||||
h.cur_pos++
|
||||
// h.data[key] = [value]
|
||||
// h.add_key(key)
|
||||
}
|
||||
|
||||
// delete deletes all values for a key.
|
||||
pub fn (mut h Header) delete(key CommonHeader) {
|
||||
h.delete_custom(key.str())
|
||||
}
|
||||
|
||||
// delete_custom deletes all values for a custom header key.
|
||||
pub fn (mut h Header) delete_custom(key string) {
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
if h.data[i].key == key {
|
||||
h.data[i] = HeaderKV{key, ''}
|
||||
}
|
||||
}
|
||||
// h.data.delete(key)
|
||||
|
||||
// remove key from keys metadata
|
||||
/*
|
||||
kl := key.to_lower()
|
||||
if kl in h.keys {
|
||||
h.keys[kl] = h.keys[kl].filter(it != key)
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
// contains returns whether the header key exists in the map.
|
||||
pub fn (h Header) contains(key CommonHeader) bool {
|
||||
if h.cur_pos == 0 {
|
||||
return false
|
||||
}
|
||||
key_str := key.str()
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
if header_key_eq(h.data[i].key, key_str) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
// return h.contains_custom(key.str())
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct HeaderQueryConfig {
|
||||
pub:
|
||||
exact bool
|
||||
}
|
||||
|
||||
// contains_custom returns whether the custom header key exists in the map.
|
||||
pub fn (h Header) contains_custom(key string, flags HeaderQueryConfig) bool {
|
||||
if flags.exact {
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
kv := h.data[i]
|
||||
if kv.key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
kv := h.data[i]
|
||||
if header_key_eq(kv.key, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// get gets the first value for the CommonHeader, or none if the key
|
||||
// does not exist.
|
||||
pub fn (h Header) get(key CommonHeader) ?string {
|
||||
return h.get_custom(key.str())
|
||||
}
|
||||
|
||||
// get_custom gets the first value for the custom header, or none if
|
||||
// the key does not exist.
|
||||
pub fn (h Header) get_custom(key string, flags HeaderQueryConfig) ?string {
|
||||
if flags.exact {
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
// for kv in h.data {
|
||||
kv := h.data[i]
|
||||
// println('${kv.key} => ${kv.value}')
|
||||
if kv.key == key {
|
||||
return kv.value
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// for kv in h.data {
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
kv := h.data[i]
|
||||
if header_key_eq(kv.key, key) {
|
||||
return kv.value
|
||||
}
|
||||
}
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// starting_with gets the first header starting with key, or none if
|
||||
// the key does not exist.
|
||||
pub fn (h Header) starting_with(key string) !string {
|
||||
for _, kv in h.data {
|
||||
if kv.key.starts_with(key) {
|
||||
return kv.key
|
||||
}
|
||||
}
|
||||
return error('none')
|
||||
}
|
||||
|
||||
// values gets all values for the CommonHeader.
|
||||
pub fn (h Header) values(key CommonHeader) []string {
|
||||
return h.custom_values(key.str())
|
||||
}
|
||||
|
||||
// custom_values gets all values for the custom header.
|
||||
pub fn (h Header) custom_values(key string, flags HeaderQueryConfig) []string {
|
||||
if h.cur_pos == 0 {
|
||||
return []
|
||||
}
|
||||
mut res := []string{cap: 2}
|
||||
if flags.exact {
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
kv := h.data[i]
|
||||
if kv.key == key && kv.value != '' { // empty value means a deleted header
|
||||
res << kv.value
|
||||
}
|
||||
}
|
||||
return res
|
||||
} else {
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
kv := h.data[i]
|
||||
if header_key_eq(kv.key, key) && kv.value != '' { // empty value means a deleted header
|
||||
res << kv.value
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
// keys gets all header keys as strings
|
||||
pub fn (h Header) keys() []string {
|
||||
mut res := []string{cap: h.cur_pos}
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
if h.data[i].value == '' {
|
||||
continue
|
||||
}
|
||||
res << h.data[i].key
|
||||
}
|
||||
// Make sure keys are lower case and unique
|
||||
return arrays.uniq(res)
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct HeaderRenderConfig {
|
||||
pub:
|
||||
version Version
|
||||
coerce bool
|
||||
canonicalize bool
|
||||
}
|
||||
|
||||
// render renders the Header into a string for use in sending HTTP
|
||||
// requests. All header lines will end in `\r\n`
|
||||
@[manualfree]
|
||||
pub fn (h Header) render(flags HeaderRenderConfig) string {
|
||||
// estimate ~48 bytes per header
|
||||
mut sb := strings.new_builder(h.data.len * 48)
|
||||
h.render_into_sb(mut sb, flags)
|
||||
res := sb.str()
|
||||
unsafe { sb.free() }
|
||||
return res
|
||||
}
|
||||
|
||||
// render_into_sb works like render, but uses a preallocated string builder instead.
|
||||
// This method should be used only for performance critical applications.
|
||||
pub fn (h Header) render_into_sb(mut sb strings.Builder, flags HeaderRenderConfig) {
|
||||
/*
|
||||
if flags.coerce {
|
||||
for kl, data_keys in h.keys {
|
||||
key := if flags.version == .v2_0 {
|
||||
kl
|
||||
} else if flags.canonicalize {
|
||||
canonicalize(kl)
|
||||
} else {
|
||||
data_keys[0]
|
||||
}
|
||||
for k in data_keys {
|
||||
for v in h.data[k] {
|
||||
sb.write_string(key)
|
||||
sb.write_string(': ')
|
||||
sb.write_string(v)
|
||||
sb.write_string('\r\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*/
|
||||
// for _, kv in h.data {
|
||||
for i := 0; i < h.cur_pos; i++ {
|
||||
kv := h.data[i]
|
||||
key := if flags.version == .v2_0 {
|
||||
kv.key.to_lower()
|
||||
} else if flags.canonicalize {
|
||||
canonicalize(kv.key.to_lower())
|
||||
} else {
|
||||
kv.key
|
||||
}
|
||||
// XTODO handle []string ? or doesn't matter?
|
||||
// for v in vs {
|
||||
sb.write_string(key)
|
||||
sb.write_string(': ')
|
||||
sb.write_string(kv.value)
|
||||
sb.write_string('\r\n')
|
||||
//}
|
||||
}
|
||||
//}
|
||||
}
|
||||
|
||||
// join combines two Header structs into a new Header struct
|
||||
pub fn (h Header) join(other Header) Header {
|
||||
mut combined := Header{
|
||||
data: h.data // h.data.clone()
|
||||
cur_pos: h.cur_pos
|
||||
}
|
||||
for k in other.keys() {
|
||||
for v in other.custom_values(k, exact: true) {
|
||||
combined.add_custom(k, v) or {
|
||||
// panic because this should never fail
|
||||
panic('unexpected error: ' + err.str())
|
||||
}
|
||||
}
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
// canonicalize canonicalizes an HTTP header key
|
||||
// Common headers are determined by the common_header_map
|
||||
// Custom headers are capitalized on the first letter and any letter after a '-'
|
||||
// NOTE: Assumes sl is lowercase, since the caller usually already has the lowercase key
|
||||
fn canonicalize(name string) string {
|
||||
// check if we have a common header
|
||||
if name in common_header_map {
|
||||
return common_header_map[name].str()
|
||||
}
|
||||
return name.split('-').map(it.capitalize()).join('-')
|
||||
}
|
||||
|
||||
// Helper function to add a key to the keys map
|
||||
/*
|
||||
fn (mut h Header) add_key(key string) {
|
||||
kl := key.to_lower()
|
||||
if !h.keys[kl].contains(key) {
|
||||
h.keys[kl] << key
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Custom error struct for invalid header tokens
|
||||
struct HeaderKeyError {
|
||||
Error
|
||||
code int
|
||||
header string
|
||||
invalid_char u8
|
||||
}
|
||||
|
||||
pub fn (err HeaderKeyError) msg() string {
|
||||
return "Invalid header key: '${err.header}'"
|
||||
}
|
||||
|
||||
pub fn (err HeaderKeyError) code() int {
|
||||
return err.code
|
||||
}
|
||||
|
||||
// is_valid checks if the header token contains all valid bytes
|
||||
fn is_valid(header string) ! {
|
||||
for _, c in header {
|
||||
if int(c) >= 128 || !is_token(c) {
|
||||
return HeaderKeyError{
|
||||
code: 1
|
||||
header: header
|
||||
invalid_char: c
|
||||
}
|
||||
}
|
||||
}
|
||||
if header.len == 0 {
|
||||
return HeaderKeyError{
|
||||
code: 2
|
||||
header: header
|
||||
invalid_char: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// is_token checks if the byte is valid for a header token
|
||||
fn is_token(b u8) bool {
|
||||
return match b {
|
||||
33, 35...39, 42, 43, 45, 46, 48...57, 65...90, 94...122, 124, 126 { true }
|
||||
else { false }
|
||||
}
|
||||
}
|
||||
|
||||
// str returns the headers string as seen in HTTP/1.1 requests.
|
||||
// Key order is not guaranteed.
|
||||
pub fn (h Header) str() string {
|
||||
return h.render(version: .v1_1)
|
||||
}
|
||||
|
||||
// parse_headers parses a newline delimited string into a Header struct
|
||||
fn parse_headers(s string) !Header {
|
||||
mut h := new_header()
|
||||
mut last_key := ''
|
||||
mut last_value := ''
|
||||
for line in s.split_into_lines() {
|
||||
if line.len == 0 {
|
||||
break
|
||||
}
|
||||
// handle header fold
|
||||
if line[0] == ` ` || line[0] == `\t` {
|
||||
last_value += ' ${line.trim(' \t')}'
|
||||
continue
|
||||
} else if last_key != '' {
|
||||
h.add_custom(last_key, last_value)!
|
||||
}
|
||||
last_key, last_value = parse_header(line)!
|
||||
}
|
||||
h.add_custom(last_key, last_value)!
|
||||
return h
|
||||
}
|
||||
|
||||
fn parse_header(s string) !(string, string) {
|
||||
if !s.contains(':') {
|
||||
return error('missing colon in header')
|
||||
}
|
||||
words := s.split_nth(':', 2)
|
||||
// TODO: parse quoted text according to the RFC
|
||||
return words[0], words[1].trim(' \t')
|
||||
}
|
||||
|
||||
fn parse_header_fast(s string) !int {
|
||||
pos := s.index(':') or { return error('missing colon in header') }
|
||||
return pos
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
module http
|
||||
|
||||
fn test_header_new() {
|
||||
h := new_header(HeaderConfig{ key: .accept, value: 'nothing' },
|
||||
key: .expires
|
||||
value: 'yesterday'
|
||||
)
|
||||
assert h.contains(.accept)
|
||||
assert h.contains(.expires)
|
||||
accept := h.get(.accept) or { '' }
|
||||
expires := h.get(.expires) or { '' }
|
||||
assert accept == 'nothing'
|
||||
assert expires == 'yesterday'
|
||||
}
|
||||
|
||||
fn test_header_invalid_key() {
|
||||
mut h := new_header()
|
||||
h.add_custom('space is invalid', ':(') or { return }
|
||||
panic('should have returned')
|
||||
}
|
||||
|
||||
fn test_header_adds_multiple() {
|
||||
mut h := new_header()
|
||||
h.add(.accept, 'one')
|
||||
h.add(.accept, 'two')
|
||||
|
||||
assert h.values(.accept) == ['one', 'two']
|
||||
}
|
||||
|
||||
/*
|
||||
fn test_same_key() {
|
||||
mut h := new_header()
|
||||
h.add_custom('accept', 'foo')!
|
||||
h.add_custom('Accept', 'bar')!
|
||||
// h.add(.accept, 'bar')
|
||||
println(h)
|
||||
exit(0)
|
||||
}
|
||||
*/
|
||||
|
||||
fn test_header_get() {
|
||||
// .dnt is all cap ("DNT"), test this
|
||||
mut h := new_header(key: .dnt, value: 'one')
|
||||
h.add_custom('dnt', 'two')!
|
||||
dnt := h.get_custom('dnt') or { '' }
|
||||
exact := h.get_custom('dnt', exact: true) or { '' }
|
||||
assert dnt == 'one'
|
||||
assert exact == 'two'
|
||||
}
|
||||
|
||||
fn test_header_set() {
|
||||
mut h := new_header(HeaderConfig{ key: .dnt, value: 'one' },
|
||||
key: .dnt
|
||||
value: 'two'
|
||||
)
|
||||
assert h.values(.dnt) == ['one', 'two']
|
||||
h.set_custom('DNT', 'three')!
|
||||
assert h.values(.dnt) == ['three']
|
||||
}
|
||||
|
||||
fn test_header_delete() {
|
||||
mut h := new_header(HeaderConfig{ key: .dnt, value: 'one' },
|
||||
key: .dnt
|
||||
value: 'two'
|
||||
)
|
||||
assert h.values(.dnt) == ['one', 'two']
|
||||
h.delete(.dnt)
|
||||
assert h.values(.dnt) == []
|
||||
}
|
||||
|
||||
fn test_header_delete_not_existing() {
|
||||
mut h := new_header()
|
||||
assert h.data.len == max_headers
|
||||
assert h.values(.dnt).len == 0
|
||||
// assert h.keys.len == 0
|
||||
h.delete(.dnt)
|
||||
assert h.data.len == max_headers
|
||||
assert h.values(.dnt).len == 0
|
||||
// assert h.keys.len == 0
|
||||
}
|
||||
|
||||
fn test_delete_header() {
|
||||
mut r := new_request(.get, '', '')
|
||||
r.header.set(.authorization, 'foo')
|
||||
r.header.delete(.authorization)
|
||||
assert r.header.get(.authorization)? == ''
|
||||
}
|
||||
|
||||
fn test_custom_header() {
|
||||
mut h := new_header()
|
||||
h.add_custom('AbC', 'dEf')!
|
||||
h.add_custom('aBc', 'GhI')!
|
||||
assert h.custom_values('AbC', exact: true) == ['dEf']
|
||||
assert h.custom_values('aBc', exact: true) == ['GhI']
|
||||
assert h.custom_values('ABC') == ['dEf', 'GhI']
|
||||
assert h.custom_values('abc') == ['dEf', 'GhI']
|
||||
assert h.keys() == ['AbC', 'aBc']
|
||||
h.delete_custom('AbC')
|
||||
h.delete_custom('aBc')
|
||||
|
||||
h.add_custom('abc', 'def')!
|
||||
assert h.custom_values('abc') == ['def']
|
||||
assert h.custom_values('ABC') == ['def']
|
||||
assert h.keys() == ['abc']
|
||||
h.delete_custom('abc')
|
||||
|
||||
h.add_custom('accEPT', '*/*')!
|
||||
assert h.custom_values('ACCept') == ['*/*']
|
||||
assert h.values(.accept) == ['*/*']
|
||||
assert h.keys() == ['accEPT']
|
||||
}
|
||||
|
||||
fn test_contains_custom() {
|
||||
mut h := new_header()
|
||||
h.add_custom('Hello', 'world')!
|
||||
assert h.contains_custom('hello')
|
||||
assert h.contains_custom('HELLO')
|
||||
assert h.contains_custom('Hello', exact: true)
|
||||
assert h.contains_custom('hello', exact: true) == false
|
||||
assert h.contains_custom('HELLO', exact: true) == false
|
||||
}
|
||||
|
||||
fn test_get_custom() {
|
||||
mut h := new_header()
|
||||
h.add_custom('Hello', 'world')!
|
||||
assert h.get_custom('hello')? == 'world'
|
||||
assert h.get_custom('HELLO')? == 'world'
|
||||
assert h.get_custom('Hello', exact: true)? == 'world'
|
||||
if _ := h.get_custom('hello', exact: true) {
|
||||
// should be none
|
||||
assert false
|
||||
}
|
||||
if _ := h.get_custom('HELLO', exact: true) {
|
||||
// should be none
|
||||
assert false
|
||||
}
|
||||
}
|
||||
|
||||
fn test_starting_with() {
|
||||
mut h := new_header()
|
||||
h.add_custom('Hello-1', 'world')!
|
||||
h.add_custom('Hello-21', 'world')!
|
||||
assert h.starting_with('Hello-')! == 'Hello-1'
|
||||
assert h.starting_with('Hello-2')! == 'Hello-21'
|
||||
}
|
||||
|
||||
fn test_custom_values() {
|
||||
mut h := new_header()
|
||||
h.add_custom('Hello', 'world')!
|
||||
assert h.custom_values('hello') == ['world']
|
||||
assert h.custom_values('HELLO') == ['world']
|
||||
assert h.custom_values('Hello', exact: true) == ['world']
|
||||
assert h.custom_values('hello', exact: true) == []
|
||||
assert h.custom_values('HELLO', exact: true) == []
|
||||
}
|
||||
|
||||
fn test_coerce_canonicalize() {
|
||||
mut h := new_header()
|
||||
h.add_custom('accept', 'foo')!
|
||||
h.add(.accept, 'bar')
|
||||
assert h.values(.accept) == ['foo', 'bar']
|
||||
assert h.keys().len == 2
|
||||
}
|
||||
|
||||
fn test_coerce_custom() {
|
||||
mut h := new_header()
|
||||
h.add_custom('Hello', 'foo')!
|
||||
h.add_custom('hello', 'bar')!
|
||||
h.add_custom('HELLO', 'baz')!
|
||||
assert h.custom_values('hello') == ['foo', 'bar', 'baz']
|
||||
assert h.keys().len == 3
|
||||
}
|
||||
|
||||
fn test_coerce_canonicalize_custom() {
|
||||
mut h := new_header()
|
||||
h.add_custom('foo-BAR', 'foo')!
|
||||
h.add_custom('FOO-bar', 'bar')!
|
||||
assert h.custom_values('foo-bar') == ['foo', 'bar']
|
||||
assert h.keys().len == 2
|
||||
}
|
||||
|
||||
fn test_render_version() {
|
||||
mut h := new_header()
|
||||
h.add_custom('accept', 'foo')!
|
||||
h.add_custom('Accept', 'bar')!
|
||||
h.add(.accept, 'baz')
|
||||
|
||||
s1_0 := h.render(version: .v1_0)
|
||||
assert s1_0.contains('accept: foo\r\n')
|
||||
assert s1_0.contains('Accept: bar\r\n')
|
||||
assert s1_0.contains('Accept: baz\r\n')
|
||||
|
||||
s1_1 := h.render(version: .v1_1)
|
||||
assert s1_1.contains('accept: foo\r\n')
|
||||
assert s1_1.contains('Accept: bar\r\n')
|
||||
assert s1_1.contains('Accept: baz\r\n')
|
||||
|
||||
s2_0 := h.render(version: .v2_0)
|
||||
assert s2_0.contains('accept: foo\r\n')
|
||||
assert s2_0.contains('accept: bar\r\n')
|
||||
assert s2_0.contains('accept: baz\r\n')
|
||||
}
|
||||
|
||||
/*
|
||||
fn test_render_coerce() {
|
||||
mut h := new_header()
|
||||
h.add_custom('accept', 'foo')!
|
||||
h.add_custom('Accept', 'bar')!
|
||||
h.add(.accept, 'baz')
|
||||
h.add(.host, 'host')
|
||||
|
||||
s1_0 := h.render(version: .v1_1, coerce: true)
|
||||
println('<<<<<<<<<<<<')
|
||||
println(s1_0)
|
||||
println('>>>>>>>>>>>>>>')
|
||||
assert s1_0.contains('accept: foo\r\n')
|
||||
assert s1_0.contains('accept: bar\r\n')
|
||||
assert s1_0.contains('accept: baz\r\n')
|
||||
assert s1_0.contains('Host: host\r\n')
|
||||
|
||||
s1_1 := h.render(version: .v1_1, coerce: true)
|
||||
assert s1_1.contains('accept: foo\r\n')
|
||||
assert s1_1.contains('accept: bar\r\n')
|
||||
assert s1_1.contains('accept: baz\r\n')
|
||||
assert s1_1.contains('Host: host\r\n')
|
||||
|
||||
s2_0 := h.render(version: .v2_0, coerce: true)
|
||||
assert s2_0.contains('accept: foo\r\n')
|
||||
assert s2_0.contains('accept: bar\r\n')
|
||||
assert s2_0.contains('accept: baz\r\n')
|
||||
assert s2_0.contains('host: host\r\n')
|
||||
}
|
||||
*/
|
||||
|
||||
fn test_render_canonicalize() {
|
||||
mut h := new_header()
|
||||
h.add_custom('accept', 'foo')!
|
||||
h.add_custom('Accept', 'bar')!
|
||||
h.add(.accept, 'baz')
|
||||
h.add(.host, 'host')
|
||||
|
||||
s1_0 := h.render(version: .v1_1, canonicalize: true)
|
||||
assert s1_0.contains('Accept: foo\r\n')
|
||||
assert s1_0.contains('Accept: bar\r\n')
|
||||
assert s1_0.contains('Accept: baz\r\n')
|
||||
assert s1_0.contains('Host: host\r\n')
|
||||
|
||||
s1_1 := h.render(version: .v1_1, canonicalize: true)
|
||||
assert s1_1.contains('Accept: foo\r\n')
|
||||
assert s1_1.contains('Accept: bar\r\n')
|
||||
assert s1_1.contains('Accept: baz\r\n')
|
||||
assert s1_1.contains('Host: host\r\n')
|
||||
|
||||
s2_0 := h.render(version: .v2_0, canonicalize: true)
|
||||
assert s2_0.contains('accept: foo\r\n')
|
||||
assert s2_0.contains('accept: bar\r\n')
|
||||
assert s2_0.contains('accept: baz\r\n')
|
||||
assert s2_0.contains('host: host\r\n')
|
||||
}
|
||||
|
||||
fn test_render_coerce_canonicalize() {
|
||||
mut h := new_header()
|
||||
h.add_custom('accept', 'foo')!
|
||||
h.add_custom('Accept', 'bar')!
|
||||
h.add(.accept, 'baz')
|
||||
h.add(.host, 'host')
|
||||
|
||||
s1_0 := h.render(version: .v1_1, coerce: true, canonicalize: true)
|
||||
assert s1_0.contains('Accept: foo\r\n')
|
||||
assert s1_0.contains('Accept: bar\r\n')
|
||||
assert s1_0.contains('Accept: baz\r\n')
|
||||
assert s1_0.contains('Host: host\r\n')
|
||||
|
||||
s1_1 := h.render(version: .v1_1, coerce: true, canonicalize: true)
|
||||
assert s1_1.contains('Accept: foo\r\n')
|
||||
assert s1_1.contains('Accept: bar\r\n')
|
||||
assert s1_1.contains('Accept: baz\r\n')
|
||||
assert s1_1.contains('Host: host\r\n')
|
||||
|
||||
s2_0 := h.render(version: .v2_0, coerce: true, canonicalize: true)
|
||||
assert s2_0.contains('accept: foo\r\n')
|
||||
assert s2_0.contains('accept: bar\r\n')
|
||||
assert s2_0.contains('accept: baz\r\n')
|
||||
assert s2_0.contains('host: host\r\n')
|
||||
}
|
||||
|
||||
fn test_str() {
|
||||
mut h := new_header()
|
||||
h.add(.accept, 'text/html')
|
||||
h.add_custom('Accept', 'image/jpeg')!
|
||||
h.add_custom('X-custom', 'Hello')!
|
||||
|
||||
println('===========')
|
||||
println(h.str())
|
||||
|
||||
// key order is not guaranteed
|
||||
assert h.str() == 'Accept: text/html\r\nAccept: image/jpeg\r\nX-custom: Hello\r\n'
|
||||
|| h.str() == 'X-custom: Hello\r\nAccept:text/html\r\nAccept: image/jpeg\r\n'
|
||||
}
|
||||
|
||||
fn test_header_from_map() {
|
||||
h := new_header_from_map({
|
||||
CommonHeader.accept: 'nothing'
|
||||
CommonHeader.expires: 'yesterday'
|
||||
})
|
||||
assert h.contains(.accept)
|
||||
assert h.contains(.expires)
|
||||
assert h.get(.accept) or { '' } == 'nothing'
|
||||
assert h.get(.expires) or { '' } == 'yesterday'
|
||||
}
|
||||
|
||||
fn test_custom_header_from_map() {
|
||||
h := new_custom_header_from_map({
|
||||
'Server': 'Veb'
|
||||
'foo': 'bar'
|
||||
})!
|
||||
assert h.contains_custom('server')
|
||||
assert h.contains_custom('foo')
|
||||
assert h.get_custom('server') or { '' } == 'Veb'
|
||||
assert h.get_custom('foo') or { '' } == 'bar'
|
||||
}
|
||||
|
||||
fn test_header_join() {
|
||||
h1 := new_header_from_map({
|
||||
CommonHeader.accept: 'nothing'
|
||||
CommonHeader.expires: 'yesterday'
|
||||
})
|
||||
h2 := new_custom_header_from_map({
|
||||
'Server': 'Veb'
|
||||
'foo': 'bar'
|
||||
})!
|
||||
h3 := h1.join(h2)
|
||||
// h1 is unchanged
|
||||
assert h1.contains(.accept)
|
||||
assert h1.contains(.expires)
|
||||
assert !h1.contains_custom('Server')
|
||||
assert !h1.contains_custom('foo')
|
||||
// h2 is unchanged
|
||||
assert !h2.contains(.accept)
|
||||
assert !h2.contains(.expires)
|
||||
assert h2.contains_custom('Server')
|
||||
assert h2.contains_custom('foo')
|
||||
// h3 has all four headers
|
||||
assert h3.contains(.accept)
|
||||
assert h3.contains(.expires)
|
||||
assert h3.contains_custom('Server')
|
||||
assert h3.contains_custom('foo')
|
||||
}
|
||||
|
||||
fn parse_headers_test(s string, expected map[string]string) ! {
|
||||
assert parse_headers(s)! == new_custom_header_from_map(expected)!
|
||||
}
|
||||
|
||||
fn test_parse_headers() ! {
|
||||
parse_headers_test('foo: bar', {
|
||||
'foo': 'bar'
|
||||
})!
|
||||
parse_headers_test('foo: \t bar', {
|
||||
'foo': 'bar'
|
||||
})!
|
||||
parse_headers_test('foo: bar\r\n\tbaz', {
|
||||
'foo': 'bar baz'
|
||||
})!
|
||||
parse_headers_test('foo: bar \r\n\tbaz\r\n buzz', {
|
||||
'foo': 'bar baz buzz'
|
||||
})!
|
||||
parse_headers_test('foo: bar\r\nbar:baz', {
|
||||
'foo': 'bar'
|
||||
'bar': 'baz'
|
||||
})!
|
||||
parse_headers_test('foo: bar\r\nbar:baz\r\n', {
|
||||
'foo': 'bar'
|
||||
'bar': 'baz'
|
||||
})!
|
||||
parse_headers_test('foo: bar\r\nbar:baz\r\n\r\n', {
|
||||
'foo': 'bar'
|
||||
'bar': 'baz'
|
||||
})!
|
||||
assert parse_headers('foo: bar\r\nfoo:baz')!.custom_values('foo') == ['bar', 'baz']
|
||||
|
||||
if x := parse_headers(' oops: oh no') {
|
||||
return error('should have errored, but got ${x}')
|
||||
}
|
||||
}
|
||||
|
||||
fn test_set_cookie() {
|
||||
// multiple Set-Cookie headers should be sent when rendered
|
||||
mut h := new_header()
|
||||
h.add(.set_cookie, 'foo')
|
||||
h.add(.set_cookie, 'bar')
|
||||
assert h.render() == 'Set-Cookie: foo\r\nSet-Cookie: bar\r\n'
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
@[has_globals]
|
||||
module http
|
||||
|
||||
import net.urllib
|
||||
import time
|
||||
|
||||
const max_redirects = 16 // safari max - other browsers allow up to 20
|
||||
|
||||
const content_type_default = 'text/plain'
|
||||
|
||||
const bufsize = 64 * 1024
|
||||
|
||||
// FetchConfig holds configuration data for the fetch function.
|
||||
pub struct FetchConfig {
|
||||
pub mut:
|
||||
url string
|
||||
method Method = .get
|
||||
header Header
|
||||
data string
|
||||
params map[string]string
|
||||
cookies map[string]string
|
||||
user_agent string = 'v.http'
|
||||
user_ptr voidptr = unsafe { nil }
|
||||
verbose bool
|
||||
proxy &HttpProxy = unsafe { nil }
|
||||
read_timeout i64 = 30 * time.second // timeout for reading the response; applies to plain http and to direct https requests
|
||||
write_timeout i64 = 30 * time.second // timeout for writing the request; applies to plain http (write timeouts are not enforced on the SSL write path yet)
|
||||
|
||||
validate bool // set this to true, if you want to stop requests, when their certificates are found to be invalid
|
||||
verify string // the path to a rootca.pem file, containing trusted CA certificate(s)
|
||||
cert string // the path to a cert.pem file, containing client certificate(s) for the request
|
||||
cert_key string // the path to a key.pem file, containing private keys for the client certificate(s)
|
||||
in_memory_verification bool // if true, verify, cert, and cert_key are read from memory, not from a file
|
||||
allow_redirect bool = true // whether to allow redirect
|
||||
max_retries int = 5 // maximum number of retries required when an underlying socket error occurs
|
||||
enable_http2 bool = true // when true (the default) and the URL is https, advertise ALPN `h2, http/1.1` and use HTTP/2 if the server selects it; set to false to force HTTP/1.1. Ignored for plain http://, and for the Windows SChannel backend which has no ALPN yet (see vlang/v#27383). on_progress / on_progress_body / stop_copying_limit / stop_receiving_limit are honored on the HTTP/2 path; on_progress fires per DATA frame payload rather than per raw network read.
|
||||
disable_connection_reuse bool // opt out of the shared connection pool: open a fresh connection for this request, send `Connection: close`, and close the connection after the response (the pre-pooling behavior)
|
||||
// callbacks to allow custom reporting code to run, while the request is running, and to implement streaming
|
||||
on_redirect RequestRedirectFn = unsafe { nil }
|
||||
on_progress RequestProgressFn = unsafe { nil }
|
||||
on_progress_body RequestProgressBodyFn = unsafe { nil }
|
||||
on_finish RequestFinishFn = unsafe { nil }
|
||||
|
||||
stop_copying_limit i64 = -1 // after this many bytes are received, stop copying to the response. Note that on_progress and on_progress_body callbacks, will continue to fire normally, until the full response is read, which allows you to implement streaming downloads, without keeping the whole big response in memory
|
||||
stop_receiving_limit i64 = -1 // after this many bytes are received, break out of the loop that reads the response, effectively stopping the request early. No more on_progress callbacks will be fired. The on_finish callback will fire.
|
||||
}
|
||||
|
||||
// new_request creates a new Request given the request `method`, `url_`, and
|
||||
// `data`.
|
||||
pub fn new_request(method Method, url_ string, data string) Request {
|
||||
url := if method == .get && !url_.contains('?') { url_ + '?' + data } else { url_ }
|
||||
// println('new req() method=${method} url="${url}" dta="${data}"')
|
||||
return Request{
|
||||
method: method
|
||||
url: url
|
||||
data: data
|
||||
/*
|
||||
headers: {
|
||||
'Accept-Encoding': 'compress'
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
// get sends a GET HTTP request to the given `url`.
|
||||
pub fn get(url string) !Response {
|
||||
return fetch(method: .get, url: url)
|
||||
}
|
||||
|
||||
// post sends the string `data` as an HTTP POST request to the given `url`.
|
||||
pub fn post(url string, data string) !Response {
|
||||
return fetch(
|
||||
method: .post
|
||||
url: url
|
||||
data: data
|
||||
header: new_header(key: .content_type, value: content_type_default)
|
||||
)
|
||||
}
|
||||
|
||||
// post_json sends the JSON `data` as an HTTP POST request to the given `url`.
|
||||
pub fn post_json(url string, data string) !Response {
|
||||
return fetch(
|
||||
method: .post
|
||||
url: url
|
||||
data: data
|
||||
header: new_header(key: .content_type, value: 'application/json')
|
||||
)
|
||||
}
|
||||
|
||||
// post_form sends the map `data` as X-WWW-FORM-URLENCODED data to an HTTP POST request
|
||||
// to the given `url`.
|
||||
pub fn post_form(url string, data map[string]string) !Response {
|
||||
return fetch(
|
||||
method: .post
|
||||
url: url
|
||||
header: new_header(key: .content_type, value: 'application/x-www-form-urlencoded')
|
||||
data: url_encode_form_data(data)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn post_form_with_cookies(url string, data map[string]string, cookies map[string]string) !Response {
|
||||
return fetch(
|
||||
method: .post
|
||||
url: url
|
||||
header: new_header(key: .content_type, value: 'application/x-www-form-urlencoded')
|
||||
data: url_encode_form_data(data)
|
||||
cookies: cookies
|
||||
)
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct PostMultipartFormConfig {
|
||||
pub mut:
|
||||
form map[string]string
|
||||
files map[string][]FileData
|
||||
header Header
|
||||
}
|
||||
|
||||
// post_multipart_form sends multipart form data `conf` as an HTTP POST
|
||||
// request to the given `url`.
|
||||
pub fn post_multipart_form(url string, conf PostMultipartFormConfig) !Response {
|
||||
body, boundary := multipart_form_body(conf.form, conf.files)
|
||||
mut header := conf.header
|
||||
header.set(.content_type, 'multipart/form-data; boundary="${boundary}"')
|
||||
return fetch(
|
||||
method: .post
|
||||
url: url
|
||||
header: header
|
||||
data: body
|
||||
)
|
||||
}
|
||||
|
||||
// put sends string `data` as an HTTP PUT request to the given `url`.
|
||||
pub fn put(url string, data string) !Response {
|
||||
return fetch(
|
||||
method: .put
|
||||
url: url
|
||||
data: data
|
||||
header: new_header(key: .content_type, value: content_type_default)
|
||||
)
|
||||
}
|
||||
|
||||
// patch sends string `data` as an HTTP PATCH request to the given `url`.
|
||||
pub fn patch(url string, data string) !Response {
|
||||
return fetch(
|
||||
method: .patch
|
||||
url: url
|
||||
data: data
|
||||
header: new_header(key: .content_type, value: content_type_default)
|
||||
)
|
||||
}
|
||||
|
||||
// head sends an HTTP HEAD request to the given `url`.
|
||||
pub fn head(url string) !Response {
|
||||
return fetch(method: .head, url: url)
|
||||
}
|
||||
|
||||
// delete sends an HTTP DELETE request to the given `url`.
|
||||
pub fn delete(url string) !Response {
|
||||
return fetch(method: .delete, url: url)
|
||||
}
|
||||
|
||||
// prepare prepares a new request for fetching, but does not call its .do() method.
|
||||
// It is useful, if you want to reuse request objects, for several requests in a row,
|
||||
// modifying the request each time, then calling .do() to get the new response.
|
||||
pub fn prepare(config FetchConfig) !Request {
|
||||
if config.url == '' {
|
||||
return error('http.fetch: empty url')
|
||||
}
|
||||
url := build_url_from_fetch(config) or { return error('http.fetch: invalid url ${config.url}') }
|
||||
req := Request{
|
||||
method: config.method
|
||||
url: url
|
||||
data: config.data
|
||||
header: config.header
|
||||
cookies: config.cookies
|
||||
user_agent: config.user_agent
|
||||
user_ptr: config.user_ptr
|
||||
verbose: config.verbose
|
||||
validate: config.validate
|
||||
read_timeout: config.read_timeout
|
||||
write_timeout: config.write_timeout
|
||||
verify: config.verify
|
||||
cert: config.cert
|
||||
proxy: config.proxy
|
||||
cert_key: config.cert_key
|
||||
in_memory_verification: config.in_memory_verification
|
||||
allow_redirect: config.allow_redirect
|
||||
max_retries: config.max_retries
|
||||
enable_http2: config.enable_http2
|
||||
disable_connection_reuse: config.disable_connection_reuse
|
||||
on_progress: config.on_progress
|
||||
on_progress_body: config.on_progress_body
|
||||
on_redirect: config.on_redirect
|
||||
on_finish: config.on_finish
|
||||
stop_copying_limit: config.stop_copying_limit
|
||||
stop_receiving_limit: config.stop_receiving_limit
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// SchemeHandlerFn dispatches a `fetch()` call for a non-HTTP scheme. Used by
|
||||
// out-of-tree-friendly modules like `net.s3` to register themselves at init
|
||||
// time without forcing `net.http` to know about them statically.
|
||||
pub type SchemeHandlerFn = fn (config FetchConfig) !Response
|
||||
|
||||
__global scheme_handlers = map[string]SchemeHandlerFn{}
|
||||
|
||||
// register_scheme attaches `handler` as the dispatcher for URLs with the
|
||||
// given `scheme` (e.g. `'s3'`). Handlers are looked up by `fetch()` before
|
||||
// the native HTTP path runs. Modules typically call this from `init()`.
|
||||
pub fn register_scheme(scheme string, handler SchemeHandlerFn) {
|
||||
scheme_handlers[scheme] = handler
|
||||
}
|
||||
|
||||
// unregister_scheme removes a previously-registered scheme handler. Mostly
|
||||
// useful in tests that install a temporary handler.
|
||||
pub fn unregister_scheme(scheme string) {
|
||||
scheme_handlers.delete(scheme)
|
||||
}
|
||||
|
||||
fn scheme_of(url string) string {
|
||||
colon := url.index(':') or { return '' }
|
||||
if colon == 0 {
|
||||
return ''
|
||||
}
|
||||
return url[..colon]
|
||||
}
|
||||
|
||||
// TODO: @[noinline] attribute is used for temporary fix the 'get_text()' intermittent segfault / nil value when compiling with GCC 13.2.x and -prod option ( Issue #20506 )
|
||||
// fetch sends an HTTP request to the `url` with the given method and configuration.
|
||||
// When `config.url` uses a scheme registered via `register_scheme` (e.g.
|
||||
// `s3://`), the call is delegated to that handler instead of the native
|
||||
// HTTP path.
|
||||
@[noinline]
|
||||
pub fn fetch(config FetchConfig) !Response {
|
||||
if scheme_handlers.len > 0 {
|
||||
scheme := scheme_of(config.url)
|
||||
if scheme != '' && scheme != 'http' && scheme != 'https' {
|
||||
if h := scheme_handlers[scheme] {
|
||||
return h(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
req := prepare(config)!
|
||||
return req.do()!
|
||||
}
|
||||
|
||||
// get_text sends an HTTP GET request to the given `url` and returns the text content of the response.
|
||||
pub fn get_text(url string) string {
|
||||
resp := fetch(url: url, method: .get) or { return '' }
|
||||
return resp.body
|
||||
}
|
||||
|
||||
// url_encode_form_data converts mapped data to a URL encoded string.
|
||||
pub fn url_encode_form_data(data map[string]string) string {
|
||||
mut pieces := []string{}
|
||||
for key_, value_ in data {
|
||||
key := urllib.query_escape(key_)
|
||||
value := urllib.query_escape(value_)
|
||||
pieces << '${key}=${value}'
|
||||
}
|
||||
return pieces.join('&')
|
||||
}
|
||||
|
||||
fn build_url_from_fetch(config FetchConfig) !string {
|
||||
mut url := urllib.parse(config.url)!
|
||||
if config.params.len == 0 {
|
||||
return url.str()
|
||||
}
|
||||
mut pieces := []string{cap: config.params.len}
|
||||
for key, val in config.params {
|
||||
pieces << '${key}=${val}'
|
||||
}
|
||||
mut query := pieces.join('&')
|
||||
if url.raw_query.len > 1 {
|
||||
query = url.raw_query + '&' + query
|
||||
}
|
||||
url.raw_query = query
|
||||
return url.str()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// vtest retry: 3
|
||||
module http
|
||||
|
||||
// internal tests have access to *everything in the module*
|
||||
import json
|
||||
|
||||
struct HttpbinResponseBody {
|
||||
args map[string]string
|
||||
data string
|
||||
files map[string]string
|
||||
form map[string]string
|
||||
headers map[string]string
|
||||
json map[string]string
|
||||
origin string
|
||||
url string
|
||||
}
|
||||
|
||||
fn http_fetch_mock(_methods []string, _config FetchConfig) ![]Response {
|
||||
url := 'https://url4e.com/httpbin/'
|
||||
methods := if _methods.len == 0 { ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'] } else { _methods }
|
||||
mut config := _config
|
||||
mut result := []Response{}
|
||||
// Note: httpbin doesn't support head
|
||||
for method in methods {
|
||||
lmethod := method.to_lower()
|
||||
config.method = method_from_str(method)
|
||||
res := fetch(FetchConfig{ ...config, url: url + lmethod })!
|
||||
// TODO
|
||||
// body := json.decode(HttpbinResponseBody,res.body)!
|
||||
result << res
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fn test_http_fetch_bare() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
responses := http_fetch_mock([], FetchConfig{}) or { panic(err) }
|
||||
for response in responses {
|
||||
assert response.status() == .ok
|
||||
}
|
||||
}
|
||||
|
||||
fn test_http_fetch_with_data() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
responses := http_fetch_mock(['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
data: 'hello world'
|
||||
) or { panic(err) }
|
||||
for response in responses {
|
||||
payload := json.decode(HttpbinResponseBody, response.body) or { panic(err) }
|
||||
assert payload.data == 'hello world'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_http_fetch_with_params() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
responses := http_fetch_mock([],
|
||||
params: {
|
||||
'a': 'b'
|
||||
'c': 'd'
|
||||
}
|
||||
) or { panic(err) }
|
||||
for response in responses {
|
||||
// payload := json.decode(HttpbinResponseBody,response.body) or {
|
||||
// panic(err)
|
||||
// }
|
||||
assert response.status() == .ok
|
||||
// TODO
|
||||
// assert payload.args['a'] == 'b'
|
||||
// assert payload.args['c'] == 'd'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_http_fetch_with_headers() ! {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
mut header := new_header()
|
||||
header.add_custom('Test-Header', 'hello world')!
|
||||
responses := http_fetch_mock([],
|
||||
header: header
|
||||
) or { panic(err) }
|
||||
for response in responses {
|
||||
// payload := json.decode(HttpbinResponseBody,response.body) or {
|
||||
// panic(err)
|
||||
// }
|
||||
assert response.status() == .ok
|
||||
// TODO
|
||||
// assert payload.headers['Test-Header'] == 'hello world'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
module http
|
||||
|
||||
import encoding.base64
|
||||
import net
|
||||
import net.urllib
|
||||
import net.ssl
|
||||
import net.socks
|
||||
|
||||
@[heap]
|
||||
struct HttpProxy {
|
||||
mut:
|
||||
scheme string
|
||||
username string
|
||||
password string
|
||||
host string
|
||||
hostname string
|
||||
port int
|
||||
url string
|
||||
}
|
||||
|
||||
// dial_tcp_via_proxy connects to `host` through the proxy specified by `proxy_url`.
|
||||
// `host` should be in `host:port` form.
|
||||
pub fn dial_tcp_via_proxy(proxy_url string, host string) !&net.TcpConn {
|
||||
proxy := new_http_proxy(proxy_url)!
|
||||
return proxy.dial(host)!
|
||||
}
|
||||
|
||||
// new_http_proxy creates a new HttpProxy instance, from the given http proxy url in `raw_url`
|
||||
pub fn new_http_proxy(raw_url string) !&HttpProxy {
|
||||
mut url := urllib.parse(raw_url) or { return error('malformed proxy url') }
|
||||
scheme := url.scheme
|
||||
|
||||
if scheme !in ['http', 'https', 'socks5'] {
|
||||
return error('invalid scheme')
|
||||
}
|
||||
|
||||
url.path = ''
|
||||
url.raw_path = ''
|
||||
url.raw_query = ''
|
||||
url.fragment = ''
|
||||
mut username := ''
|
||||
mut password := ''
|
||||
|
||||
str_url := url.str()
|
||||
|
||||
mut host := url.host
|
||||
mut port := url.port().int()
|
||||
|
||||
if port == 0 {
|
||||
if scheme == 'https' {
|
||||
port = 443
|
||||
host += ':' + port.str()
|
||||
} else if scheme == 'http' {
|
||||
port = 80
|
||||
host += ':' + port.str()
|
||||
}
|
||||
}
|
||||
if port == 0 {
|
||||
return error('Unknown port')
|
||||
}
|
||||
|
||||
if u := url.user {
|
||||
username = u.username
|
||||
password = u.password
|
||||
}
|
||||
|
||||
return &HttpProxy{
|
||||
scheme: scheme
|
||||
username: username
|
||||
password: password
|
||||
host: host
|
||||
hostname: url.hostname()
|
||||
port: port
|
||||
url: str_url
|
||||
}
|
||||
}
|
||||
|
||||
// str returns the configured proxy URL for logging and debugging.
|
||||
pub fn (pr &HttpProxy) str() string {
|
||||
if isnil(pr) {
|
||||
return 'nil'
|
||||
}
|
||||
return pr.url
|
||||
}
|
||||
|
||||
// host format - ip:port
|
||||
fn (pr &HttpProxy) build_proxy_headers(host string) string {
|
||||
mut uheaders := []string{}
|
||||
address := host.all_before_last(':')
|
||||
uheaders << 'Proxy-Connection: Keep-Alive\r\n'
|
||||
if pr.username != '' {
|
||||
mut authinfo := ''
|
||||
|
||||
authinfo += pr.username
|
||||
if pr.password != '' {
|
||||
authinfo += ':${pr.password}'
|
||||
}
|
||||
|
||||
encoded_authinfo := base64.encode(authinfo.bytes())
|
||||
|
||||
uheaders << 'Proxy-Authorization: Basic ${encoded_authinfo}\r\n'
|
||||
}
|
||||
|
||||
version := Version.v1_1
|
||||
|
||||
return 'CONNECT ${host} ${version}\r\nHost: ${address}\r\n' + uheaders.join('') + '\r\n'
|
||||
}
|
||||
|
||||
fn read_proxy_connect_response(mut tcp net.TcpConn) !string {
|
||||
mut total_bytes_read := 0
|
||||
mut msg := [4096]u8{}
|
||||
mut buffer := [1]u8{}
|
||||
for total_bytes_read < msg.len {
|
||||
bytes_read := tcp.read_ptr(&buffer[0], 1)!
|
||||
if bytes_read == 0 {
|
||||
return error('proxy closed the connection while establishing a tunnel')
|
||||
}
|
||||
msg[total_bytes_read] = buffer[0]
|
||||
total_bytes_read++
|
||||
if total_bytes_read > 3 && msg[total_bytes_read - 1] == `\n`
|
||||
&& msg[total_bytes_read - 2] == `\r` && msg[total_bytes_read - 3] == `\n`
|
||||
&& msg[total_bytes_read - 4] == `\r` {
|
||||
return msg[..total_bytes_read].bytestr()
|
||||
}
|
||||
}
|
||||
return error('proxy response headers exceeded 4096 bytes')
|
||||
}
|
||||
|
||||
fn validate_proxy_connect_response(response string) ! {
|
||||
status_line := response.all_before('\r\n')
|
||||
if !status_line.starts_with('HTTP/1.1 200') && !status_line.starts_with('HTTP/1.0 200') {
|
||||
return error('proxy tunnel error: ${status_line}')
|
||||
}
|
||||
}
|
||||
|
||||
fn (pr &HttpProxy) connect_tcp(host string) !&net.TcpConn {
|
||||
if pr.scheme in ['http', 'https'] {
|
||||
mut tcp := net.dial_tcp(pr.host)!
|
||||
tcp.write(pr.build_proxy_headers(host).bytes())!
|
||||
response := read_proxy_connect_response(mut tcp)!
|
||||
validate_proxy_connect_response(response)!
|
||||
return tcp
|
||||
} else if pr.scheme == 'socks5' {
|
||||
return socks.socks5_dial(pr.host, host, pr.username, pr.password)!
|
||||
} else {
|
||||
return error('http_proxy connect_tcp: invalid proxy scheme')
|
||||
}
|
||||
}
|
||||
|
||||
fn (pr &HttpProxy) http_do(host urllib.URL, method Method, path string, req &Request, data string, header Header) !Response {
|
||||
host_name := host.hostname()
|
||||
mut port := host.port().int()
|
||||
if port == 0 {
|
||||
port = if host.scheme == 'https' { 443 } else { 80 }
|
||||
}
|
||||
port_part := if (host.scheme == 'http' && port == 80) || (host.scheme == 'https' && port == 443) {
|
||||
''
|
||||
} else {
|
||||
':${port}'
|
||||
}
|
||||
|
||||
s := req.build_request_headers_with(method, host_name, port,
|
||||
'${host.scheme}://${host_name}${port_part}${path}', data, header)
|
||||
if host.scheme == 'https' {
|
||||
mut client := pr.ssl_dial('${host_name}:${port}')!
|
||||
|
||||
$if windows {
|
||||
return error('Windows Not SUPPORTED') // TODO: windows ssl
|
||||
// response_text := req.do_request(req.build_request_headers(req.method, host_name,
|
||||
// path))!
|
||||
// client.shutdown()!
|
||||
// return response_text
|
||||
} $else {
|
||||
return req.do_request(req.build_request_headers_with(method, host_name, port, path,
|
||||
data, header), mut client)!
|
||||
}
|
||||
} else if host.scheme == 'http' {
|
||||
mut client := pr.dial('${host_name}:${port}')!
|
||||
client.set_read_timeout(req.read_timeout)
|
||||
client.set_write_timeout(req.write_timeout)
|
||||
client.write_string(s)!
|
||||
$if trace_http_request ? {
|
||||
eprintln('> ${s}')
|
||||
}
|
||||
response_data := req.read_all_from_client_connection(client)!
|
||||
client.close()!
|
||||
response_text := response_data.data.bytestr()
|
||||
$if trace_http_response ? {
|
||||
eprintln('< ${response_text}')
|
||||
}
|
||||
if req.on_finish != unsafe { nil } {
|
||||
req.on_finish(req, u64(response_text.len))!
|
||||
}
|
||||
return parse_received_response(response_text, response_data.info)
|
||||
}
|
||||
return error('Invalid Scheme')
|
||||
}
|
||||
|
||||
fn (pr &HttpProxy) dial(host string) !&net.TcpConn {
|
||||
return pr.connect_tcp(host)!
|
||||
}
|
||||
|
||||
fn (pr &HttpProxy) ssl_dial(host string) !&ssl.SSLConn {
|
||||
if pr.scheme in ['http', 'https'] {
|
||||
mut tcp := pr.connect_tcp(host)!
|
||||
mut ssl_conn := ssl.new_ssl_conn(
|
||||
verify: ''
|
||||
cert: ''
|
||||
cert_key: ''
|
||||
validate: false
|
||||
in_memory_verification: false
|
||||
)!
|
||||
ssl_conn.connect(mut tcp, host.all_before_last(':')) or {
|
||||
tcp.close() or {}
|
||||
return err
|
||||
}
|
||||
ssl_conn.owns_socket = true
|
||||
return ssl_conn
|
||||
} else if pr.scheme == 'socks5' {
|
||||
return socks.socks5_ssl_dial(pr.host, host, pr.username, pr.password)!
|
||||
} else {
|
||||
return error('http_proxy ssl_dial: invalid proxy scheme')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
// vtest retry: 3
|
||||
// vtest vflags: -d use_openssl
|
||||
// vtest build: !windows
|
||||
module http
|
||||
|
||||
import encoding.base64
|
||||
import net
|
||||
import net.mbedtls
|
||||
import net.urllib
|
||||
import os
|
||||
import time
|
||||
|
||||
const sample_proxy_url = 'https://localhost'
|
||||
const sample_auth_proxy_url = 'http://user:pass@localhost:8888'
|
||||
|
||||
const sample_host = '127.0.0.1:1337'
|
||||
const sample_request = &Request{
|
||||
url: 'http://${sample_host}'
|
||||
}
|
||||
const sample_path = '/'
|
||||
const proxy_https_request_count = 12
|
||||
const proxy_https_test_cert_path = @VEXEROOT +
|
||||
'/vlib/net/websocket/tests/autobahn/fuzzing_server_wss/config/server.crt'
|
||||
const proxy_https_test_key_path = @VEXEROOT +
|
||||
'/vlib/net/websocket/tests/autobahn/fuzzing_server_wss/config/server.key'
|
||||
|
||||
fn test_proxy_fields() ? {
|
||||
sample_proxy := new_http_proxy(sample_proxy_url)!
|
||||
sample_auth_proxy := new_http_proxy(sample_auth_proxy_url)!
|
||||
|
||||
assert sample_proxy.scheme == 'https'
|
||||
assert sample_proxy.host == 'localhost:443'
|
||||
assert sample_proxy.hostname == 'localhost'
|
||||
assert sample_proxy.port == 443
|
||||
assert sample_proxy.url == sample_proxy_url
|
||||
assert sample_auth_proxy.scheme == 'http'
|
||||
assert sample_auth_proxy.username == 'user'
|
||||
assert sample_auth_proxy.password == 'pass'
|
||||
assert sample_auth_proxy.host == 'localhost:8888'
|
||||
assert sample_auth_proxy.hostname == 'localhost'
|
||||
assert sample_auth_proxy.port == 8888
|
||||
assert sample_auth_proxy.url == sample_auth_proxy_url
|
||||
}
|
||||
|
||||
fn test_proxy_headers() ? {
|
||||
sample_proxy := new_http_proxy(sample_proxy_url)!
|
||||
headers := sample_proxy.build_proxy_headers(sample_host)
|
||||
|
||||
assert headers == 'CONNECT 127.0.0.1:1337 HTTP/1.1\r\n' + 'Host: 127.0.0.1\r\n' +
|
||||
'Proxy-Connection: Keep-Alive\r\n\r\n'
|
||||
}
|
||||
|
||||
fn test_proxy_headers_authenticated() ? {
|
||||
sample_proxy := new_http_proxy(sample_auth_proxy_url)!
|
||||
headers := sample_proxy.build_proxy_headers(sample_host)
|
||||
|
||||
auth_token := base64.encode(('${sample_proxy.username}:' + '${sample_proxy.password}').bytes())
|
||||
|
||||
assert headers == 'CONNECT 127.0.0.1:1337 HTTP/1.1\r\n' + 'Host: 127.0.0.1\r\n' +
|
||||
'Proxy-Connection: Keep-Alive\r\nProxy-Authorization: Basic ${auth_token}\r\n\r\n'
|
||||
}
|
||||
|
||||
enum ProxyTunnelCopyResult {
|
||||
data
|
||||
timeout
|
||||
closed
|
||||
}
|
||||
|
||||
fn count_open_file_descriptors() int {
|
||||
$if windows {
|
||||
return 0
|
||||
} $else {
|
||||
fds := os.ls('/dev/fd') or { return 0 }
|
||||
return fds.len
|
||||
}
|
||||
}
|
||||
|
||||
fn start_https_proxy_test_target_server() !(int, chan bool) {
|
||||
ready := chan int{cap: 1}
|
||||
done := chan bool{cap: 1}
|
||||
spawn fn [ready, done] () {
|
||||
mut port_listener := net.listen_tcp(.ip, '127.0.0.1:0') or { panic(err) }
|
||||
port := int((port_listener.addr() or { panic(err) }).port() or { panic(err) })
|
||||
port_listener.close() or {}
|
||||
mut listener := mbedtls.new_ssl_listener('127.0.0.1:${port}', mbedtls.SSLConnectConfig{
|
||||
cert: proxy_https_test_cert_path
|
||||
cert_key: proxy_https_test_key_path
|
||||
validate: false
|
||||
in_memory_verification: false
|
||||
}) or { panic(err) }
|
||||
ready <- port
|
||||
defer {
|
||||
listener.shutdown() or {}
|
||||
done <- true
|
||||
}
|
||||
for _ in 0 .. proxy_https_request_count {
|
||||
mut conn := listener.accept() or { panic(err) }
|
||||
handle_https_proxy_test_target_connection(mut conn)
|
||||
}
|
||||
}()
|
||||
return <-ready, done
|
||||
}
|
||||
|
||||
fn handle_https_proxy_test_target_connection(mut conn mbedtls.SSLConn) {
|
||||
defer {
|
||||
conn.shutdown() or {}
|
||||
}
|
||||
mut request_buf := []u8{len: 2048}
|
||||
_ = conn.read(mut request_buf) or { return }
|
||||
conn.write_string('HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok') or {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fn start_https_proxy_test_server(target_port int) !(int, chan bool) {
|
||||
ready := chan int{cap: 1}
|
||||
done := chan bool{cap: 1}
|
||||
spawn fn [ready, done, target_port] () {
|
||||
mut listener := net.listen_tcp(.ip, '127.0.0.1:0') or { panic(err) }
|
||||
port := int((listener.addr() or { panic(err) }).port() or { panic(err) })
|
||||
ready <- port
|
||||
mut workers := []thread{cap: proxy_https_request_count}
|
||||
for _ in 0 .. proxy_https_request_count {
|
||||
mut client := listener.accept() or { panic(err) }
|
||||
workers << spawn handle_https_proxy_test_tunnel(mut client, target_port)
|
||||
}
|
||||
listener.close() or {}
|
||||
workers.wait()
|
||||
done <- true
|
||||
}()
|
||||
return <-ready, done
|
||||
}
|
||||
|
||||
fn handle_https_proxy_test_tunnel(mut client net.TcpConn, target_port int) {
|
||||
defer {
|
||||
client.close() or {}
|
||||
}
|
||||
request := read_proxy_request(mut client) or { return }
|
||||
if !request.starts_with('CONNECT 127.0.0.1:${target_port} HTTP/1.1\r\n') {
|
||||
client.write_string('HTTP/1.1 400 Bad Request\r\n\r\n') or {}
|
||||
return
|
||||
}
|
||||
mut upstream := net.dial_tcp('127.0.0.1:${target_port}') or {
|
||||
client.write_string('HTTP/1.1 502 Bad Gateway\r\n\r\n') or {}
|
||||
return
|
||||
}
|
||||
defer {
|
||||
upstream.close() or {}
|
||||
}
|
||||
client.write_string('HTTP/1.1 200 Connection Established\r\n\r\n') or { return }
|
||||
client.set_read_timeout(50 * time.millisecond)
|
||||
upstream.set_read_timeout(50 * time.millisecond)
|
||||
deadline := time.now().add(2 * time.second)
|
||||
for time.now() < deadline {
|
||||
client_state := copy_https_proxy_test_tunnel_data(mut client, mut upstream)
|
||||
upstream_state := copy_https_proxy_test_tunnel_data(mut upstream, mut client)
|
||||
if client_state == .closed || upstream_state == .closed {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_proxy_request(mut conn net.TcpConn) !string {
|
||||
mut total_bytes_read := 0
|
||||
mut msg := [4096]u8{}
|
||||
mut buffer := [1]u8{}
|
||||
for total_bytes_read < msg.len {
|
||||
bytes_read := conn.read_ptr(&buffer[0], 1)!
|
||||
if bytes_read == 0 {
|
||||
return error('unexpected EOF while reading proxy request')
|
||||
}
|
||||
msg[total_bytes_read] = buffer[0]
|
||||
total_bytes_read++
|
||||
if total_bytes_read > 3 && msg[total_bytes_read - 1] == `\n`
|
||||
&& msg[total_bytes_read - 2] == `\r` && msg[total_bytes_read - 3] == `\n`
|
||||
&& msg[total_bytes_read - 4] == `\r` {
|
||||
return msg[..total_bytes_read].bytestr()
|
||||
}
|
||||
}
|
||||
return error('proxy request headers exceeded 4096 bytes')
|
||||
}
|
||||
|
||||
fn copy_https_proxy_test_tunnel_data(mut src net.TcpConn, mut dst net.TcpConn) ProxyTunnelCopyResult {
|
||||
mut buf := []u8{len: 1024}
|
||||
bytes_read := src.read(mut buf) or {
|
||||
if err.code() == net.err_timed_out_code {
|
||||
return .timeout
|
||||
}
|
||||
return .closed
|
||||
}
|
||||
if bytes_read <= 0 {
|
||||
return .closed
|
||||
}
|
||||
dst.write(buf[..bytes_read]) or { return .closed }
|
||||
return .data
|
||||
}
|
||||
|
||||
fn test_https_proxy_requests_do_not_leak_sockets() ! {
|
||||
$if windows {
|
||||
return
|
||||
}
|
||||
$if sanitized_job ? {
|
||||
return
|
||||
}
|
||||
$if tinyc {
|
||||
// TinyCC hangs in the bundled mbedtls handshake path on linux CI.
|
||||
return
|
||||
}
|
||||
target_port, target_done := start_https_proxy_test_target_server()!
|
||||
proxy_port, proxy_done := start_https_proxy_test_server(target_port)!
|
||||
baseline_fds := count_open_file_descriptors()
|
||||
proxy := new_http_proxy('http://127.0.0.1:${proxy_port}')!
|
||||
for _ in 0 .. proxy_https_request_count {
|
||||
resp := fetch(
|
||||
method: .get
|
||||
url: 'https://127.0.0.1:${target_port}/'
|
||||
proxy: proxy
|
||||
validate: false
|
||||
)!
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == 'ok'
|
||||
}
|
||||
_ = <-target_done
|
||||
_ = <-proxy_done
|
||||
time.sleep(100 * time.millisecond)
|
||||
final_fds := count_open_file_descriptors()
|
||||
assert final_fds <= baseline_fds + 3
|
||||
}
|
||||
|
||||
fn test_http_proxy_do() {
|
||||
env := os.environ()
|
||||
mut env_proxy := ''
|
||||
|
||||
for envvar in ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY'] {
|
||||
prox_val := env[envvar] or { continue }
|
||||
if prox_val != '' {
|
||||
env_proxy = env[envvar]
|
||||
}
|
||||
}
|
||||
if env_proxy != '' {
|
||||
println('Has usable proxy env vars')
|
||||
proxy := new_http_proxy(env_proxy)!
|
||||
mut header := new_header(key: .user_agent, value: 'vlib')
|
||||
header.add_custom('X-Vlang-Test', 'proxied')!
|
||||
res := proxy.http_do(urllib.parse('http://httpbin.org/headers')!, Method.get, '/headers', &Request{
|
||||
proxy: proxy
|
||||
header: header
|
||||
}, '', header)!
|
||||
println(res.status_code)
|
||||
println('he4aders ${res.header}')
|
||||
assert res.status_code == 200
|
||||
// assert res.header.data['X-Vlang-Test'] == 'proxied'
|
||||
} else {
|
||||
println('Proxy env vars (HTTP_PROXY or HTTPS_PROXY) not set. Skipping test.')
|
||||
}
|
||||
}
|
||||
|
||||
const multipart_https_payload_len = 20 * 1024 + 137
|
||||
|
||||
fn test_https_multipart_form_preserves_large_binary_body() ! {
|
||||
$if tinyc {
|
||||
// TinyCC hangs in the bundled mbedtls handshake path on linux CI.
|
||||
return
|
||||
}
|
||||
mut port_listener := net.listen_tcp(.ip, '127.0.0.1:0')!
|
||||
port := port_listener.addr()!.port()!
|
||||
port_listener.close()!
|
||||
|
||||
payload := multipart_https_test_payload()
|
||||
form := {
|
||||
'alpha': 'beta'
|
||||
}
|
||||
files := {
|
||||
'file': [
|
||||
FileData{
|
||||
filename: 'payload.bin'
|
||||
content_type: 'application/octet-stream'
|
||||
data: payload
|
||||
},
|
||||
]
|
||||
}
|
||||
body, boundary := multipart_form_body(form, files)
|
||||
|
||||
mut listener := mbedtls.new_ssl_listener('127.0.0.1:${port}', mbedtls.SSLConnectConfig{
|
||||
cert: proxy_https_test_cert_path
|
||||
cert_key: proxy_https_test_key_path
|
||||
validate: false
|
||||
})!
|
||||
server := spawn multipart_https_serve_once(mut listener, body, boundary, form, files)
|
||||
|
||||
mut header := new_header()
|
||||
header.set(.content_type, 'multipart/form-data; boundary="${boundary}"')
|
||||
resp := fetch(
|
||||
method: .post
|
||||
url: 'https://127.0.0.1:${port}/upload'
|
||||
header: header
|
||||
data: body
|
||||
validate: false
|
||||
)!
|
||||
server.wait()
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == 'ok'
|
||||
}
|
||||
|
||||
fn multipart_https_test_payload() string {
|
||||
mut payload := []u8{len: multipart_https_payload_len, init: u8(((index * 17) % 250) + 1)}
|
||||
payload[127] = 0
|
||||
payload[4096] = 0
|
||||
payload[16 * 1024] = 0
|
||||
payload[payload.len - 1] = `!`
|
||||
return payload.bytestr()
|
||||
}
|
||||
|
||||
fn multipart_https_serve_once(mut listener mbedtls.SSLListener, expected_body string, boundary string, expected_form map[string]string, expected_files map[string][]FileData) {
|
||||
defer {
|
||||
listener.shutdown() or {}
|
||||
}
|
||||
mut conn := listener.accept() or { panic(err) }
|
||||
conn.set_read_timeout(5 * time.second)
|
||||
defer {
|
||||
conn.shutdown() or {}
|
||||
}
|
||||
|
||||
request_text := read_https_request(mut conn) or { panic(err) }
|
||||
req := parse_request_str(request_text) or { panic(err) }
|
||||
|
||||
assert req.method == .post
|
||||
assert req.url == '/upload'
|
||||
assert req.data == expected_body
|
||||
assert req.data.len == expected_body.len
|
||||
assert req.header.get(.content_length) or { panic(err) } == expected_body.len.str()
|
||||
assert req.header.get(.content_type) or { panic(err) } == 'multipart/form-data; boundary="${boundary}"'
|
||||
|
||||
form, files := parse_multipart_form(req.data, boundary)
|
||||
assert form == expected_form
|
||||
assert files == expected_files
|
||||
|
||||
conn.write_string('HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok') or {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_https_request(mut conn mbedtls.SSLConn) !string {
|
||||
mut request := []u8{}
|
||||
mut buf := []u8{len: 1024}
|
||||
mut content_length := -1
|
||||
mut headers_end := -1
|
||||
|
||||
for {
|
||||
n := conn.read(mut buf) or {
|
||||
if err.code() == net.err_timed_out_code {
|
||||
return error('timed out while reading HTTPS request')
|
||||
}
|
||||
return err
|
||||
}
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
request << buf[..n]
|
||||
|
||||
request_str := request.bytestr()
|
||||
if headers_end == -1 {
|
||||
headers_end = request_str.index('\r\n\r\n') or { -1 }
|
||||
if headers_end != -1 {
|
||||
headers := request_str[..headers_end]
|
||||
for line in headers.split('\r\n') {
|
||||
if line.to_lower().starts_with('content-length:') {
|
||||
content_length = line.all_after(':').trim_space().int()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if headers_end != -1 && content_length >= 0 {
|
||||
body_start := headers_end + 4
|
||||
if request.len - body_start >= content_length {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if headers_end == -1 {
|
||||
return error('HTTPS request did not include a full header block')
|
||||
}
|
||||
if content_length < 0 {
|
||||
return error('HTTPS request did not include Content-Length')
|
||||
}
|
||||
body_start := headers_end + 4
|
||||
if request.len - body_start < content_length {
|
||||
return error('HTTPS request body was truncated: expected ${content_length} bytes, got ${request.len - body_start}')
|
||||
}
|
||||
return request.bytestr()
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import net
|
||||
import net.http
|
||||
import time
|
||||
|
||||
fn test_https_get() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
assert http.get_text('https://vlang.io/version') == '0.1.5'
|
||||
println('https ok')
|
||||
}
|
||||
|
||||
fn test_http_get_from_vlang_utc_now() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
url := 'http://vlang.io/utc_now'
|
||||
println('Test getting current time from HTTP ${url} by http.get')
|
||||
res := http.get(url) or { panic(err) }
|
||||
assert res.status() == .ok
|
||||
assert res.body != ''
|
||||
assert res.body.int() > 1566403696
|
||||
println('Current time is: ${res.body.int()}')
|
||||
}
|
||||
|
||||
fn test_https_get_from_vlang_utc_now() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
url := 'https://vlang.io/utc_now'
|
||||
println('Test getting current time from HTTPS ${url} by http.get')
|
||||
res := http.get(url) or { panic(err) }
|
||||
assert res.status() == .ok
|
||||
assert res.body != ''
|
||||
assert res.body.int() > 1566403696
|
||||
println('Current time is: ${res.body.int()}')
|
||||
}
|
||||
|
||||
fn test_http_public_servers() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
urls := [
|
||||
'http://github.com/robots.txt',
|
||||
'http://google.com/robots.txt',
|
||||
// 'http://yahoo.com/robots.txt',
|
||||
]
|
||||
for url in urls {
|
||||
println('Testing http.get on public HTTP url: ${url} ')
|
||||
res := http.get(url) or { panic(err) }
|
||||
assert res.status() == .ok
|
||||
assert res.body != ''
|
||||
}
|
||||
}
|
||||
|
||||
fn test_https_public_servers() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
urls := [
|
||||
'https://github.com/robots.txt',
|
||||
'https://google.com/robots.txt',
|
||||
// 'https://yahoo.com/robots.txt',
|
||||
]
|
||||
for url in urls {
|
||||
println('Testing http.get on public HTTPS url: ${url} ')
|
||||
res := http.get(url) or { panic(err) }
|
||||
assert res.status() == .ok
|
||||
assert res.body != ''
|
||||
}
|
||||
}
|
||||
|
||||
struct RelativeRedirectHandler {}
|
||||
|
||||
fn (mut handler RelativeRedirectHandler) handle(req http.Request) http.Response {
|
||||
mut res := http.Response{}
|
||||
match req.url {
|
||||
'/relative-redirect/3?abc=xyz' {
|
||||
res.header = http.new_header(key: .location, value: '/relative-redirect/2?abc=xyz')
|
||||
res.set_status(.found)
|
||||
}
|
||||
'/relative-redirect/2?abc=xyz' {
|
||||
res.header = http.new_header(key: .location, value: '/relative-redirect/1?abc=xyz')
|
||||
res.set_status(.found)
|
||||
}
|
||||
'/relative-redirect/1?abc=xyz' {
|
||||
res.header = http.new_header(key: .location, value: '/get?abc=xyz')
|
||||
res.set_status(.found)
|
||||
}
|
||||
'/get?abc=xyz' {
|
||||
res.body = '{"args": {"abc": "xyz"}}'
|
||||
res.set_status(.ok)
|
||||
}
|
||||
else {
|
||||
res.body = req.url
|
||||
res.set_status(.not_found)
|
||||
}
|
||||
}
|
||||
|
||||
res.set_version(req.version)
|
||||
return res
|
||||
}
|
||||
|
||||
fn test_relative_redirects() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
mut server := &http.Server{
|
||||
accept_timeout: 100 * time.millisecond
|
||||
handler: RelativeRedirectHandler{}
|
||||
addr: '127.0.0.1:0'
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running() or {
|
||||
server.stop()
|
||||
t.wait()
|
||||
panic(err)
|
||||
}
|
||||
defer {
|
||||
server.stop()
|
||||
t.wait()
|
||||
}
|
||||
res := http.get('http://${server.addr}/relative-redirect/3?abc=xyz') or { panic(err) }
|
||||
assert res.status() == .ok
|
||||
assert res.body != ''
|
||||
assert res.body.contains('"abc": "xyz"')
|
||||
}
|
||||
|
||||
fn user_agent_response_body(request string) string {
|
||||
for line in request.split('\r\n') {
|
||||
if line.to_lower().starts_with('user-agent:') {
|
||||
user_agent := line.all_after(':').trim_space()
|
||||
return '{"user-agent": "${user_agent}"}'
|
||||
}
|
||||
}
|
||||
return '{"user-agent": ""}'
|
||||
}
|
||||
|
||||
fn serve_user_agent_once(mut listener net.TcpListener) {
|
||||
mut conn := listener.accept() or {
|
||||
listener.close() or {}
|
||||
return
|
||||
}
|
||||
defer {
|
||||
conn.close() or {}
|
||||
listener.close() or {}
|
||||
}
|
||||
mut request := ''
|
||||
mut buf := []u8{len: 4096}
|
||||
for {
|
||||
n := conn.read(mut buf) or { return }
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
request += buf[..n].bytestr()
|
||||
if request.contains('\r\n\r\n') {
|
||||
break
|
||||
}
|
||||
}
|
||||
body := user_agent_response_body(request)
|
||||
response := 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${body.len}\r\nConnection: close\r\n\r\n${body}'
|
||||
conn.write(response.bytes()) or {}
|
||||
}
|
||||
|
||||
fn start_user_agent_server() !(int, thread) {
|
||||
mut listener := net.listen_tcp(.ip, '127.0.0.1:0')!
|
||||
port := listener.addr()!.port()!
|
||||
return port, spawn serve_user_agent_once(mut listener)
|
||||
}
|
||||
|
||||
fn test_default_user_agent() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
port, server := start_user_agent_server()!
|
||||
res := http.get('http://127.0.0.1:${port}/user-agent') or {
|
||||
server.wait()
|
||||
panic(err)
|
||||
}
|
||||
server.wait()
|
||||
assert res.status() == .ok
|
||||
assert res.body != ''
|
||||
assert res.body.contains('"user-agent": "v.http"')
|
||||
}
|
||||
|
||||
fn test_custom_user_agent() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
ua := 'V http test for UA'
|
||||
port, server := start_user_agent_server()!
|
||||
mut req := http.new_request(.get, 'http://127.0.0.1:${port}/user-agent', '')
|
||||
req.user_agent = ua
|
||||
res := req.do() or {
|
||||
server.wait()
|
||||
panic(err)
|
||||
}
|
||||
server.wait()
|
||||
assert res.status() == .ok
|
||||
assert res.body != ''
|
||||
assert res.body.contains('"user-agent": "${ua}"')
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// The methods listed here are all of those on the list available at:
|
||||
// https://www.iana.org/assignments/http-methods/http-methods.xhtml
|
||||
pub enum Method { // as of 2023-06-20
|
||||
get // Note: get ***should*** remain the first value here, to ensure that http.fetch() by default will use it
|
||||
head
|
||||
post
|
||||
put
|
||||
// uncommon ones:
|
||||
acl
|
||||
baseline_control
|
||||
bind
|
||||
checkin
|
||||
checkout
|
||||
connect
|
||||
copy
|
||||
delete
|
||||
label
|
||||
link
|
||||
lock
|
||||
merge
|
||||
mkactivity
|
||||
mkcalendar
|
||||
mkcol
|
||||
mkredirectref
|
||||
mkworkspace
|
||||
move
|
||||
options
|
||||
orderpatch
|
||||
patch
|
||||
pri
|
||||
propfind
|
||||
proppatch
|
||||
rebind
|
||||
report
|
||||
search
|
||||
trace
|
||||
unbind
|
||||
uncheckout
|
||||
unlink
|
||||
unlock
|
||||
update
|
||||
updateredirectref
|
||||
version_control
|
||||
}
|
||||
|
||||
// str returns the string representation of the HTTP Method `m`.
|
||||
pub fn (m Method) str() string {
|
||||
return match m {
|
||||
.get { 'GET' }
|
||||
.head { 'HEAD' }
|
||||
.post { 'POST' }
|
||||
.put { 'PUT' }
|
||||
// uncommon ones:
|
||||
.acl { 'ACL' }
|
||||
.baseline_control { 'BASELINE-CONTROL' }
|
||||
.bind { 'BIND' }
|
||||
.checkin { 'CHECKIN' }
|
||||
.checkout { 'CHECKOUT' }
|
||||
.connect { 'CONNECT' }
|
||||
.copy { 'COPY' }
|
||||
.delete { 'DELETE' }
|
||||
.label { 'LABEL' }
|
||||
.link { 'LINK' }
|
||||
.lock { 'LOCK' }
|
||||
.merge { 'MERGE' }
|
||||
.mkactivity { 'MKACTIVITY' }
|
||||
.mkcalendar { 'MKCALENDAR' }
|
||||
.mkcol { 'MKCOL' }
|
||||
.mkredirectref { 'MKREDIRECTREF' }
|
||||
.mkworkspace { 'MKWORKSPACE' }
|
||||
.move { 'MOVE' }
|
||||
.options { 'OPTIONS' }
|
||||
.orderpatch { 'ORDERPATCH' }
|
||||
.patch { 'PATCH' }
|
||||
.pri { 'PRI' }
|
||||
.propfind { 'PROPFIND' }
|
||||
.proppatch { 'PROPPATCH' }
|
||||
.rebind { 'REBIND' }
|
||||
.report { 'REPORT' }
|
||||
.search { 'SEARCH' }
|
||||
.trace { 'TRACE' }
|
||||
.unbind { 'UNBIND' }
|
||||
.uncheckout { 'UNCHECKOUT' }
|
||||
.unlink { 'UNLINK' }
|
||||
.unlock { 'UNLOCK' }
|
||||
.update { 'UPDATE' }
|
||||
.updateredirectref { 'UPDATEREDIRECTREF' }
|
||||
.version_control { 'VERSION-CONTROL' }
|
||||
}
|
||||
}
|
||||
|
||||
// method_from_str returns the corresponding Method enum field
|
||||
// given a string `m`, e.g. `'GET'` would return Method.get.
|
||||
//
|
||||
// Currently, the default value is Method.get for unsupported string value.
|
||||
pub fn method_from_str(m string) Method {
|
||||
return match m {
|
||||
'GET' { Method.get }
|
||||
'HEAD' { Method.head }
|
||||
'POST' { Method.post }
|
||||
'PUT' { Method.put }
|
||||
// uncommon ones:
|
||||
'ACL' { Method.acl }
|
||||
'BASELINE-CONTROL' { Method.baseline_control }
|
||||
'BIND' { Method.bind }
|
||||
'CHECKIN' { Method.checkin }
|
||||
'CHECKOUT' { Method.checkout }
|
||||
'CONNECT' { Method.connect }
|
||||
'COPY' { Method.copy }
|
||||
'DELETE' { Method.delete }
|
||||
'LABEL' { Method.label }
|
||||
'LINK' { Method.link }
|
||||
'LOCK' { Method.lock }
|
||||
'MERGE' { Method.merge }
|
||||
'MKACTIVITY' { Method.mkactivity }
|
||||
'MKCALENDAR' { Method.mkcalendar }
|
||||
'MKCOL' { Method.mkcol }
|
||||
'MKREDIRECTREF' { Method.mkredirectref }
|
||||
'MKWORKSPACE' { Method.mkworkspace }
|
||||
'MOVE' { Method.move }
|
||||
'OPTIONS' { Method.options }
|
||||
'ORDERPATCH' { Method.orderpatch }
|
||||
'PATCH' { Method.patch }
|
||||
'PRI' { Method.pri }
|
||||
'PROPFIND' { Method.propfind }
|
||||
'PROPPATCH' { Method.proppatch }
|
||||
'REBIND' { Method.rebind }
|
||||
'REPORT' { Method.report }
|
||||
'SEARCH' { Method.search }
|
||||
'TRACE' { Method.trace }
|
||||
'UNBIND' { Method.unbind }
|
||||
'UNCHECKOUT' { Method.uncheckout }
|
||||
'UNLINK' { Method.unlink }
|
||||
'UNLOCK' { Method.unlock }
|
||||
'UPDATE' { Method.update }
|
||||
'UPDATEREDIRECTREF' { Method.updateredirectref }
|
||||
'VERSION-CONTROL' { Method.version_control }
|
||||
else { Method.get } // always default to .get, it is the safest
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import net.http
|
||||
import json
|
||||
|
||||
struct MimeType {
|
||||
source string
|
||||
extensions []string
|
||||
compressible bool
|
||||
charset string
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mime_folder := dir(executable())
|
||||
chdir(mime_folder)!
|
||||
//
|
||||
mt_json := http.get('https://raw.githubusercontent.com/jshttp/mime-db/master/db.json')!
|
||||
mt_map := json.decode(map[string]MimeType, mt_json.body)!
|
||||
|
||||
mut ext_to_mt_str := map[string]string{}
|
||||
for mt_str, mt in mt_map {
|
||||
for ext in mt.extensions {
|
||||
ext_to_mt_str[ext] = mt_str
|
||||
}
|
||||
}
|
||||
ext_to_mt_str['v'] = 'text/x-vlang'
|
||||
ext_to_mt_str['vsh'] = 'text/x-vlang'
|
||||
|
||||
write_file('db.v', '
|
||||
module mime
|
||||
|
||||
// FILE AUTOGENERATED BY `build.vsh` - DO NOT MANUALLY EDIT
|
||||
|
||||
const db = ${mt_map}
|
||||
|
||||
const ext_to_mt_str = ${ext_to_mt_str}
|
||||
')!
|
||||
execute('${@VEXE} fmt -w db.v')
|
||||
println('db.v was regenerated. New file size: ${file_size('db.v')} bytes .')
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
module mime
|
||||
|
||||
pub struct MimeType {
|
||||
source string
|
||||
extensions []string
|
||||
compressible bool
|
||||
charset string
|
||||
}
|
||||
|
||||
// returns a `MimeType` for the given MIME type
|
||||
pub fn get_complete_mime_type(mt string) MimeType {
|
||||
return db[mt]
|
||||
}
|
||||
|
||||
// returns the MIME type for the given file extension
|
||||
pub fn get_mime_type(ext string) string {
|
||||
return ext_to_mt_str[ext]
|
||||
}
|
||||
|
||||
// returns a `content-type` header ready to use for the given MIME type
|
||||
pub fn get_content_type(mt string) string {
|
||||
mt_struct := db[mt]
|
||||
charset := if mt_struct.charset.len > 0 { mt_struct.charset.to_lower() } else { 'utf-8' }
|
||||
return '${mt}; charset=${charset}'
|
||||
}
|
||||
|
||||
// returns the default extension for the given MIME type
|
||||
pub fn get_default_ext(mt string) string {
|
||||
return if db[mt].extensions.len > 0 {
|
||||
db[mt].extensions[0]
|
||||
} else {
|
||||
''
|
||||
}
|
||||
}
|
||||
|
||||
// returns true if the given MIME type exists
|
||||
pub fn exists(mt string) bool {
|
||||
return mt in db
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
module mime
|
||||
|
||||
fn test_mime() {
|
||||
assert get_complete_mime_type('application/json') == MimeType{
|
||||
source: 'iana'
|
||||
extensions: ['json', 'map']
|
||||
compressible: true
|
||||
charset: 'UTF-8'
|
||||
}
|
||||
assert get_mime_type('json') == 'application/json'
|
||||
assert get_content_type('application/json') == 'application/json; charset=utf-8'
|
||||
assert get_default_ext('application/json') == 'json'
|
||||
|
||||
assert get_complete_mime_type('text/markdown') == MimeType{
|
||||
source: 'iana'
|
||||
extensions: ['md', 'markdown']
|
||||
compressible: true
|
||||
charset: ''
|
||||
}
|
||||
assert get_mime_type('md') == 'text/markdown'
|
||||
assert get_content_type('text/markdown') == 'text/markdown; charset=utf-8'
|
||||
assert get_default_ext('text/markdown') == 'md'
|
||||
|
||||
assert exists('application/json') == true
|
||||
assert exists('udfsbsfib') == false
|
||||
|
||||
assert get_default_ext('application/1d-interleaved-parityfec') == '' // valid mime type without associated extension
|
||||
assert get_default_ext('invalid mime type') == '' // invalid mime type
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
||||
module http
|
||||
|
||||
import io
|
||||
import net
|
||||
import strings
|
||||
|
||||
fn receive_all_data_timeout_cb(_ voidptr, _ &u8, _ int) !int {
|
||||
return error_with_code('read timed out', net.err_timed_out_code)
|
||||
}
|
||||
|
||||
fn receive_all_data_eof_cb(_ voidptr, _ &u8, _ int) !int {
|
||||
return io.Eof{}
|
||||
}
|
||||
|
||||
struct ReceiveAllDataFixture {
|
||||
parts []string
|
||||
mut:
|
||||
index int
|
||||
}
|
||||
|
||||
fn receive_all_data_fixture_cb(con voidptr, buf &u8, bufsize int) !int {
|
||||
mut fixture := unsafe { &ReceiveAllDataFixture(con) }
|
||||
if fixture.index >= fixture.parts.len {
|
||||
return io.Eof{}
|
||||
}
|
||||
part := fixture.parts[fixture.index].bytes()
|
||||
fixture.index++
|
||||
assert part.len <= bufsize
|
||||
mut out := unsafe { buf.vbytes(bufsize) }
|
||||
return copy(mut out, part)
|
||||
}
|
||||
|
||||
struct ProgressBodyCapture {
|
||||
mut:
|
||||
data []u8
|
||||
reads []u64
|
||||
expected []u64
|
||||
statuses []int
|
||||
}
|
||||
|
||||
fn receive_all_data_progress_body_cb(request &Request, chunk []u8, body_so_far u64, expected_size u64, status_code int) ! {
|
||||
mut capture := unsafe { &ProgressBodyCapture(request.user_ptr) }
|
||||
capture.data << chunk
|
||||
capture.reads << body_so_far
|
||||
capture.expected << expected_size
|
||||
capture.statuses << status_code
|
||||
}
|
||||
|
||||
fn test_receive_all_data_from_cb_in_builder_propagates_non_eof_errors() {
|
||||
mut req := Request{}
|
||||
mut content := strings.new_builder(64)
|
||||
req.receive_all_data_from_cb_in_builder(mut content, unsafe { nil },
|
||||
receive_all_data_timeout_cb) or {
|
||||
assert err.code() == net.err_timed_out_code
|
||||
return
|
||||
}
|
||||
panic('expected a timeout error')
|
||||
}
|
||||
|
||||
fn test_receive_all_data_from_cb_in_builder_stops_on_eof() {
|
||||
mut req := Request{}
|
||||
mut content := strings.new_builder(64)
|
||||
req.receive_all_data_from_cb_in_builder(mut content, unsafe { nil }, receive_all_data_eof_cb) or {
|
||||
panic('unexpected error: ${err}')
|
||||
}
|
||||
assert content.str() == ''
|
||||
}
|
||||
|
||||
fn test_receive_all_data_from_cb_in_builder_dechunks_progress_body_and_parses_truncated_chunked_response() {
|
||||
parts := [
|
||||
'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/plain\r\n\r\n4\r\nWi',
|
||||
'ki\r\n6',
|
||||
'\r\nped',
|
||||
'ia!\r\n0\r',
|
||||
'\n\r\n',
|
||||
]
|
||||
full_response := parts.join('')
|
||||
mut fixture := ReceiveAllDataFixture{
|
||||
parts: parts
|
||||
}
|
||||
mut capture := ProgressBodyCapture{}
|
||||
mut req := Request{
|
||||
on_progress_body: receive_all_data_progress_body_cb
|
||||
stop_copying_limit: i64(full_response.len - 1)
|
||||
user_ptr: voidptr(&capture)
|
||||
}
|
||||
mut content := strings.new_builder(64)
|
||||
response_info := req.receive_all_data_from_cb_in_builder(mut content, voidptr(&fixture),
|
||||
receive_all_data_fixture_cb)!
|
||||
assert response_info.is_chunked_transfer
|
||||
assert response_info.has_truncated_body
|
||||
assert capture.data.bytestr() == 'Wikipedia!'
|
||||
assert capture.reads == [u64(2), 4, 7, 10]
|
||||
assert capture.expected == [u64(0), 0, 0, 0]
|
||||
assert capture.statuses == [200, 200, 200, 200]
|
||||
resp := parse_received_response(content.str(), response_info)!
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == ''
|
||||
}
|
||||
|
||||
fn test_receive_all_data_from_cb_in_builder_errors_on_premature_eof_with_content_length() {
|
||||
mut fixture := ReceiveAllDataFixture{
|
||||
parts: [
|
||||
'HTTP/1.1 200 OK\r\nContent-Length: 10\r\nContent-Type: text/plain\r\n\r\nhello',
|
||||
]
|
||||
}
|
||||
mut req := Request{}
|
||||
mut content := strings.new_builder(64)
|
||||
req.receive_all_data_from_cb_in_builder(mut content, voidptr(&fixture),
|
||||
receive_all_data_fixture_cb) or {
|
||||
assert err.msg().contains('response body ended early')
|
||||
assert err.msg().contains('5 of 10 bytes')
|
||||
return
|
||||
}
|
||||
panic('expected an early EOF error for a truncated fixed-length response')
|
||||
}
|
||||
|
||||
fn test_receive_all_data_from_cb_in_builder_errors_on_incomplete_chunked_response() {
|
||||
mut fixture := ReceiveAllDataFixture{
|
||||
parts: [
|
||||
'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/plain\r\n\r\n4\r\nWi',
|
||||
'ki\r\n6\r\nped',
|
||||
]
|
||||
}
|
||||
mut req := Request{}
|
||||
mut content := strings.new_builder(64)
|
||||
req.receive_all_data_from_cb_in_builder(mut content, voidptr(&fixture),
|
||||
receive_all_data_fixture_cb) or {
|
||||
assert err.msg() == 'http.request: incomplete chunked response'
|
||||
return
|
||||
}
|
||||
panic('expected an early EOF error for an incomplete chunked response')
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// vtest build: !windows
|
||||
import net.http
|
||||
import net.urllib
|
||||
import net
|
||||
import io
|
||||
import time
|
||||
|
||||
struct StringReader {
|
||||
text string
|
||||
mut:
|
||||
place int
|
||||
}
|
||||
|
||||
fn (mut s StringReader) read(mut buf []u8) !int {
|
||||
if s.place >= s.text.len {
|
||||
return io.Eof{}
|
||||
}
|
||||
max_bytes := 100
|
||||
end := if s.place + max_bytes >= s.text.len { s.text.len } else { s.place + max_bytes }
|
||||
n := copy(mut buf, s.text[s.place..end].bytes())
|
||||
s.place += n
|
||||
return n
|
||||
}
|
||||
|
||||
fn reader(s string) &io.BufferedReader {
|
||||
return io.new_buffered_reader(
|
||||
reader: &StringReader{
|
||||
text: s
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn test_parse_request_not_http() {
|
||||
mut reader__ := reader('hello')
|
||||
http.parse_request(mut reader__) or { return }
|
||||
panic('should not have parsed')
|
||||
}
|
||||
|
||||
fn test_parse_request_no_headers() {
|
||||
mut reader_ := reader('GET / HTTP/1.1\r\n\r\n')
|
||||
req := http.parse_request(mut reader_) or { panic('did not parse: ${err}') }
|
||||
assert req.method == .get
|
||||
assert req.url == '/'
|
||||
assert req.version == .v1_1
|
||||
}
|
||||
|
||||
fn test_parse_request_two_headers() {
|
||||
mut reader_ := reader('GET / HTTP/1.1\r\nTest1: a\r\nTest2: B\r\n\r\n')
|
||||
req := http.parse_request(mut reader_) or { panic('did not parse: ${err}') }
|
||||
assert req.header.custom_values('Test1') == ['a']
|
||||
assert req.header.custom_values('Test2') == ['B']
|
||||
}
|
||||
|
||||
fn test_parse_request_two_header_values() {
|
||||
mut reader_ := reader('GET / HTTP/1.1\r\nTest1: a; b\r\nTest2: c\r\nTest2: d\r\n\r\n')
|
||||
req := http.parse_request(mut reader_) or { panic('did not parse: ${err}') }
|
||||
assert req.header.custom_values('Test1') == ['a; b']
|
||||
assert req.header.custom_values('Test2') == ['c', 'd']
|
||||
}
|
||||
|
||||
fn test_parse_request_body() {
|
||||
mut reader_ :=
|
||||
reader('GET / HTTP/1.1\r\nTest1: a\r\nTest2: b\r\nContent-Length: 4\r\n\r\nbodyabc')
|
||||
req := http.parse_request(mut reader_) or { panic('did not parse: ${err}') }
|
||||
assert req.data == 'body'
|
||||
}
|
||||
|
||||
fn test_parse_request_line() {
|
||||
method, target, version := http.parse_request_line('GET /target HTTP/1.1') or {
|
||||
panic('did not parse: ${err}')
|
||||
}
|
||||
assert method == .get
|
||||
assert target.str() == '/target'
|
||||
assert version == .v1_1
|
||||
}
|
||||
|
||||
fn test_parse_request_uri_with_consecutive_slashes() {
|
||||
url := urllib.parse_request_uri('//another.html') or { panic('did not parse: ${err}') }
|
||||
assert url.host == ''
|
||||
assert url.path == '//another.html'
|
||||
assert url.str() == '//another.html'
|
||||
|
||||
absolute := urllib.parse_request_uri('http://localhost:8080//another.html') or {
|
||||
panic('did not parse: ${err}')
|
||||
}
|
||||
assert absolute.host == 'localhost:8080'
|
||||
assert absolute.path == '//another.html'
|
||||
assert absolute.str() == 'http://localhost:8080//another.html'
|
||||
}
|
||||
|
||||
fn test_parse_request_line_with_consecutive_slashes() {
|
||||
method, target, version := http.parse_request_line('GET //another.html HTTP/1.1') or {
|
||||
panic('did not parse: ${err}')
|
||||
}
|
||||
assert method == .get
|
||||
assert target.host == ''
|
||||
assert target.path == '//another.html'
|
||||
assert target.str() == '//another.html'
|
||||
assert version == .v1_1
|
||||
}
|
||||
|
||||
fn test_parse_form() {
|
||||
assert http.parse_form('foo=bar&bar=baz') == {
|
||||
'foo': 'bar'
|
||||
'bar': 'baz'
|
||||
}
|
||||
assert http.parse_form('foo=bar=&bar=baz') == {
|
||||
'foo': 'bar='
|
||||
'bar': 'baz'
|
||||
}
|
||||
assert http.parse_form('foo=bar%3D&bar=baz') == {
|
||||
'foo': 'bar='
|
||||
'bar': 'baz'
|
||||
}
|
||||
assert http.parse_form('foo=b%26ar&bar=baz') == {
|
||||
'foo': 'b&ar'
|
||||
'bar': 'baz'
|
||||
}
|
||||
assert http.parse_form('a=b& c=d') == {
|
||||
'a': 'b'
|
||||
' c': 'd'
|
||||
}
|
||||
assert http.parse_form('a=b&c= d ') == {
|
||||
'a': 'b'
|
||||
'c': ' d '
|
||||
}
|
||||
assert http.parse_form('{json}') == {
|
||||
'json': '{json}'
|
||||
}
|
||||
assert http.parse_form('{
|
||||
"_id": "76c",
|
||||
"friends": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Mason Luna"
|
||||
}
|
||||
],
|
||||
"greeting": "Hello."
|
||||
}') == {
|
||||
'json': '{
|
||||
"_id": "76c",
|
||||
"friends": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Mason Luna"
|
||||
}
|
||||
],
|
||||
"greeting": "Hello."
|
||||
}'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_multipart_form() {
|
||||
boundary := '6844a625b1f0b299'
|
||||
names := ['foo', 'fooz']
|
||||
file := 'bar.v'
|
||||
ct := 'application/octet-stream'
|
||||
contents := ['baz', 'buzz']
|
||||
data := "--${boundary}
|
||||
Content-Disposition: form-data; name=\"${names[0]}\"; filename=\"${file}\"\r
|
||||
Content-Type: ${ct}\r
|
||||
\r
|
||||
${contents[0]}\r
|
||||
--${boundary}\r
|
||||
Content-Disposition: form-data; name=\"${names[1]}\"\r
|
||||
\r
|
||||
${contents[1]}\r
|
||||
--${boundary}--\r
|
||||
"
|
||||
form, files := http.parse_multipart_form(data, boundary)
|
||||
assert files == {
|
||||
names[0]: [
|
||||
http.FileData{
|
||||
filename: file
|
||||
content_type: ct
|
||||
data: contents[0]
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
assert form == {
|
||||
names[1]: contents[1]
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_multipart_form2() {
|
||||
boundary := '---------------------------27472781931927549291906391339'
|
||||
data := '--${boundary}\r
|
||||
Content-Disposition: form-data; name="username"\r
|
||||
\r
|
||||
admin\r
|
||||
--${boundary}\r
|
||||
Content-Disposition: form-data; name="password"\r
|
||||
\r
|
||||
admin123\r
|
||||
--${boundary}--\r
|
||||
'
|
||||
form, files := http.parse_multipart_form(data, boundary)
|
||||
for k, v in form {
|
||||
eprintln('> k: ${k} | v: ${v}')
|
||||
eprintln('>> k.bytes(): ${k.bytes()}')
|
||||
eprintln('>> v.bytes(): ${v.bytes()}')
|
||||
}
|
||||
assert form['username'] == 'admin'
|
||||
assert form['password'] == 'admin123'
|
||||
}
|
||||
|
||||
fn test_multipart_form_body() {
|
||||
files := {
|
||||
'foo': [
|
||||
http.FileData{
|
||||
filename: 'bar.v'
|
||||
content_type: 'application/octet-stream'
|
||||
data: 'baz'
|
||||
},
|
||||
]
|
||||
}
|
||||
form := {
|
||||
'fooz': 'buzz'
|
||||
}
|
||||
|
||||
body, boundary := http.multipart_form_body(form, files)
|
||||
parsed_form, parsed_files := http.parse_multipart_form(body, boundary)
|
||||
assert parsed_files == files
|
||||
assert parsed_form == form
|
||||
}
|
||||
|
||||
fn test_parse_large_body() {
|
||||
body := 'A'.repeat(10_001) // greater than max_bytes
|
||||
req := 'GET / HTTP/1.1\r\nContent-Length: ${body.len}\r\n\r\n${body}'
|
||||
mut reader_ := reader(req)
|
||||
result := http.parse_request(mut reader_)!
|
||||
assert result.data.len == body.len
|
||||
assert result.data == body
|
||||
}
|
||||
|
||||
fn test_parse_multipart_form_empty_body() {
|
||||
body := ''
|
||||
boundary := '----WebKitFormBoundaryQcBIkwnOACVsvR8b'
|
||||
form, files := http.parse_multipart_form(body, boundary)
|
||||
assert form.len == 0
|
||||
assert files.len == 0
|
||||
}
|
||||
|
||||
fn test_parse_multipart_form_issue_26204__do_not_panic_for_small_or_partial_forms() {
|
||||
boundary := '----01KDN6J6BKWY9WMYWRW4MG5J59'
|
||||
body := '${boundary}\r\nContent-Disposition: form-data; name="fooz"${boundary}--\r\n'
|
||||
form, files := http.parse_multipart_form(body, boundary)
|
||||
assert form.len == 0
|
||||
assert files.len == 0
|
||||
}
|
||||
|
||||
fn test_parse_multipart_form_issue_24974_raw() {
|
||||
body := r'------WebKiormBoundaryQcBIkwnOACVsvR8b\r\nContent-Disposition: form-data; name="files"; filename="michael-sum-LEpfefQf4rU-unsplash.jpg"\r\nContent-Type: image/jpeg\r\n\r\n\r\n------WebKitFormBoundaryQcBIkwnOACVsvR8b\r\nContent-Disposition: form-data; name="files"; filename="mikhail-vasilyev-IFxjDdqK_0U-unsplash.jpg"\r\nContent-Type: image/jpeg\r\n\r\n\r\n------WebKitFormBoundaryQcBIkwnOACVsvR8b--\r\n'
|
||||
boundary := r'----WebKitFormBoundaryQcBIkwnOACVsvR8b'
|
||||
form, files := http.parse_multipart_form(body, boundary)
|
||||
assert form.len == 0
|
||||
assert files.len == 0
|
||||
}
|
||||
|
||||
fn test_parse_multipart_form_issue_24974_cooked() {
|
||||
body := '------WebKiormBoundaryQcBIkwnOACVsvR8b\r\nContent-Disposition: form-data; name="files"; filename="michael-sum-LEpfefQf4rU-unsplash.jpg"\r\nContent-Type: image/jpeg\r\n\r\n\r\n------WebKitFormBoundaryQcBIkwnOACVsvR8b\r\nContent-Disposition: form-data; name="files"; filename="mikhail-vasilyev-IFxjDdqK_0U-unsplash.jpg"\r\nContent-Type: image/jpeg\r\n\r\n\r\n------WebKitFormBoundaryQcBIkwnOACVsvR8b--\r\n'
|
||||
boundary := '----WebKitFormBoundaryQcBIkwnOACVsvR8b'
|
||||
form, files := http.parse_multipart_form(body, boundary)
|
||||
assert form.len == 0
|
||||
assert files.len == 1
|
||||
assert files['files'][0].filename == 'mikhail-vasilyev-IFxjDdqK_0U-unsplash.jpg'
|
||||
assert files['files'][0].content_type == 'image/jpeg'
|
||||
}
|
||||
|
||||
fn test_parse_request_head_str_basic() {
|
||||
s := 'GET / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n'
|
||||
req := http.parse_request_head_str(s) or { panic('did not parse: ${err}') }
|
||||
assert req.method == .get
|
||||
assert req.url == '/'
|
||||
assert req.version == .v1_1
|
||||
assert req.host == 'example.com'
|
||||
}
|
||||
|
||||
fn test_parse_request_head_str_post_with_headers() {
|
||||
s := 'POST /api HTTP/1.1\r\nHost: test.com\r\nContent-Type: application/json\r\nContent-Length: 10\r\n\r\n'
|
||||
req := http.parse_request_head_str(s) or { panic('did not parse: ${err}') }
|
||||
assert req.method == .post
|
||||
assert req.url == '/api'
|
||||
assert req.version == .v1_1
|
||||
assert req.host == 'test.com'
|
||||
assert req.header.custom_values('Content-Type') == ['application/json']
|
||||
}
|
||||
|
||||
fn test_parse_request_head_str_post_with_headers_and_body() {
|
||||
s := 'POST /index HTTP/1.1\r\nHost: localhost:9008\r\nUser-Agent: curl/7.68.0\r\nAccept: */*\r\nContent-Type: application/json\r\nContent-Length: 24\r\nConnection: keep-alive\r\n\r\n{"username": "test"}'
|
||||
req := http.parse_request_head_str(s) or {
|
||||
assert false, 'did not parse: ${err}'
|
||||
return
|
||||
}
|
||||
assert req.method == .post
|
||||
assert req.url == '/index'
|
||||
assert req.version == .v1_1
|
||||
assert req.host == 'localhost:9008'
|
||||
assert req.header.custom_values('User-Agent') == ['curl/7.68.0']
|
||||
assert req.header.custom_values('Accept') == ['*/*']
|
||||
assert req.header.custom_values('Content-Type') == ['application/json']
|
||||
assert req.header.custom_values('Connection') == ['keep-alive']
|
||||
assert req.data == ''
|
||||
}
|
||||
|
||||
fn test_parse_request_head_post_with_headers_and_body() {
|
||||
s := 'POST /index HTTP/1.1\r\nHost: localhost:9008\r\nUser-Agent: curl/7.68.0\r\nAccept: */*\r\nContent-Type: application/json\r\nContent-Length: 24\r\nConnection: keep-alive\r\n\r\n{"username": "test"}'
|
||||
req := http.parse_request_str(s) or {
|
||||
assert false, 'did not parse: ${err}'
|
||||
return
|
||||
}
|
||||
assert req.data == '{"username": "test"}'
|
||||
}
|
||||
|
||||
fn test_parse_request_head_str_with_spaces_in_header_values() {
|
||||
s := 'GET /path HTTP/1.1\r\nX-Custom-Header: value with spaces\r\n\r\n'
|
||||
req := http.parse_request_head_str(s) or { panic('did not parse: ${err}') }
|
||||
assert req.method == .get
|
||||
assert req.url == '/path'
|
||||
assert req.header.custom_values('X-Custom-Header') == ['value with spaces']
|
||||
}
|
||||
|
||||
fn test_parse_request_head_str_multiple_same_header() {
|
||||
s := 'GET / HTTP/1.1\r\nHost: example.com\r\nSet-Cookie: session=abc\r\nSet-Cookie: user=xyz\r\n\r\n'
|
||||
req := http.parse_request_head_str(s) or { panic('did not parse: ${err}') }
|
||||
assert req.method == .get
|
||||
assert req.host == 'example.com'
|
||||
assert req.header.custom_values('Set-Cookie') == ['session=abc', 'user=xyz']
|
||||
}
|
||||
|
||||
fn test_get_does_not_wait_for_timeout_when_content_length_is_complete() {
|
||||
mut listener := net.listen_tcp(.ip, '127.0.0.1:0')!
|
||||
port := listener.addr()!.port()!
|
||||
t := spawn fn (mut listener net.TcpListener) {
|
||||
mut conn := listener.accept() or {
|
||||
listener.close() or {}
|
||||
return
|
||||
}
|
||||
defer {
|
||||
conn.close() or {}
|
||||
listener.close() or {}
|
||||
}
|
||||
|
||||
mut request_buf := []u8{len: 2048}
|
||||
_ = conn.read(mut request_buf) or { return }
|
||||
response := 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok'
|
||||
conn.write(response.bytes()) or { return }
|
||||
|
||||
conn.set_read_timeout(5 * time.second)
|
||||
mut drain_buf := []u8{len: 128}
|
||||
for {
|
||||
n := conn.read(mut drain_buf) or { break }
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}(mut listener)
|
||||
|
||||
mut req := http.new_request(.get, 'http://127.0.0.1:${port}', '')
|
||||
req.read_timeout = 2 * time.second
|
||||
start := time.now()
|
||||
res := req.do()!
|
||||
elapsed := time.since(start)
|
||||
t.wait()
|
||||
|
||||
assert res.status() == .ok
|
||||
assert res.body == 'ok'
|
||||
assert elapsed < time.second
|
||||
}
|
||||
|
||||
fn test_prepare_uses_fetch_config_timeouts() {
|
||||
req := http.prepare(
|
||||
url: 'http://example.com'
|
||||
read_timeout: 123 * time.millisecond
|
||||
write_timeout: 456 * time.millisecond
|
||||
)!
|
||||
assert req.read_timeout == 123 * time.millisecond
|
||||
assert req.write_timeout == 456 * time.millisecond
|
||||
}
|
||||
|
||||
fn test_get_does_not_wait_for_timeout_when_chunked_body_is_complete() {
|
||||
mut listener := net.listen_tcp(.ip, '127.0.0.1:0')!
|
||||
port := listener.addr()!.port()!
|
||||
t := spawn fn (mut listener net.TcpListener) {
|
||||
mut conn := listener.accept() or {
|
||||
listener.close() or {}
|
||||
return
|
||||
}
|
||||
defer {
|
||||
conn.close() or {}
|
||||
listener.close() or {}
|
||||
}
|
||||
|
||||
mut request_buf := []u8{len: 2048}
|
||||
_ = conn.read(mut request_buf) or { return }
|
||||
response := 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n2\r\nok\r\n0\r\n\r\n'
|
||||
conn.write(response.bytes()) or { return }
|
||||
|
||||
conn.set_read_timeout(5 * time.second)
|
||||
mut drain_buf := []u8{len: 128}
|
||||
for {
|
||||
n := conn.read(mut drain_buf) or { break }
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}(mut listener)
|
||||
|
||||
mut req := http.new_request(.get, 'http://127.0.0.1:${port}', '')
|
||||
req.read_timeout = 2 * time.second
|
||||
start := time.now()
|
||||
res := req.do()!
|
||||
elapsed := time.since(start)
|
||||
t.wait()
|
||||
|
||||
assert res.status() == .ok
|
||||
assert res.body == 'ok'
|
||||
assert elapsed < time.second
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import compress.gzip
|
||||
import compress.zlib
|
||||
import net.http.chunked
|
||||
import strconv
|
||||
import strings
|
||||
|
||||
// Response represents the result of the request
|
||||
pub struct Response {
|
||||
pub mut:
|
||||
body string
|
||||
header Header
|
||||
status_code int
|
||||
status_msg string
|
||||
http_version string
|
||||
}
|
||||
|
||||
fn (mut resp Response) free() {
|
||||
unsafe { resp.header.free() }
|
||||
}
|
||||
|
||||
// Formats resp to bytes suitable for HTTP response transmission
|
||||
pub fn (resp Response) bytes() []u8 {
|
||||
mut sb := strings.new_builder(resp.response_buffer_cap())
|
||||
resp.write_into_builder(mut sb)
|
||||
return unsafe { sb.reuse_as_plain_u8_array() }
|
||||
}
|
||||
|
||||
// Formats resp to a string suitable for HTTP response transmission
|
||||
pub fn (resp Response) bytestr() string {
|
||||
mut sb := strings.new_builder(resp.response_buffer_cap())
|
||||
resp.write_into_builder(mut sb)
|
||||
res := sb.str()
|
||||
unsafe { sb.free() }
|
||||
return res
|
||||
}
|
||||
|
||||
fn (resp Response) response_buffer_cap() int {
|
||||
return resp.body.len + 64 + resp.header.cur_pos * 48
|
||||
}
|
||||
|
||||
fn (resp Response) write_into_builder(mut sb strings.Builder) {
|
||||
sb.write_string('HTTP/')
|
||||
sb.write_string(resp.http_version)
|
||||
sb.write_u8(` `)
|
||||
sb.write_decimal(resp.status_code)
|
||||
sb.write_u8(` `)
|
||||
sb.write_string(resp.status_msg)
|
||||
sb.write_string('\r\n')
|
||||
resp.header.render_into_sb(mut sb,
|
||||
version: resp.version()
|
||||
)
|
||||
sb.write_string('\r\n')
|
||||
sb.write_string(resp.body)
|
||||
}
|
||||
|
||||
// Parse a raw HTTP response into a Response object
|
||||
pub fn parse_response(resp string) !Response {
|
||||
version, status_code, status_msg := parse_status_line(resp.all_before('\r\n'))!
|
||||
// Build resp header map and separate the body
|
||||
start_idx, end_idx := find_headers_range(resp)!
|
||||
header := parse_headers(resp.substr(start_idx, end_idx))!
|
||||
mut body := resp.substr(end_idx, resp.len)
|
||||
if has_header_token(header.get(.transfer_encoding) or { '' }, 'chunked') {
|
||||
body = chunked.decode(body)!
|
||||
}
|
||||
body = decode_response_body(body, header.get(.content_encoding) or { '' })
|
||||
return Response{
|
||||
http_version: version
|
||||
status_code: status_code
|
||||
status_msg: status_msg
|
||||
header: header
|
||||
body: body
|
||||
}
|
||||
}
|
||||
|
||||
fn has_header_token(header_value string, expected_token string) bool {
|
||||
for token in parse_header_tokens(header_value) {
|
||||
if token == expected_token.to_lower() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fn parse_header_tokens(header_value string) []string {
|
||||
mut tokens := []string{}
|
||||
for part in header_value.split(',') {
|
||||
token := part.all_before(';').trim_space().to_lower()
|
||||
if token != '' {
|
||||
tokens << token
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
fn decode_response_body(body string, content_encoding string) string {
|
||||
if body.len == 0 {
|
||||
return body
|
||||
}
|
||||
encodings := parse_header_tokens(content_encoding)
|
||||
if encodings.len == 0 {
|
||||
return body
|
||||
}
|
||||
mut decoded := body.bytes()
|
||||
for i := encodings.len - 1; i >= 0; i-- {
|
||||
encoding := encodings[i]
|
||||
decoded = match encoding {
|
||||
'gzip', 'x-gzip' {
|
||||
gzip.decompress(decoded) or { return body }
|
||||
}
|
||||
'deflate' {
|
||||
zlib.decompress(decoded) or { return body }
|
||||
}
|
||||
'identity' {
|
||||
decoded
|
||||
}
|
||||
else {
|
||||
return body
|
||||
}
|
||||
}
|
||||
}
|
||||
return decoded.bytestr()
|
||||
}
|
||||
|
||||
// parse_status_line parses the first HTTP response line into the HTTP
|
||||
// version, status code, and reason phrase
|
||||
fn parse_status_line(line string) !(string, int, string) {
|
||||
if line.len < 5 || line[..5].to_lower() != 'http/' {
|
||||
return error('response does not start with HTTP/, line: `${line}`')
|
||||
}
|
||||
data := line.split_nth(' ', 3)
|
||||
if data.len != 3 {
|
||||
return error('expected at least 3 tokens, but found: ${data.len}')
|
||||
}
|
||||
version := data[0].substr(5, data[0].len)
|
||||
// validate version is 1*DIGIT "." 1*DIGIT
|
||||
digits := version.split_nth('.', 3)
|
||||
if digits.len != 2 {
|
||||
return error('HTTP version malformed, found: `${digits}`')
|
||||
}
|
||||
for digit in digits {
|
||||
strconv.atoi(digit) or {
|
||||
return error('HTTP version must contain only integers, found: `${digit}`')
|
||||
}
|
||||
}
|
||||
return version, strconv.atoi(data[1])!, data[2]
|
||||
}
|
||||
|
||||
// cookies parses the Set-Cookie headers into Cookie objects
|
||||
pub fn (r Response) cookies() []Cookie {
|
||||
mut cookies := []Cookie{}
|
||||
for cookie in r.header.values(.set_cookie) {
|
||||
cookies << parse_cookie(cookie) or { continue }
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
// status parses the status_code and returns a corresponding enum field of Status
|
||||
pub fn (r Response) status() Status {
|
||||
return status_from_int(r.status_code)
|
||||
}
|
||||
|
||||
// set_status sets the status_code and status_msg of the response
|
||||
pub fn (mut r Response) set_status(s Status) {
|
||||
r.status_code = s.int()
|
||||
r.status_msg = s.str()
|
||||
}
|
||||
|
||||
// version parses the version
|
||||
pub fn (r Response) version() Version {
|
||||
return match r.http_version {
|
||||
'1.0' { .v1_0 }
|
||||
'1.1' { .v1_1 }
|
||||
'2.0' { .v2_0 }
|
||||
else { .unknown }
|
||||
}
|
||||
}
|
||||
|
||||
// set_version sets the http_version string of the response
|
||||
pub fn (mut r Response) set_version(v Version) {
|
||||
if v == .unknown {
|
||||
r.http_version = ''
|
||||
return
|
||||
}
|
||||
maj, min := v.protos()
|
||||
r.http_version = '${maj}.${min}'
|
||||
}
|
||||
|
||||
pub struct ResponseConfig {
|
||||
pub:
|
||||
version Version = .v1_1
|
||||
status Status = .ok
|
||||
header Header
|
||||
body string
|
||||
}
|
||||
|
||||
// new_response creates a Response object from the configuration. This
|
||||
// function will add a Content-Length header if body is not empty.
|
||||
pub fn new_response(conf ResponseConfig) Response {
|
||||
mut resp := Response{
|
||||
body: conf.body
|
||||
header: conf.header
|
||||
}
|
||||
if resp.body != '' && !resp.header.contains(.content_length) {
|
||||
resp.header.add(.content_length, resp.body.len.str())
|
||||
}
|
||||
resp.set_status(conf.status)
|
||||
resp.set_version(conf.version)
|
||||
return resp
|
||||
}
|
||||
|
||||
// find_headers_range returns the start (inclusive) and end (exclusive)
|
||||
// index of the headers in the string, including the trailing newlines. This
|
||||
// helper function expects the first line in `data` to be the HTTP status line
|
||||
// (HTTP/1.1 200 OK).
|
||||
fn find_headers_range(data string) !(int, int) {
|
||||
start_idx := data.index('\n') or { return error('no start index found') } + 1
|
||||
mut count := 0
|
||||
for i := start_idx; i < data.len; i++ {
|
||||
if data[i] == `\n` {
|
||||
count++
|
||||
} else if data[i] != `\r` {
|
||||
count = 0
|
||||
}
|
||||
if count == 2 {
|
||||
return start_idx, i + 1
|
||||
}
|
||||
}
|
||||
return error('no end index found')
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
module http
|
||||
|
||||
import compress.gzip
|
||||
import compress.zlib
|
||||
|
||||
fn test_response_bytestr_1() {
|
||||
resp := new_response(
|
||||
status: .ok
|
||||
body: 'Foo'
|
||||
)
|
||||
assert resp.bytestr() == 'HTTP/1.1 200 OK\r\n' + 'Content-Length: 3\r\n' + '\r\n' + 'Foo'
|
||||
}
|
||||
|
||||
fn test_response_bytestr_2() {
|
||||
resp := new_response(
|
||||
status: .found
|
||||
body: 'Foo'
|
||||
header: new_header(key: .location, value: '/')
|
||||
)
|
||||
lines := resp.bytestr().split_into_lines()
|
||||
assert lines[0] == 'HTTP/1.1 302 Found'
|
||||
// header order is not guaranteed
|
||||
check_headers(['Location: /', 'Content-Length: 3'], lines[1..3])!
|
||||
assert lines[3] == ''
|
||||
assert lines[4] == 'Foo'
|
||||
}
|
||||
|
||||
// check_headers is a helper function for asserting all expected headers
|
||||
// are found because rendered header order is not guaranteed. The check
|
||||
// is O(n^2) which is fine for small lists.
|
||||
fn check_headers(expected []string, found []string) ! {
|
||||
assert expected.len == found.len
|
||||
for header in expected {
|
||||
if !found.contains(header) {
|
||||
return error('expected header "${header}" not in ${found}')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_response() {
|
||||
content := 'HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nFoo'
|
||||
x := parse_response(content)!
|
||||
assert x.http_version == '1.1'
|
||||
assert x.status_code == 200
|
||||
assert x.status_msg == 'OK'
|
||||
assert x.header.contains(.content_length)
|
||||
assert x.header.get(.content_length)? == '3'
|
||||
assert x.body == 'Foo'
|
||||
}
|
||||
|
||||
fn test_parse_response_with_cookies() {
|
||||
cookie_id := 'v_is_best'
|
||||
content := 'HTTP/1.1 200 OK\r\nSet-Cookie: id=${cookie_id}\r\nContent-Length: 3\r\n\r\nFoo'
|
||||
mut x := parse_response(content)!
|
||||
assert x.http_version == '1.1'
|
||||
assert x.status_code == 200
|
||||
assert x.status_msg == 'OK'
|
||||
assert x.header.contains(.content_length)
|
||||
assert x.header.get(.content_length)? == '3'
|
||||
assert x.body == 'Foo'
|
||||
response_cookie := x.cookies()
|
||||
assert response_cookie[0].str() == 'id=${cookie_id}'
|
||||
|
||||
// cookie has Base64 encoding info, ending with '=='
|
||||
cookie_base64 := 'Ln0kBnAaAyYFQ8lH7d5J8Y5w1/iyDRpj6d0nBLTbBUMbtEyPD32rPvpApsvxhLJWlkHuHT3KYL0g/xNBxC9od5tMFAgurLxKdRd5lZ6Pd7W+SllkbsXmUA=='
|
||||
content_cookie_base64 := 'HTTP/1.1 200 OK\r\nSet-Cookie: enctoken=${cookie_base64}; path=/; secure; SameSite=None\r\nContent-Length: 3\r\n\r\nFoo'
|
||||
x = parse_response(content_cookie_base64)!
|
||||
assert x.http_version == '1.1'
|
||||
assert x.status_code == 200
|
||||
assert x.status_msg == 'OK'
|
||||
assert x.header.contains(.content_length)
|
||||
assert x.header.get(.content_length)? == '3'
|
||||
assert x.body == 'Foo'
|
||||
response_cookie_base64 := x.cookies()
|
||||
assert response_cookie_base64[0].str().split(';')[0] == 'enctoken=${cookie_base64}'
|
||||
}
|
||||
|
||||
fn test_parse_response_with_weird_cookie() {
|
||||
// weird cookies test
|
||||
content_weird := 'HTTP/1.1 200 OK\r\nSet-Cookie: a=b; ; =; aa=; =bb; cc; ==\r\nContent-Length: 3\r\n\r\nFoo'
|
||||
mut xx := parse_response(content_weird)!
|
||||
weird_cookie := xx.cookies()
|
||||
assert weird_cookie[0].str() == 'a=b'
|
||||
}
|
||||
|
||||
fn test_parse_response_with_gzip_content_encoding() {
|
||||
expected_body := '{"a": 1}'
|
||||
compressed_body := gzip.compress(expected_body.bytes())!
|
||||
content :=
|
||||
'HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: ${compressed_body.len}\r\n\r\n' +
|
||||
compressed_body.bytestr()
|
||||
resp := parse_response(content)!
|
||||
assert resp.body == expected_body
|
||||
}
|
||||
|
||||
fn test_parse_response_with_deflate_content_encoding() {
|
||||
expected_body := '{"a": 1}'
|
||||
compressed_body := zlib.compress(expected_body.bytes())!
|
||||
content :=
|
||||
'HTTP/1.1 200 OK\r\nContent-Encoding: deflate\r\nContent-Length: ${compressed_body.len}\r\n\r\n' +
|
||||
compressed_body.bytestr()
|
||||
resp := parse_response(content)!
|
||||
assert resp.body == expected_body
|
||||
}
|
||||
|
||||
fn test_parse_response_with_chunked_and_gzip_content_encoding() {
|
||||
expected_body := '{"a": 1}'
|
||||
compressed_body := gzip.compress(expected_body.bytes())!
|
||||
chunked_body := '${compressed_body.len:x}\r\n' + compressed_body.bytestr() + '\r\n0\r\n\r\n'
|
||||
content := 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Encoding: gzip\r\n\r\n' +
|
||||
chunked_body
|
||||
resp := parse_response(content)!
|
||||
assert resp.body == expected_body
|
||||
}
|
||||
|
||||
fn test_vschannel_error_message_formats_non_wsa_errors() {
|
||||
assert vschannel_error_message(-2146893052) == '0x80090304'
|
||||
assert vschannel_error_message(42) == '42'
|
||||
}
|
||||
|
||||
fn test_vschannel_error_message_formats_windows_wsa_errors() {
|
||||
$if windows {
|
||||
assert vschannel_error_message(11001) == '(11001) wsahost_not_found'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_vschannel_request_error_normalizes_connect_failures() {
|
||||
err := vschannel_request_error(vschannel_sec_e_internal_error)
|
||||
assert err.msg() == vschannel_connect_failed_msg
|
||||
assert err.code() == vschannel_sec_e_internal_error
|
||||
}
|
||||
|
||||
fn test_vschannel_request_error_keeps_other_codes() {
|
||||
err := vschannel_request_error(42)
|
||||
assert err.msg() == 'http: vschannel request failed: 42'
|
||||
assert err.code() == 42
|
||||
}
|
||||
|
||||
fn test_vschannel_parse_response_normalizes_connect_failure_output() {
|
||||
vschannel_parse_response('Error 10057 sending data to server (1)\nError performing handshake',
|
||||
0) or {
|
||||
assert err.msg() == vschannel_connect_failed_msg
|
||||
assert err.code() == 0
|
||||
return
|
||||
}
|
||||
assert false
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import io
|
||||
import net
|
||||
import time
|
||||
import runtime
|
||||
// ServerStatus is the current status of the server.
|
||||
// .closed means that the server is completely inactive (the default on creation, and after calling .close()).
|
||||
// .running means that the server is active and serving (after .listen_and_serve()).
|
||||
// .stopped means that the server is not active but still listening (after .stop() ).
|
||||
|
||||
pub enum ServerStatus {
|
||||
closed
|
||||
running
|
||||
stopped
|
||||
}
|
||||
|
||||
pub interface Handler {
|
||||
mut:
|
||||
handle(Request) Response
|
||||
}
|
||||
|
||||
pub const default_server_port = 9009
|
||||
|
||||
pub const default_https_server_port = 9043
|
||||
|
||||
pub struct Server {
|
||||
mut:
|
||||
state ServerStatus = .closed
|
||||
listener_opened bool
|
||||
pub mut:
|
||||
addr string = ':${default_server_port}'
|
||||
handler Handler = DebugHandler{}
|
||||
read_timeout time.Duration = 30 * time.second
|
||||
write_timeout time.Duration = 30 * time.second
|
||||
accept_timeout time.Duration = 30 * time.second
|
||||
tls_handshake_timeout time.Duration = 30 * time.second // fallback handshake budget used when accept_timeout is zero or net.infinite_timeout; ignored on non-TLS servers
|
||||
pool_channel_slots int = 1024
|
||||
worker_num int = runtime.nr_jobs()
|
||||
max_keep_alive_requests int = 100 // max requests per keep-alive connection (0 = unlimited)
|
||||
listener net.TcpListener
|
||||
|
||||
// TLS termination: when both `cert` and `cert_key` are set, the server
|
||||
// accepts HTTPS connections instead of plain HTTP. With
|
||||
// `in_memory_verification = true`, `cert` and `cert_key` are PEM strings;
|
||||
// otherwise they are filesystem paths. Currently implemented on the
|
||||
// default mbedtls backend; building with `-d use_openssl` reports a clear
|
||||
// runtime error from listen_and_serve.
|
||||
cert string
|
||||
cert_key string
|
||||
in_memory_verification bool
|
||||
enable_http2 bool // opt in to HTTP/2 on the TLS listener: advertises ALPN `h2, http/1.1`. Clients that select `h2` are served by the HTTP/2 driver; clients that select `http/1.1` (or send no ALPN) keep the existing HTTP/1.1 path.
|
||||
|
||||
on_running fn (mut s Server) = unsafe { nil } // Blocking cb. If set, ran by the web server on transitions to its .running state.
|
||||
on_stopped fn (mut s Server) = unsafe { nil } // Blocking cb. If set, ran by the web server on transitions to its .stopped state.
|
||||
on_closed fn (mut s Server) = unsafe { nil } // Blocking cb. If set, ran by the web server on transitions to its .closed state.
|
||||
|
||||
show_startup_message bool = true // set to false, to remove the default `Listening on ...` message.
|
||||
}
|
||||
|
||||
// listen_and_serve listens on the server port `s.port` over TCP network and
|
||||
// uses `s.parse_and_respond` to handle requests on incoming connections with `s.handler`.
|
||||
pub fn (mut s Server) listen_and_serve() {
|
||||
if s.handler is DebugHandler {
|
||||
eprintln('Server handler not set, using debug handler')
|
||||
}
|
||||
|
||||
if s.cert != '' && s.cert_key != '' {
|
||||
s.listen_and_serve_tls()
|
||||
return
|
||||
}
|
||||
|
||||
mut l := s.listener.addr() or {
|
||||
eprintln('Failed getting listener address, err: ${err}')
|
||||
return
|
||||
}
|
||||
if l.family() == net.AddrFamily.unspec {
|
||||
listening_address := if s.addr == '' || s.addr == ':0' { 'localhost:0' } else { s.addr }
|
||||
listen_family := net.AddrFamily.ip
|
||||
// listen_family := $if windows { net.AddrFamily.ip } $else { net.AddrFamily.ip6 }
|
||||
s.listener = net.listen_tcp(listen_family, listening_address) or {
|
||||
eprintln('Listening on ${s.addr} failed, err: ${err}')
|
||||
return
|
||||
}
|
||||
l = s.listener.addr() or {
|
||||
eprintln('Failed getting listener address 2, err: ${err}')
|
||||
return
|
||||
}
|
||||
}
|
||||
s.addr = l.str()
|
||||
s.listener_opened = true
|
||||
s.listener.set_accept_timeout(s.accept_timeout)
|
||||
|
||||
// Create tcp connection channel
|
||||
ch := chan &net.TcpConn{cap: s.pool_channel_slots}
|
||||
// Create workers
|
||||
mut ws := []thread{cap: s.worker_num}
|
||||
for wid in 0 .. s.worker_num {
|
||||
ws << new_handler_worker(wid, ch, s.handler, s.max_keep_alive_requests)
|
||||
}
|
||||
|
||||
if s.show_startup_message {
|
||||
println('Listening on http://${s.addr}/')
|
||||
flush_stdout()
|
||||
}
|
||||
|
||||
time.sleep(20 * time.millisecond)
|
||||
s.state = .running
|
||||
if s.on_running != unsafe { nil } {
|
||||
s.on_running(mut s)
|
||||
}
|
||||
for s.state == .running {
|
||||
mut conn := s.listener.accept_only() or {
|
||||
if err.code() == net.err_timed_out_code {
|
||||
// Skip network timeouts, they are normal
|
||||
continue
|
||||
}
|
||||
eprintln('accept() failed, reason: ${err}; skipping')
|
||||
continue
|
||||
}
|
||||
conn.set_read_timeout(s.read_timeout)
|
||||
conn.set_write_timeout(s.write_timeout)
|
||||
ch <- conn
|
||||
}
|
||||
if s.state == .stopped {
|
||||
s.close()
|
||||
}
|
||||
}
|
||||
|
||||
// stop signals the server that it should not respond anymore.
|
||||
@[inline]
|
||||
pub fn (mut s Server) stop() {
|
||||
s.state = .stopped
|
||||
if s.on_stopped != unsafe { nil } {
|
||||
s.on_stopped(mut s)
|
||||
}
|
||||
}
|
||||
|
||||
// close immediately closes the port and signals the server that it has been closed.
|
||||
@[inline]
|
||||
pub fn (mut s Server) close() {
|
||||
s.state = .closed
|
||||
if s.listener_opened {
|
||||
s.listener.close() or { return }
|
||||
s.listener_opened = false
|
||||
}
|
||||
if s.on_closed != unsafe { nil } {
|
||||
s.on_closed(mut s)
|
||||
}
|
||||
}
|
||||
|
||||
// status indicates whether the server is running, stopped, or closed.
|
||||
@[inline]
|
||||
pub fn (s &Server) status() ServerStatus {
|
||||
return s.state
|
||||
}
|
||||
|
||||
// WaitTillRunningParams allows for parametrising the calls to s.wait_till_running()
|
||||
@[params]
|
||||
pub struct WaitTillRunningParams {
|
||||
pub:
|
||||
max_retries int = 100 // how many times to check for the status, for each single s.wait_till_running() call
|
||||
retry_period_ms int = 10 // how much time to wait between each check for the status, in milliseconds
|
||||
}
|
||||
|
||||
// wait_till_running allows you to synchronise your calling (main) thread, with the state of the server
|
||||
// (when the server is running in another thread).
|
||||
// It returns an error, after params.max_retries * params.retry_period_ms
|
||||
// milliseconds have passed, without that expected server transition.
|
||||
pub fn (mut s Server) wait_till_running(params WaitTillRunningParams) !int {
|
||||
mut i := 0
|
||||
for s.status() != .running && i < params.max_retries {
|
||||
time.sleep(params.retry_period_ms * time.millisecond)
|
||||
i++
|
||||
}
|
||||
if i >= params.max_retries {
|
||||
return error('maximum retries reached')
|
||||
}
|
||||
time.sleep(params.retry_period_ms)
|
||||
return i
|
||||
}
|
||||
|
||||
struct HandlerWorker {
|
||||
id int
|
||||
ch chan &net.TcpConn
|
||||
max_keep_alive_requests int
|
||||
pub mut:
|
||||
handler Handler
|
||||
}
|
||||
|
||||
fn new_handler_worker(wid int, ch chan &net.TcpConn, handler Handler, max_keep_alive_requests int) thread {
|
||||
mut w := &HandlerWorker{
|
||||
id: wid
|
||||
ch: ch
|
||||
handler: handler
|
||||
max_keep_alive_requests: max_keep_alive_requests
|
||||
}
|
||||
return spawn w.process_requests()
|
||||
}
|
||||
|
||||
fn (mut w HandlerWorker) process_requests() {
|
||||
for {
|
||||
mut conn := <-w.ch or { break }
|
||||
w.handle_conn(mut conn)
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut w HandlerWorker) handle_conn(mut conn net.TcpConn) {
|
||||
conn.set_sock() or {
|
||||
net.close(conn.handle) or {}
|
||||
eprintln('set_sock() failed: ${err}')
|
||||
return
|
||||
}
|
||||
defer {
|
||||
conn.close() or { eprintln('close() failed: ${err}') }
|
||||
}
|
||||
|
||||
mut reader := io.new_buffered_reader(reader: conn)
|
||||
defer {
|
||||
unsafe {
|
||||
reader.free()
|
||||
}
|
||||
}
|
||||
|
||||
mut request_count := 0
|
||||
for {
|
||||
mut req := parse_request(mut reader) or {
|
||||
if err !is io.Eof {
|
||||
$if debug {
|
||||
// only show in debug mode to prevent abuse
|
||||
eprintln('error parsing request: ${err}')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
request_count++
|
||||
|
||||
remote_ip := conn.peer_ip() or { '0.0.0.0' }
|
||||
req.header.add_custom('Remote-Addr', remote_ip) or {}
|
||||
|
||||
mut resp := w.handler.handle(req)
|
||||
normalize_server_response(mut resp, req)
|
||||
|
||||
// Implemented by developers?
|
||||
if !resp.header.contains(.content_length) {
|
||||
resp.header.set(.content_length, '${resp.body.len}')
|
||||
}
|
||||
|
||||
// Check if max keep-alive requests limit reached
|
||||
max_reached := w.max_keep_alive_requests > 0 && request_count >= w.max_keep_alive_requests
|
||||
|
||||
// Determine if connection should be kept alive
|
||||
// HTTP/1.1 defaults to keep-alive, HTTP/1.0 defaults to close
|
||||
req_conn := (req.header.get(.connection) or { '' }).to_lower()
|
||||
resp_conn := (resp.header.get(.connection) or { '' }).to_lower()
|
||||
keep_alive := if max_reached {
|
||||
false
|
||||
} else if resp_conn == 'close' {
|
||||
false
|
||||
} else if resp_conn == 'keep-alive' {
|
||||
true
|
||||
} else if req_conn == 'close' {
|
||||
false
|
||||
} else if req_conn == 'keep-alive' {
|
||||
true
|
||||
} else {
|
||||
// Default behavior based on HTTP version
|
||||
req.version == .v1_1
|
||||
}
|
||||
|
||||
// Set Connection header in response
|
||||
// Always override if max requests reached, otherwise only set if not already present
|
||||
if max_reached || !resp.header.contains(.connection) {
|
||||
if keep_alive {
|
||||
resp.header.set(.connection, 'keep-alive')
|
||||
} else {
|
||||
resp.header.set(.connection, 'close')
|
||||
}
|
||||
}
|
||||
|
||||
conn.write(resp.bytes()) or {
|
||||
eprintln('error sending response: ${err}')
|
||||
return
|
||||
}
|
||||
|
||||
if !keep_alive {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_server_response(mut resp Response, req Request) {
|
||||
server_version := if req.version == .unknown { Version.v1_1 } else { req.version }
|
||||
match resp.http_version {
|
||||
'1.0', '1.1', '2.0' {}
|
||||
else {
|
||||
resp.set_version(server_version)
|
||||
}
|
||||
}
|
||||
|
||||
status := status_from_int(resp.status_code)
|
||||
if status.is_valid() {
|
||||
if resp.status_msg == '' {
|
||||
resp.status_msg = status.str()
|
||||
}
|
||||
} else if resp.status_code == 0 && resp.status_msg == '' {
|
||||
resp.set_status(.ok)
|
||||
} else {
|
||||
resp.set_status(.internal_server_error)
|
||||
}
|
||||
}
|
||||
|
||||
// DebugHandler implements the Handler interface by echoing the request
|
||||
// in the response.
|
||||
struct DebugHandler {}
|
||||
|
||||
fn (d DebugHandler) handle(req Request) Response {
|
||||
$if debug {
|
||||
eprintln('[${time.now()}] ${req.method} ${req.url}\n\r${req.header}\n\r${req.data} - 200 OK')
|
||||
} $else {
|
||||
eprintln('[${time.now()}] ${req.method} ${req.url} - 200')
|
||||
}
|
||||
mut r := Response{
|
||||
body: req.data
|
||||
header: req.header
|
||||
}
|
||||
r.set_status(.ok)
|
||||
r.set_version(req.version)
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
// vtest retry: 5
|
||||
import log
|
||||
import net
|
||||
import net.http
|
||||
import time
|
||||
|
||||
const atimeout = 500 * time.millisecond
|
||||
|
||||
fn testsuite_begin() {
|
||||
log.info(@FN)
|
||||
}
|
||||
|
||||
fn testsuite_end() {
|
||||
log.info(@FN)
|
||||
}
|
||||
|
||||
fn test_server_stop() {
|
||||
log.warn('${@FN} started')
|
||||
defer {
|
||||
log.warn('${@FN} finished')
|
||||
}
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running()!
|
||||
mut watch := time.new_stopwatch()
|
||||
server.stop()
|
||||
assert server.status() == .stopped
|
||||
assert watch.elapsed() < 100 * time.millisecond
|
||||
t.wait()
|
||||
assert watch.elapsed() < 999 * time.millisecond
|
||||
}
|
||||
|
||||
fn test_server_close() {
|
||||
log.warn('${@FN} started')
|
||||
defer {
|
||||
log.warn('${@FN} finished')
|
||||
}
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
handler: MyHttpHandler{}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running()!
|
||||
mut watch := time.new_stopwatch()
|
||||
server.close()
|
||||
assert server.status() == .closed
|
||||
assert watch.elapsed() < 100 * time.millisecond
|
||||
t.wait()
|
||||
assert watch.elapsed() < 999 * time.millisecond
|
||||
}
|
||||
|
||||
fn test_server_custom_listener() {
|
||||
log.warn('${@FN} started')
|
||||
defer {
|
||||
log.warn('${@FN} finished')
|
||||
}
|
||||
listener := net.listen_tcp(.ip6, ':8081')!
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
listener: listener
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running()!
|
||||
mut watch := time.new_stopwatch()
|
||||
server.close()
|
||||
assert server.status() == .closed
|
||||
assert watch.elapsed() < 100 * time.millisecond
|
||||
t.wait()
|
||||
assert watch.elapsed() < 999 * time.millisecond
|
||||
}
|
||||
|
||||
struct MyHttpHandler {
|
||||
mut:
|
||||
counter int
|
||||
oks int
|
||||
not_founds int
|
||||
redirects int
|
||||
}
|
||||
|
||||
fn (mut handler MyHttpHandler) handle(req http.Request) http.Response {
|
||||
handler.counter++
|
||||
// eprintln('${time.now()} | counter: ${handler.counter} | ${req.method} ${req.url}\n${req.header}\n${req.data} - 200 OK\n')
|
||||
// Note: the response must not echo `req.header` wholesale — the request's
|
||||
// `Content-Length` (0 for GETs) would override the server's correct one,
|
||||
// yielding a malformed response whose declared length disagrees with its
|
||||
// body.
|
||||
mut r := http.Response{
|
||||
body: req.data + ', ${req.url}'
|
||||
}
|
||||
match req.url.all_before('?') {
|
||||
'/endpoint', '/another/endpoint' {
|
||||
r.set_status(.ok)
|
||||
handler.oks++
|
||||
}
|
||||
'/redirect_to_big' {
|
||||
r.header = http.new_header(key: .location, value: '/big')
|
||||
r.status_msg = 'Moved permanently'
|
||||
r.status_code = 301
|
||||
handler.redirects++
|
||||
}
|
||||
'/big' {
|
||||
r.body = 'xyz def '.repeat(5_000)
|
||||
r.set_status(.ok)
|
||||
handler.oks++
|
||||
}
|
||||
else {
|
||||
r.set_status(.not_found)
|
||||
handler.not_founds++
|
||||
}
|
||||
}
|
||||
|
||||
r.set_version(req.version)
|
||||
return r
|
||||
}
|
||||
|
||||
fn test_server_custom_handler() {
|
||||
log.warn('${@FN} started')
|
||||
defer {
|
||||
log.warn('${@FN} finished')
|
||||
}
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
handler: MyHttpHandler{}
|
||||
addr: '127.0.0.1:18197'
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running()!
|
||||
x := http.fetch(url: 'http://${server.addr}/endpoint?abc=xyz', data: 'my data')!
|
||||
assert x.body == 'my data, /endpoint?abc=xyz'
|
||||
assert x.status_code == 200
|
||||
assert x.status_msg == 'OK'
|
||||
assert x.http_version == '1.1'
|
||||
y := http.fetch(url: 'http://${server.addr}/another/endpoint', data: 'abcde')!
|
||||
assert y.body == 'abcde, /another/endpoint'
|
||||
assert y.status_code == 200
|
||||
assert x.status_msg == 'OK'
|
||||
assert y.status() == .ok
|
||||
assert y.http_version == '1.1'
|
||||
|
||||
http.fetch(url: 'http://${server.addr}/something/else')!
|
||||
|
||||
big_url := 'http://${server.addr}/redirect_to_big'
|
||||
mut progress_calls := &ProgressCalls{}
|
||||
z := http.fetch(
|
||||
url: big_url
|
||||
user_ptr: progress_calls
|
||||
on_redirect: fn (req &http.Request, nredirects int, new_url string) ! {
|
||||
mut progress_calls := unsafe { &ProgressCalls(req.user_ptr) }
|
||||
eprintln('>>>>>>>> on_redirect, req.url: ${req.url} | new_url: ${new_url} | nredirects: ${nredirects}')
|
||||
progress_calls.redirected_to << new_url
|
||||
}
|
||||
on_progress: fn (req &http.Request, chunk []u8, read_so_far u64) ! {
|
||||
mut progress_calls := unsafe { &ProgressCalls(req.user_ptr) }
|
||||
eprintln('>>>>>>>> on_progress, req.url: ${req.url} | got chunk.len: ${chunk.len:5}, read_so_far: ${read_so_far:8}, chunk: ${chunk#[0..30].bytestr()}')
|
||||
progress_calls.chunks << chunk.clone()
|
||||
progress_calls.reads << read_so_far
|
||||
}
|
||||
on_finish: fn (req &http.Request, final_size u64) ! {
|
||||
mut progress_calls := unsafe { &ProgressCalls(req.user_ptr) }
|
||||
eprintln('>>>>>>>> on_finish, req.url: ${req.url}, final_size: ${final_size}')
|
||||
progress_calls.finished_was_called = true
|
||||
progress_calls.final_size = final_size
|
||||
}
|
||||
)!
|
||||
assert z.status_code == 200
|
||||
assert z.body.starts_with('xyz')
|
||||
assert z.body.len > 10000
|
||||
assert progress_calls.final_size > 40_000
|
||||
assert progress_calls.finished_was_called
|
||||
assert progress_calls.chunks.len > 1
|
||||
assert progress_calls.reads.len > 1
|
||||
assert progress_calls.chunks[0].bytestr().starts_with('HTTP/1.1 301 Moved permanently')
|
||||
assert progress_calls.chunks[1].bytestr().starts_with('HTTP/1.1 200 OK')
|
||||
assert progress_calls.chunks.last().bytestr().contains('xyz def')
|
||||
assert progress_calls.redirected_to == ['http://${server.addr}/big']
|
||||
|
||||
server.stop()
|
||||
t.wait()
|
||||
|
||||
if mut server.handler is MyHttpHandler {
|
||||
assert server.handler.counter == 5
|
||||
assert server.handler.oks == 3
|
||||
assert server.handler.not_founds == 1
|
||||
assert server.handler.redirects == 1
|
||||
} else {
|
||||
assert false, 'expected MyHttpHandler, got ${typeof(server.handler).name}'
|
||||
}
|
||||
}
|
||||
|
||||
struct ProgressCalls {
|
||||
mut:
|
||||
chunks [][]u8
|
||||
reads []u64
|
||||
finished_was_called bool
|
||||
redirected_to []string
|
||||
final_size u64
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
struct MyCountingHandler {
|
||||
mut:
|
||||
counter int
|
||||
}
|
||||
|
||||
fn (mut handler MyCountingHandler) handle(req http.Request) http.Response {
|
||||
handler.counter++
|
||||
mut r := http.Response{
|
||||
body: req.data + ', ${req.url}, counter: ${handler.counter}'
|
||||
header: req.header
|
||||
}
|
||||
match req.url.all_before('?') {
|
||||
'/count' {
|
||||
r.set_status(.ok)
|
||||
}
|
||||
else {
|
||||
r.set_status(.not_found)
|
||||
}
|
||||
}
|
||||
|
||||
r.set_version(req.version)
|
||||
return r
|
||||
}
|
||||
|
||||
fn test_my_counting_handler_on_random_port() {
|
||||
log.warn('${@FN} started')
|
||||
defer {
|
||||
log.warn('${@FN} finished')
|
||||
}
|
||||
mut server := &http.Server{
|
||||
show_startup_message: false
|
||||
addr: ''
|
||||
accept_timeout: atimeout
|
||||
handler: MyCountingHandler{}
|
||||
on_running: fn (mut server http.Server) {
|
||||
spawn fn (mut server http.Server) {
|
||||
log.warn('server started')
|
||||
url := 'http://${server.addr}/count'
|
||||
log.info('fetching from url: ${url}')
|
||||
for _ in 0 .. 5 {
|
||||
x := http.fetch(url: url, data: 'my data') or { panic(err) }
|
||||
log.info(x.body)
|
||||
}
|
||||
server.stop()
|
||||
log.warn('server stopped')
|
||||
}(mut server)
|
||||
}
|
||||
}
|
||||
server.listen_and_serve()
|
||||
if mut server.handler is MyCountingHandler {
|
||||
dump(server.handler.counter)
|
||||
assert server.handler.counter == 5
|
||||
}
|
||||
assert true
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
struct MyCustomHttpHostHandler {}
|
||||
|
||||
fn (mut handler MyCustomHttpHostHandler) handle(req http.Request) http.Response {
|
||||
dump(req.header)
|
||||
return http.Response{
|
||||
body: 'Host was: ${req.header.get(.host) or { '-' }}'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_host_header_sent_to_server() {
|
||||
ip := '127.0.0.1'
|
||||
port := 54671
|
||||
log.warn('${@FN} started')
|
||||
defer { log.warn('${@FN} finished') }
|
||||
mut server := &http.Server{
|
||||
handler: MyCustomHttpHostHandler{}
|
||||
addr: '${ip}:${port}'
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running() or {
|
||||
estr := err.str()
|
||||
if estr == 'maximum retries reached' {
|
||||
log.error('>>>> Skipping test ${@FN} since its server could not start, err: ${err}')
|
||||
return
|
||||
}
|
||||
log.fatal(estr)
|
||||
}
|
||||
defer { server.stop() }
|
||||
dump(server.addr)
|
||||
x := http.get('http://${server.addr}/')!
|
||||
dump(x)
|
||||
assert x.status_code == 200
|
||||
assert x.status_msg == 'OK'
|
||||
assert x.body.ends_with('${ip}:${port}')
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
struct InvalidResponseHandler {}
|
||||
|
||||
fn (mut handler InvalidResponseHandler) handle(req http.Request) http.Response {
|
||||
return http.Response{
|
||||
header: http.new_header_from_map({
|
||||
http.CommonHeader.content_type: 'text/plain'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct InvalidStatusCodeHandler {}
|
||||
|
||||
fn (mut handler InvalidStatusCodeHandler) handle(req http.Request) http.Response {
|
||||
return http.Response{
|
||||
body: 'broken status'
|
||||
status_code: 42
|
||||
}
|
||||
}
|
||||
|
||||
fn test_server_normalizes_invalid_handler_response() {
|
||||
log.warn('${@FN} started')
|
||||
defer { log.warn('${@FN} finished') }
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
handler: InvalidResponseHandler{}
|
||||
addr: '127.0.0.1:18202'
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running() or {
|
||||
estr := err.str()
|
||||
if estr == 'maximum retries reached' {
|
||||
log.error('>>>> Skipping test ${@FN} since its server could not start, err: ${err}')
|
||||
return
|
||||
}
|
||||
log.fatal(estr)
|
||||
}
|
||||
|
||||
mut conn := net.dial_tcp('127.0.0.1:18202')!
|
||||
defer { conn.close() or {} }
|
||||
conn.set_read_timeout(5 * time.second)
|
||||
conn.set_write_timeout(5 * time.second)
|
||||
|
||||
request := 'GET / HTTP/1.1\r\nHost: 127.0.0.1:18202\r\nConnection: close\r\n\r\n'
|
||||
conn.write(request.bytes())!
|
||||
response := read_http_response(mut conn)!
|
||||
lines := response.split('\r\n')
|
||||
assert lines[0] == 'HTTP/1.1 200 OK'
|
||||
assert response.to_lower().contains('content-type: text/plain')
|
||||
assert response.to_lower().contains('content-length: 0')
|
||||
|
||||
server.stop()
|
||||
t.wait()
|
||||
}
|
||||
|
||||
fn test_server_coerces_invalid_status_code_to_internal_server_error() {
|
||||
log.warn('${@FN} started')
|
||||
defer { log.warn('${@FN} finished') }
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
handler: InvalidStatusCodeHandler{}
|
||||
addr: '127.0.0.1:18203'
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running() or {
|
||||
estr := err.str()
|
||||
if estr == 'maximum retries reached' {
|
||||
log.error('>>>> Skipping test ${@FN} since its server could not start, err: ${err}')
|
||||
return
|
||||
}
|
||||
log.fatal(estr)
|
||||
}
|
||||
|
||||
mut conn := net.dial_tcp('127.0.0.1:18203')!
|
||||
defer { conn.close() or {} }
|
||||
conn.set_read_timeout(5 * time.second)
|
||||
conn.set_write_timeout(5 * time.second)
|
||||
|
||||
request := 'GET / HTTP/1.1\r\nHost: 127.0.0.1:18203\r\nConnection: close\r\n\r\n'
|
||||
conn.write(request.bytes())!
|
||||
response := read_http_response(mut conn)!
|
||||
lines := response.split('\r\n')
|
||||
assert lines[0] == 'HTTP/1.1 500 Internal Server Error'
|
||||
assert response.ends_with('broken status')
|
||||
|
||||
server.stop()
|
||||
t.wait()
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
struct RedirectMethodHandler {}
|
||||
|
||||
fn (mut handler RedirectMethodHandler) handle(req http.Request) http.Response {
|
||||
mut r := http.Response{}
|
||||
match req.url {
|
||||
'/redirect-301' {
|
||||
r.header = http.new_header(key: .location, value: '/expect-get')
|
||||
r.set_status(.moved_permanently)
|
||||
}
|
||||
'/redirect-302' {
|
||||
r.header = http.new_header(key: .location, value: '/expect-get')
|
||||
r.set_status(.found)
|
||||
}
|
||||
'/redirect-303' {
|
||||
r.header = http.new_header(key: .location, value: '/expect-get')
|
||||
r.set_status(.see_other)
|
||||
}
|
||||
'/redirect-307' {
|
||||
r.header = http.new_header(key: .location, value: '/expect-post')
|
||||
r.set_status(.temporary_redirect)
|
||||
}
|
||||
'/redirect-308' {
|
||||
r.header = http.new_header(key: .location, value: '/expect-post')
|
||||
r.set_status(.permanent_redirect)
|
||||
}
|
||||
'/expect-get' {
|
||||
if req.method == .get && req.data == '' {
|
||||
r.body = 'redirected-as-get'
|
||||
r.set_status(.ok)
|
||||
} else {
|
||||
r.body = 'expected GET without a body, got ${req.method} `${req.data}`'
|
||||
r.set_status(.method_not_allowed)
|
||||
}
|
||||
}
|
||||
'/expect-post' {
|
||||
if req.method == .post && req.data == 'payload' {
|
||||
r.body = 'preserved-post'
|
||||
r.set_status(.ok)
|
||||
} else {
|
||||
r.body = 'expected POST with payload, got ${req.method} `${req.data}`'
|
||||
r.set_status(.method_not_allowed)
|
||||
}
|
||||
}
|
||||
else {
|
||||
r.set_status(.not_found)
|
||||
}
|
||||
}
|
||||
|
||||
r.set_version(req.version)
|
||||
return r
|
||||
}
|
||||
|
||||
fn test_redirects_change_post_to_get_only_when_required() {
|
||||
log.warn('${@FN} started')
|
||||
defer { log.warn('${@FN} finished') }
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
handler: RedirectMethodHandler{}
|
||||
addr: '127.0.0.1:18204'
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running() or {
|
||||
estr := err.str()
|
||||
if estr == 'maximum retries reached' {
|
||||
log.error('>>>> Skipping test ${@FN} since its server could not start, err: ${err}')
|
||||
return
|
||||
}
|
||||
log.fatal(estr)
|
||||
}
|
||||
|
||||
for path in ['/redirect-301', '/redirect-302', '/redirect-303'] {
|
||||
resp := http.post('http://${server.addr}${path}', 'payload')!
|
||||
assert resp.status() == .ok
|
||||
assert resp.body == 'redirected-as-get'
|
||||
}
|
||||
|
||||
for path in ['/redirect-307', '/redirect-308'] {
|
||||
resp := http.post('http://${server.addr}${path}', 'payload')!
|
||||
assert resp.status() == .ok
|
||||
assert resp.body == 'preserved-post'
|
||||
}
|
||||
|
||||
server.stop()
|
||||
t.wait()
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
struct KeepAliveHandler {
|
||||
mut:
|
||||
request_count int
|
||||
}
|
||||
|
||||
fn (mut handler KeepAliveHandler) handle(req http.Request) http.Response {
|
||||
handler.request_count++
|
||||
mut r := http.Response{
|
||||
body: 'request #${handler.request_count}: ${req.url}'
|
||||
}
|
||||
r.set_status(.ok)
|
||||
r.set_version(req.version)
|
||||
// Echo back the Connection header from the request if present
|
||||
if conn := req.header.get(.connection) {
|
||||
r.header.set(.connection, conn)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
fn test_server_keep_alive() {
|
||||
log.warn('${@FN} started')
|
||||
defer { log.warn('${@FN} finished') }
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
handler: KeepAliveHandler{}
|
||||
addr: '127.0.0.1:18198'
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running() or {
|
||||
estr := err.str()
|
||||
if estr == 'maximum retries reached' {
|
||||
log.error('>>>> Skipping test ${@FN} since its server could not start, err: ${err}')
|
||||
return
|
||||
}
|
||||
log.fatal(estr)
|
||||
}
|
||||
|
||||
// Test keep-alive by sending multiple requests over a single TCP connection
|
||||
mut conn := net.dial_tcp('127.0.0.1:18198')!
|
||||
defer { conn.close() or {} }
|
||||
conn.set_read_timeout(5 * time.second)
|
||||
conn.set_write_timeout(5 * time.second)
|
||||
|
||||
// Send first request with Connection: keep-alive
|
||||
request1 := 'GET /test1 HTTP/1.1\r\nHost: 127.0.0.1:18198\r\nConnection: keep-alive\r\n\r\n'
|
||||
conn.write(request1.bytes())!
|
||||
mut resp1 := read_http_response(mut conn)!
|
||||
log.info('Response 1: ${resp1}')
|
||||
assert resp1.contains('request #1')
|
||||
assert resp1.to_lower().contains('connection: keep-alive')
|
||||
|
||||
// Send second request on the same connection
|
||||
request2 := 'GET /test2 HTTP/1.1\r\nHost: 127.0.0.1:18198\r\nConnection: keep-alive\r\n\r\n'
|
||||
conn.write(request2.bytes())!
|
||||
mut resp2 := read_http_response(mut conn)!
|
||||
log.info('Response 2: ${resp2}')
|
||||
assert resp2.contains('request #2')
|
||||
assert resp2.to_lower().contains('connection: keep-alive')
|
||||
|
||||
// Send third request with Connection: close to end the connection
|
||||
request3 := 'GET /test3 HTTP/1.1\r\nHost: 127.0.0.1:18198\r\nConnection: close\r\n\r\n'
|
||||
conn.write(request3.bytes())!
|
||||
mut resp3 := read_http_response(mut conn)!
|
||||
log.info('Response 3: ${resp3}')
|
||||
assert resp3.contains('request #3')
|
||||
assert resp3.to_lower().contains('connection: close')
|
||||
|
||||
server.stop()
|
||||
t.wait()
|
||||
|
||||
// Verify all 3 requests were handled
|
||||
if mut server.handler is KeepAliveHandler {
|
||||
assert server.handler.request_count == 3
|
||||
} else {
|
||||
assert false, 'expected KeepAliveHandler, got ${typeof(server.handler).name}'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_server_keep_alive_many_requests() {
|
||||
log.warn('${@FN} started')
|
||||
defer { log.warn('${@FN} finished') }
|
||||
total_requests := 64
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
handler: KeepAliveHandler{}
|
||||
addr: '127.0.0.1:18199'
|
||||
show_startup_message: false
|
||||
max_keep_alive_requests: 0
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running() or {
|
||||
estr := err.str()
|
||||
if estr == 'maximum retries reached' {
|
||||
log.error('>>>> Skipping test ${@FN} since its server could not start, err: ${err}')
|
||||
return
|
||||
}
|
||||
log.fatal(estr)
|
||||
}
|
||||
|
||||
mut conn := net.dial_tcp('127.0.0.1:18199')!
|
||||
defer { conn.close() or {} }
|
||||
conn.set_read_timeout(5 * time.second)
|
||||
conn.set_write_timeout(5 * time.second)
|
||||
|
||||
for i in 0 .. total_requests {
|
||||
path := if i % 2 == 0 { '/left' } else { '/right' }
|
||||
request := 'GET ${path}?n=${i} HTTP/1.1\r\nHost: 127.0.0.1:18199\r\nConnection: keep-alive\r\n\r\n'
|
||||
conn.write(request.bytes())!
|
||||
resp := read_http_response(mut conn)!
|
||||
assert resp.contains('request #${i + 1}')
|
||||
assert resp.contains(path)
|
||||
assert resp.to_lower().contains('connection: keep-alive')
|
||||
}
|
||||
|
||||
request_done := 'GET /done HTTP/1.1\r\nHost: 127.0.0.1:18199\r\nConnection: close\r\n\r\n'
|
||||
conn.write(request_done.bytes())!
|
||||
resp_done := read_http_response(mut conn)!
|
||||
assert resp_done.contains('request #${total_requests + 1}')
|
||||
assert resp_done.contains('/done')
|
||||
assert resp_done.to_lower().contains('connection: close')
|
||||
|
||||
server.stop()
|
||||
t.wait()
|
||||
if mut server.handler is KeepAliveHandler {
|
||||
assert server.handler.request_count == total_requests + 1
|
||||
} else {
|
||||
assert false, 'expected KeepAliveHandler, got ${typeof(server.handler).name}'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_server_max_keep_alive_requests() {
|
||||
log.warn('${@FN} started')
|
||||
defer { log.warn('${@FN} finished') }
|
||||
mut server := &http.Server{
|
||||
accept_timeout: atimeout
|
||||
handler: KeepAliveHandler{}
|
||||
addr: '127.0.0.1:18200'
|
||||
show_startup_message: false
|
||||
max_keep_alive_requests: 3 // Limit to 3 requests per connection
|
||||
}
|
||||
t := spawn server.listen_and_serve()
|
||||
server.wait_till_running() or {
|
||||
estr := err.str()
|
||||
if estr == 'maximum retries reached' {
|
||||
log.error('>>>> Skipping test ${@FN} since its server could not start, err: ${err}')
|
||||
return
|
||||
}
|
||||
log.fatal(estr)
|
||||
}
|
||||
|
||||
// Test that connection closes after max_keep_alive_requests
|
||||
mut conn := net.dial_tcp('127.0.0.1:18200')!
|
||||
defer { conn.close() or {} }
|
||||
conn.set_read_timeout(5 * time.second)
|
||||
conn.set_write_timeout(5 * time.second)
|
||||
|
||||
// Send first request
|
||||
request1 := 'GET /test1 HTTP/1.1\r\nHost: 127.0.0.1:18200\r\nConnection: keep-alive\r\n\r\n'
|
||||
conn.write(request1.bytes())!
|
||||
mut resp1 := read_http_response(mut conn)!
|
||||
log.info('Response 1: ${resp1}')
|
||||
assert resp1.contains('request #1')
|
||||
assert resp1.to_lower().contains('connection: keep-alive')
|
||||
|
||||
// Send second request
|
||||
request2 := 'GET /test2 HTTP/1.1\r\nHost: 127.0.0.1:18200\r\nConnection: keep-alive\r\n\r\n'
|
||||
conn.write(request2.bytes())!
|
||||
mut resp2 := read_http_response(mut conn)!
|
||||
log.info('Response 2: ${resp2}')
|
||||
assert resp2.contains('request #2')
|
||||
assert resp2.to_lower().contains('connection: keep-alive')
|
||||
|
||||
// Send third request - should get Connection: close since max is reached
|
||||
request3 := 'GET /test3 HTTP/1.1\r\nHost: 127.0.0.1:18200\r\nConnection: keep-alive\r\n\r\n'
|
||||
conn.write(request3.bytes())!
|
||||
mut resp3 := read_http_response(mut conn)!
|
||||
log.info('Response 3: ${resp3}')
|
||||
assert resp3.contains('request #3')
|
||||
assert resp3.to_lower().contains('connection: close'), 'Expected connection: close after max requests reached'
|
||||
|
||||
server.stop()
|
||||
t.wait()
|
||||
|
||||
if mut server.handler is KeepAliveHandler {
|
||||
assert server.handler.request_count == 3
|
||||
} else {
|
||||
assert false, 'expected KeepAliveHandler, got ${typeof(server.handler).name}'
|
||||
}
|
||||
}
|
||||
|
||||
fn read_http_response(mut conn net.TcpConn) !string {
|
||||
mut response := []u8{}
|
||||
mut buf := []u8{len: 1024}
|
||||
mut content_length := -1
|
||||
mut headers_end := -1
|
||||
|
||||
for {
|
||||
n := conn.read(mut buf) or { break }
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
response << buf[..n]
|
||||
|
||||
// Check if we have received all headers
|
||||
response_str := response.bytestr()
|
||||
if headers_end == -1 {
|
||||
headers_end = response_str.index('\r\n\r\n') or { -1 }
|
||||
if headers_end != -1 {
|
||||
// Parse Content-Length from headers
|
||||
headers := response_str[..headers_end]
|
||||
for line in headers.split('\r\n') {
|
||||
if line.to_lower().starts_with('content-length:') {
|
||||
content_length = line.all_after(':').trim_space().int()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have received the full response
|
||||
if headers_end != -1 && content_length >= 0 {
|
||||
body_start := headers_end + 4
|
||||
body_received := response.len - body_start
|
||||
if body_received >= content_length {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.bytestr()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// TLS termination for net.http.Server is currently provided by the default
|
||||
// mbedtls backend. Under `-d use_openssl`, the OpenSSL backend does not yet
|
||||
// expose a server-side listener; this file replaces the implementation in
|
||||
// `server_tls_notd_use_openssl.v` with a stub that reports a clear error.
|
||||
|
||||
fn (mut s Server) listen_and_serve_tls() {
|
||||
eprintln('net.http.Server: TLS termination is not supported on -d use_openssl yet; remove -d use_openssl or omit cert/cert_key to fall back to plain HTTP')
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import net
|
||||
import sync
|
||||
|
||||
@[heap]
|
||||
struct TlsIdleConnTracker {
|
||||
mu &sync.Mutex = sync.new_mutex()
|
||||
mut:
|
||||
handles []int
|
||||
closing bool
|
||||
}
|
||||
|
||||
fn (mut t TlsIdleConnTracker) mark_idle(handle int) bool {
|
||||
t.mu.lock()
|
||||
defer {
|
||||
t.mu.unlock()
|
||||
}
|
||||
if t.closing {
|
||||
return false
|
||||
}
|
||||
t.handles << handle
|
||||
return true
|
||||
}
|
||||
|
||||
fn (mut t TlsIdleConnTracker) unmark_idle(handle int) {
|
||||
t.mu.lock()
|
||||
defer {
|
||||
t.mu.unlock()
|
||||
}
|
||||
idx := t.handles.index(handle)
|
||||
if idx >= 0 {
|
||||
t.handles.delete(idx)
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut t TlsIdleConnTracker) close_idle() {
|
||||
t.mu.lock()
|
||||
t.closing = true
|
||||
handles := t.handles.clone()
|
||||
t.handles.clear()
|
||||
t.mu.unlock()
|
||||
for handle in handles {
|
||||
net.shutdown(handle)
|
||||
$if windows {
|
||||
net.close(handle) or {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import io
|
||||
import net
|
||||
import time
|
||||
import net.mbedtls
|
||||
|
||||
const tls_accept_poll_timeout = 100 * time.millisecond
|
||||
|
||||
// tls_handshake_timeout is the default value for Server.tls_handshake_timeout,
|
||||
// used as the fallback handshake budget when Server.accept_timeout is zero or
|
||||
// net.infinite_timeout. The handshake runs on the accept thread, so without a
|
||||
// finite bound a client that completes the TCP connect and then stalls
|
||||
// mid-handshake would wedge the accept loop forever.
|
||||
const tls_handshake_timeout = 30 * time.second
|
||||
|
||||
fn tls_accept_timeouts(accept_timeout time.Duration, handshake_fallback time.Duration) (time.Duration, time.Duration) {
|
||||
// A finite `accept_timeout` doubles as the handshake budget; when it is
|
||||
// zero or net.infinite_timeout (i64.max), fall back to `handshake_fallback`
|
||||
// so the handshake still times out and shutdown stays responsive.
|
||||
// net.infinite_timeout is positive (i64.max), so the > 0 check alone is
|
||||
// not enough — mbedtls's ssl_timeout_deadline treats it as an infinite
|
||||
// deadline exactly like 0 or negative values.
|
||||
is_finite := accept_timeout > 0 && accept_timeout != net.infinite_timeout
|
||||
handshake_timeout := if is_finite { accept_timeout } else { handshake_fallback }
|
||||
accept_poll_timeout := if is_finite && accept_timeout < tls_accept_poll_timeout {
|
||||
accept_timeout
|
||||
} else {
|
||||
tls_accept_poll_timeout
|
||||
}
|
||||
return accept_poll_timeout, handshake_timeout
|
||||
}
|
||||
|
||||
// This file implements TLS termination for net.http.Server on top of the
|
||||
// mbedtls SSL listener. It is gated to the default TLS backend; the matching
|
||||
// `server_tls_d_use_openssl.v` provides a clear-error stub when the project is
|
||||
// built with `-d use_openssl`.
|
||||
|
||||
// listen_and_serve_tls is the TLS counterpart of listen_and_serve. It is
|
||||
// dispatched to by listen_and_serve when `s.cert` and `s.cert_key` are set.
|
||||
fn (mut s Server) listen_and_serve_tls() {
|
||||
// Pick a default port that's distinct from the plain-HTTP default if the
|
||||
// user hasn't overridden it.
|
||||
addr := if s.addr == '' || s.addr == ':${default_server_port}' {
|
||||
':${default_https_server_port}'
|
||||
} else {
|
||||
s.addr
|
||||
}
|
||||
|
||||
// When HTTP/2 is enabled, advertise ALPN `h2, http/1.1` on the listener.
|
||||
// Clients that select `h2` are dispatched to the HTTP/2 driver after the
|
||||
// handshake; clients that select `http/1.1` (or send no ALPN extension)
|
||||
// keep the existing HTTP/1.1 worker path.
|
||||
alpn := if s.enable_http2 { ['h2', 'http/1.1'] } else { []string{} }
|
||||
mut listener := mbedtls.new_ssl_listener(addr, mbedtls.SSLConnectConfig{
|
||||
cert: s.cert
|
||||
cert_key: s.cert_key
|
||||
in_memory_verification: s.in_memory_verification
|
||||
validate: false // accept any client; servers don't verify clients by default
|
||||
read_timeout: s.read_timeout
|
||||
alpn_protocols: alpn
|
||||
}) or {
|
||||
eprintln('Listening TLS on ${addr} failed, err: ${err}')
|
||||
return
|
||||
}
|
||||
defer {
|
||||
listener.shutdown() or {}
|
||||
if s.state == .stopped {
|
||||
s.state = .closed
|
||||
if s.on_closed != unsafe { nil } {
|
||||
s.on_closed(mut s)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.addr = addr
|
||||
|
||||
ch := chan &mbedtls.SSLConn{cap: s.pool_channel_slots}
|
||||
mut idle_conns := &TlsIdleConnTracker{}
|
||||
mut ws := []thread{cap: s.worker_num}
|
||||
for wid in 0 .. s.worker_num {
|
||||
ws << new_tls_handler_worker(wid, ch, s.handler, s.max_keep_alive_requests, idle_conns)
|
||||
}
|
||||
|
||||
if s.show_startup_message {
|
||||
println('Listening on https://${s.addr}/')
|
||||
flush_stdout()
|
||||
}
|
||||
|
||||
time.sleep(20 * time.millisecond)
|
||||
s.state = .running
|
||||
if s.on_running != unsafe { nil } {
|
||||
s.on_running(mut s)
|
||||
}
|
||||
accept_poll_timeout, handshake_timeout := tls_accept_timeouts(s.accept_timeout,
|
||||
s.tls_handshake_timeout)
|
||||
for s.state == .running {
|
||||
mut conn := listener.accept_with_timeouts(accept_poll_timeout, handshake_timeout) or {
|
||||
if s.state != .running {
|
||||
break
|
||||
}
|
||||
if err.code() == net.err_timed_out_code {
|
||||
continue
|
||||
}
|
||||
$if debug {
|
||||
eprintln('TLS accept failed: ${err}; skipping')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.read_timeout > 0 {
|
||||
conn.set_read_timeout(s.read_timeout)
|
||||
}
|
||||
ch <- conn
|
||||
}
|
||||
ch.close()
|
||||
idle_conns.close_idle()
|
||||
ws.wait()
|
||||
}
|
||||
|
||||
// TlsHandlerWorker serves HTTP/1.1 requests on TLS-wrapped connections.
|
||||
struct TlsHandlerWorker {
|
||||
id int
|
||||
ch chan &mbedtls.SSLConn
|
||||
max_keep_alive_requests int
|
||||
mut:
|
||||
idle_conns &TlsIdleConnTracker = unsafe { nil }
|
||||
pub mut:
|
||||
handler Handler
|
||||
}
|
||||
|
||||
fn new_tls_handler_worker(wid int, ch chan &mbedtls.SSLConn, handler Handler, max_keep_alive_requests int, idle_conns &TlsIdleConnTracker) thread {
|
||||
mut w := &TlsHandlerWorker{
|
||||
id: wid
|
||||
ch: ch
|
||||
handler: handler
|
||||
max_keep_alive_requests: max_keep_alive_requests
|
||||
idle_conns: idle_conns
|
||||
}
|
||||
return spawn w.process_requests()
|
||||
}
|
||||
|
||||
fn (mut w TlsHandlerWorker) process_requests() {
|
||||
for {
|
||||
mut conn := <-w.ch or { break }
|
||||
w.handle_conn(mut conn)
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut w TlsHandlerWorker) handle_conn(mut conn mbedtls.SSLConn) {
|
||||
// For H2 connections, serve_h2_conn_with_idle_tracker's serve() owns the
|
||||
// mark_idle/unmark_idle lifetime (it marks idle once and unmarks in its
|
||||
// own defer). Calling unmark_idle here a second time would race: after
|
||||
// serve()'s defer fires the OS can recycle conn.handle for a new
|
||||
// connection that has already called mark_idle, and this stale unmark
|
||||
// would silently evict it, preventing close_idle from ever shutting it
|
||||
// down and leaking the reader goroutine.
|
||||
mut is_h2 := false
|
||||
defer {
|
||||
if !is_h2 {
|
||||
w.idle_conns.unmark_idle(conn.handle)
|
||||
}
|
||||
conn.shutdown() or {}
|
||||
}
|
||||
// If the TLS handshake negotiated HTTP/2 via ALPN, switch to the HTTP/2
|
||||
// driver; otherwise fall through to the existing HTTP/1.1 path unchanged.
|
||||
if conn.negotiated_alpn() == 'h2' {
|
||||
is_h2 = true
|
||||
serve_h2_conn_with_idle_tracker(mut conn, mut w.handler, w.idle_conns, conn.handle) or {
|
||||
$if debug {
|
||||
eprintln('h2 server error: ${err}')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
mut reader := io.new_buffered_reader(reader: conn)
|
||||
defer {
|
||||
unsafe {
|
||||
reader.free()
|
||||
}
|
||||
}
|
||||
|
||||
mut request_count := 0
|
||||
for {
|
||||
if !w.idle_conns.mark_idle(conn.handle) {
|
||||
return
|
||||
}
|
||||
mut req := parse_request(mut reader) or {
|
||||
if err !is io.Eof {
|
||||
$if debug {
|
||||
eprintln('error parsing TLS request: ${err}')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
w.idle_conns.unmark_idle(conn.handle)
|
||||
request_count++
|
||||
// `conn.ip` is the peer's IPv4 address as populated by mbedtls'
|
||||
// accept(); blank for IPv6, which is acceptable for keep-alive logic.
|
||||
if conn.ip != '' {
|
||||
req.header.add_custom('Remote-Addr', conn.ip) or {}
|
||||
}
|
||||
|
||||
mut resp := w.handler.handle(req)
|
||||
normalize_server_response(mut resp, req)
|
||||
|
||||
if !resp.header.contains(.content_length) {
|
||||
resp.header.set(.content_length, '${resp.body.len}')
|
||||
}
|
||||
|
||||
max_reached := w.max_keep_alive_requests > 0 && request_count >= w.max_keep_alive_requests
|
||||
req_conn := (req.header.get(.connection) or { '' }).to_lower()
|
||||
resp_conn := (resp.header.get(.connection) or { '' }).to_lower()
|
||||
keep_alive := if max_reached {
|
||||
false
|
||||
} else if resp_conn == 'close' {
|
||||
false
|
||||
} else if resp_conn == 'keep-alive' {
|
||||
true
|
||||
} else if req_conn == 'close' {
|
||||
false
|
||||
} else if req_conn == 'keep-alive' {
|
||||
true
|
||||
} else {
|
||||
req.version == .v1_1
|
||||
}
|
||||
if max_reached || !resp.header.contains(.connection) {
|
||||
if keep_alive {
|
||||
resp.header.set(.connection, 'keep-alive')
|
||||
} else {
|
||||
resp.header.set(.connection, 'close')
|
||||
}
|
||||
}
|
||||
|
||||
conn.write(resp.bytes()) or {
|
||||
$if debug {
|
||||
eprintln('error sending TLS response: ${err}')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !keep_alive {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
// vtest build: !sanitize-memory-clang
|
||||
// Hermetic TLS-termination test for net.http.Server: spin up a local HTTPS
|
||||
// server with an in-memory cert/key, hit it with http.fetch (validate: false),
|
||||
// and assert the round-trip.
|
||||
|
||||
module main
|
||||
|
||||
import net
|
||||
import net.http
|
||||
import net.mbedtls
|
||||
import time
|
||||
|
||||
const server_tls_cert = '-----BEGIN CERTIFICATE-----\nMIIEOTCCAyECFG64Q2g46jZb3kRbDOJWX/BwjSp6MA0GCSqGSIb3DQEBCwUAMEUx\nCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl\ncm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjMwODAyMTcyOTQyWhgPMjA1MDEyMTcx\nNzI5NDJaMGsxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRQwEgYD\nVQQHDAtMb3MgQW5nZWxlczEdMBsGA1UECgwUQ2F0YWx5c3QgRGV2ZWxvcG1lbnQx\nEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC\nggIBALqAI4fqUi+QBVWcsXglouLdOML5+w0+1hSR1KdO0Q5XPdQAs/yYWJ+KUkDw\nG++rfy9DUPq7FNRBVurXQkcAtn6gXdllGUSjwUiDo/N4mMOyS/2sufBuaeww7jVi\nrppH+zwP1tUnjRd6khl6bi1Ian9VSzr3Iy9CkXIg1GU4CPXkOydLeoQfepXxWoK1\nOUNwT3VKC/stAfY3j/NIIeiJYkyuRGFCkxn/BUjN+AsXiTugRcYKEFHdIPkOuCXp\nYbhf+lLsczpxCs3rdZG9b/N6mEDCzXTmeHkmsjdPTf+1k5DZZvKzVBBrgdxCgBb7\n5RwjF5v9WmnIc33wWgfJC6FaUzj9NYxYUbPHD+jTz0rJB/jj4u/xJlM/e5NRmXdW\n70pOMKXtWjRSolLOFIPKLY1qs3KMTAZxKKWPDDF7WlMJxMRt7nnnks5yw43Nog4C\njDLk1ZgETnPpLgo3jbmJdIv+OHKTJrBlVvDq7VTyixCoS5G8KoOmyQJhaXG6NwE2\niVhH5JIKgzgCfetfDsnjxqJ/qtrFXPa8FF2TsomD0NK/GZmIcs+9OeVB75Jn5uhF\nfLHScpiTbuu5w3P/LI/MqihLRB6RRNnRzPH8fIg5bYC9b770ta/8GcFRuYE8t+UR\nGtqXJoIKixbDlqV54kal8FQzYzhETf9+NM6Kb/lKEfG/pslvAgMBAAEwDQYJKoZI\nhvcNAQELBQADggEBALI3uNiNO0QE1brA3QYFK+d9ZroB72NrJ0UNkzYHDg2Fc6xg\n4aVVfaxY08+TmKc0JlMOW+pUxeCW/+UBSngdQiR9EE9xm0k0XIrAsy9RXxRvEtPu\nM1VI2h7ayp1Y2BrnQinevTSgtqLRyS1VbOFRl1FiyVvinw2I0KsDdAMNevAPXcOa\nQ8pUgUq6f56DkhocQaj+hxD/uV8HryNxuoSXnPhvfTN3z4YRGzsaWevJ9EYJliOM\n+XugcqfFJ+W7/QCEcAHCL+Bw6OydG5NFORr3p57PXjjcL/uKmxPBrWg2Bz6uT4uR\nMhj0zttiFHLAt9jGfyk6W57UNUja1e1ggftJJhs=\n-----END CERTIFICATE-----\n'
|
||||
|
||||
const server_tls_key = '-----BEGIN RSA PRIVATE KEY-----\nMIIJKQIBAAKCAgEAuoAjh+pSL5AFVZyxeCWi4t04wvn7DT7WFJHUp07RDlc91ACz\n/JhYn4pSQPAb76t/L0NQ+rsU1EFW6tdCRwC2fqBd2WUZRKPBSIOj83iYw7JL/ay5\n8G5p7DDuNWKumkf7PA/W1SeNF3qSGXpuLUhqf1VLOvcjL0KRciDUZTgI9eQ7J0t6\nhB96lfFagrU5Q3BPdUoL+y0B9jeP80gh6IliTK5EYUKTGf8FSM34CxeJO6BFxgoQ\nUd0g+Q64JelhuF/6UuxzOnEKzet1kb1v83qYQMLNdOZ4eSayN09N/7WTkNlm8rNU\nEGuB3EKAFvvlHCMXm/1aachzffBaB8kLoVpTOP01jFhRs8cP6NPPSskH+OPi7/Em\nUz97k1GZd1bvSk4wpe1aNFKiUs4Ug8otjWqzcoxMBnEopY8MMXtaUwnExG3ueeeS\nznLDjc2iDgKMMuTVmAROc+kuCjeNuYl0i/44cpMmsGVW8OrtVPKLEKhLkbwqg6bJ\nAmFpcbo3ATaJWEfkkgqDOAJ9618OyePGon+q2sVc9rwUXZOyiYPQ0r8ZmYhyz705\n5UHvkmfm6EV8sdJymJNu67nDc/8sj8yqKEtEHpFE2dHM8fx8iDltgL1vvvS1r/wZ\nwVG5gTy35REa2pcmggqLFsOWpXniRqXwVDNjOERN/340zopv+UoR8b+myW8CAwEA\nAQKCAgEAkcoffF0JOBMOiHlAJhrNtSiX+ZruzNDlCxlgshUjyWEbfQG7sWbqSHUZ\njZflTrqyZqDpyca7Jp2ZM2Vocxa0klIMayfj08trCaOWY3pPeROE4d3HUJMPjEpH\nvEXTFdnVJIOBPgl3+vWfBfm17QIh9j4X3BVbVNNl3WCaiDGAl699Kl+Pe38cFeCh\nD3JZPEWsZ5SlvwjU8sNGbThjAWN8C1NjMuCXG4hGej5Ae3M/nPPR91jgnw4Me4Ut\nIL3K3RVyGqaqAPJjLsu0kWQUArJAGMfvUkXjwVklkaUV5SHtJBs+pdTXjyprTmJR\nvSXWWON5zkAEEJNY7QcZaeKYi96PFLUFI+ciEdnXn74CfSKhgZCBo+OyFZjDWW5R\nNmgAbZTN2RW0z+V54Lg36JfJrmiGs8TN06KwNjFo+iOJCdQnoUSIhTlmMfVbXPah\ntRfQvwqtfqVS9W/jkiGq9yDDqyXx093R/QTM/XqDlWJ2iOJFppOJefGFCWF6Fwll\nVT9povTAGQmXFiAxwFZxWtbFa0i8fP5QG80X6l/gRklSd6ZXAVvcLkaFGqxunDAe\nrYC2jBwHWRpVmbxw880SWRzlAsJXc7M8PQnBTlyX1mFZNnwAJgqplz0BQHQhQh4V\nqNfisUm9smtda+Hr9GBBUxs09ulery3I0lQjsArVxPqPVgUbFPECggEBANqLA5fH\n2LupOBoFH/fK5jixyGdSB8eJvU+XuS8RBBexnzTQApmDHiU7Axa/cKvxAfUgwBpU\n6OIsL6Lq6wowVInBgo7GraACwspGMIP8Z7+A8qDgSWIcpXP21Ny2RW+nukdH8ZnV\nTFtiFxLYU9GRfzSUcqvE0miKfMGP/S9Cqbew00K6CQ2xurLTR2AchfUQZJJIg7eF\nRBoftthXLQ+s1JoiLJX2gqCliFy32RMAUP+pKvKVJmVQh8bxEkoEzTV2eY7eTxsH\nJDH5hD66EZ5bW/nVAMruJ3iKjy3WvjDbnddNAz9IFKrd1RMP9dgSEKuSv/HhqwPe\n1q9Wm6LWZo8BlYcCggEBANp3M14QMcMxRlZE0TiSopi1CaE8OG0C9apToS1dol2s\n4lCsWHVPIC516LMPGU0bmCdtwJey1mgXQEKVxCWHkVhhoCKT/tN53o5qkptrhrXL\npbqmRfoMXI7LwJU+Vqi5fwSPGrSR/IzHwCUL7pHTbYN7wT5rr2rcC84XYSX31TFm\nNfMnbDuUk33ycAo07Vqts5A5FN+xViEUMFSDmfA2XmOAV77awz0l/3n3qOg9lQYe\nU4Av2nT19lGELirLInkB1ndLirWAcLaCBXKOLW4bzpNm9Bt8aiziVzcUzlJlLa+1\nnb/7//xzKi0eM/BhyJfhsmOz5B8AQ6Ca/keDk8M7JtkCggEARl8DDinE6VCpBv/l\ndlX4YgMlQ9fPN3pr4ig58iTpi3Ofj1L3s1TcLSLecMG+Vy9o8PTVxuTWhJWz1SMO\nAh7j6ePM1Yq2N9MLxDRrxOROyASOnCz8lEIjKL8vdc6fdz+sJO3OpzleuAJS6beM\n7euK6XRvpE3hbtZBK9bgsQonOkYPEOp0pds4AgM0dYdZvzrDF7OP7lVUQ5E4wFr5\n4JVHdEZS0wsoru/+g9STaqHscxaXBLvwPCl9Pxs7R2haZ7+5jr6Y/FwFVK5C3ivu\nJm7GpCDpe27KeO8tAZancXYWUlCzHfpo5Ug/Jz85a5UNlyHO+uUuuzVTLeyWew3M\nwnnBGwKCAQEAqGTBP3wUH3TX1p9s9cJxemvxZEra44woeIXF8wX9pV8hgzWVabb4\nA1f3ai31Pq5KdfnvPf8nrUxex/RRIOyCaDG4EW8qOS/zEKutHgef6nly4ZBQ2BC3\nN4pug5ttiNiSw5za5NyyYoGF5ghweA8UlwjJR6gRqri6kL0MsQt7VXyHkUmN787y\ncV5yZiut2PuTMVQOdu5miVDagAqAmdwOnXvMJtzRKU0kw4rWs0zklbbCfkhkh0sf\n9m2AeJPjmoqEGags3wKF3ugR8t8MvZbJgG0XNCiOXtKIj3iGIJTExm+jjNxd0OWk\nWOqy9lMpH4lky91ZtVuqxR0za0RMnWv24QKCAQBe8l0w9AYVNGDLv1jyPcbsncty\nNYI81yqe2mL+TC00sMCeil7C7WCP7kRklY01rH5q5gJ9Q1UV+bOj2fQdXDmQ5Bgo\n41jseh44gkbuXAeWcSDrDkJCrfvlNqFobTmUb8cdb9aQlHYfOJ31367LJspiw2SY\nmCbnLQ5sMnyBiMkcn0GfBV6IAkZVN73DPa8a1m/0Qrrv1GmBJFVbuZd9d/hAWpHa\nekhXPq0Sta+RNDfBR3aI5lAmVA17qRGiubQYJ+Ldq0aRJ40fGE51ctoSU/5RMcmh\n6+Qro+jSC94L46xMFp+1J5atgB1p/jVzTT/Ws7SLyotYUSL8zU7tcLiycQXs\n-----END RSA PRIVATE KEY-----\n'
|
||||
|
||||
struct EchoHandler {
|
||||
mut:
|
||||
last_path string
|
||||
}
|
||||
|
||||
fn (mut h EchoHandler) handle(req http.Request) http.Response {
|
||||
h.last_path = req.url
|
||||
return http.Response{
|
||||
status_code: 200
|
||||
body: 'tls hello ${req.url}'
|
||||
}
|
||||
}
|
||||
|
||||
struct BlockingHandler {
|
||||
started chan bool
|
||||
release chan bool
|
||||
}
|
||||
|
||||
fn testsuite_end() {
|
||||
http.close_idle_connections()
|
||||
}
|
||||
|
||||
fn (mut h BlockingHandler) handle(req http.Request) http.Response {
|
||||
h.started <- true
|
||||
_ := <-h.release
|
||||
return http.Response{
|
||||
status_code: 200
|
||||
header: http.new_header(key: .connection, value: 'close')
|
||||
body: 'released'
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_port() !int {
|
||||
mut l := net.listen_tcp(.ip, '127.0.0.1:0')!
|
||||
port := l.addr()!.port()!
|
||||
l.close()!
|
||||
return port
|
||||
}
|
||||
|
||||
fn test_server_tls_round_trip() {
|
||||
$if use_openssl ? {
|
||||
// TLS termination for net.http.Server is not yet supported on the
|
||||
// OpenSSL backend; the listener stub reports a clear runtime error and
|
||||
// the test is skipped here so the suite stays green under
|
||||
// `-d use_openssl`.
|
||||
eprintln('skipping: TLS server not implemented for -d use_openssl yet')
|
||||
return
|
||||
}
|
||||
port := pick_port() or {
|
||||
assert false, 'pick_port: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &http.Server{
|
||||
addr: '127.0.0.1:${port}'
|
||||
cert: server_tls_cert
|
||||
cert_key: server_tls_key
|
||||
in_memory_verification: true
|
||||
accept_timeout: time.second
|
||||
handler: EchoHandler{}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn srv.listen_and_serve()
|
||||
srv.wait_till_running() or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'server failed to start: ${err}'
|
||||
return
|
||||
}
|
||||
defer {
|
||||
srv.close()
|
||||
t.wait()
|
||||
}
|
||||
// Give the listener a beat to come up.
|
||||
time.sleep(50 * time.millisecond)
|
||||
|
||||
resp := http.fetch(
|
||||
url: 'https://127.0.0.1:${port}/hello'
|
||||
validate: false
|
||||
) or {
|
||||
assert false, 'fetch failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == 'tls hello /hello'
|
||||
}
|
||||
|
||||
fn test_server_tls_stop() {
|
||||
$if use_openssl ? {
|
||||
eprintln('skipping: TLS server not implemented for -d use_openssl yet')
|
||||
return
|
||||
}
|
||||
port := pick_port() or {
|
||||
assert false, 'pick_port: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &http.Server{
|
||||
addr: '127.0.0.1:${port}'
|
||||
cert: server_tls_cert
|
||||
cert_key: server_tls_key
|
||||
in_memory_verification: true
|
||||
accept_timeout: 100 * time.millisecond
|
||||
handler: EchoHandler{}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn srv.listen_and_serve()
|
||||
srv.wait_till_running() or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'server failed to start: ${err}'
|
||||
return
|
||||
}
|
||||
srv.stop()
|
||||
assert srv.status() == .stopped
|
||||
t.wait()
|
||||
assert srv.status() == .closed
|
||||
}
|
||||
|
||||
fn test_server_tls_close_caps_default_accept_poll() {
|
||||
$if use_openssl ? {
|
||||
eprintln('skipping: TLS server not implemented for -d use_openssl yet')
|
||||
return
|
||||
}
|
||||
port := pick_port() or {
|
||||
assert false, 'pick_port: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &http.Server{
|
||||
addr: '127.0.0.1:${port}'
|
||||
cert: server_tls_cert
|
||||
cert_key: server_tls_key
|
||||
in_memory_verification: true
|
||||
handler: EchoHandler{}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn srv.listen_and_serve()
|
||||
srv.wait_till_running() or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'server failed to start: ${err}'
|
||||
return
|
||||
}
|
||||
sw := time.new_stopwatch()
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert sw.elapsed() < time.second
|
||||
assert srv.status() == .closed
|
||||
}
|
||||
|
||||
fn test_server_tls_close_waits_for_active_request() {
|
||||
$if use_openssl ? {
|
||||
eprintln('skipping: TLS server not implemented for -d use_openssl yet')
|
||||
return
|
||||
}
|
||||
port := pick_port() or {
|
||||
assert false, 'pick_port: ${err}'
|
||||
return
|
||||
}
|
||||
started := chan bool{cap: 1}
|
||||
release := chan bool{cap: 1}
|
||||
done := chan string{cap: 1}
|
||||
mut srv := &http.Server{
|
||||
addr: '127.0.0.1:${port}'
|
||||
cert: server_tls_cert
|
||||
cert_key: server_tls_key
|
||||
in_memory_verification: true
|
||||
accept_timeout: time.second
|
||||
handler: BlockingHandler{
|
||||
started: started
|
||||
release: release
|
||||
}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn srv.listen_and_serve()
|
||||
srv.wait_till_running() or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'server failed to start: ${err}'
|
||||
return
|
||||
}
|
||||
spawn fn [done, port] () {
|
||||
resp := http.fetch(
|
||||
url: 'https://127.0.0.1:${port}/blocked'
|
||||
enable_http2: false
|
||||
validate: false
|
||||
) or {
|
||||
done <- 'error: ${err}'
|
||||
return
|
||||
}
|
||||
done <- resp.body
|
||||
}()
|
||||
select {
|
||||
_ := <-started {}
|
||||
msg := <-done {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'client finished before handler started: ${msg}'
|
||||
return
|
||||
}
|
||||
2 * time.second {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'timed out waiting for handler to start'
|
||||
return
|
||||
}
|
||||
}
|
||||
srv.close()
|
||||
time.sleep(50 * time.millisecond)
|
||||
release <- true
|
||||
assert (<-done) == 'released'
|
||||
t.wait()
|
||||
assert srv.status() == .closed
|
||||
}
|
||||
|
||||
fn test_server_tls_close_during_silent_handshake() {
|
||||
$if use_openssl ? {
|
||||
eprintln('skipping: TLS server not implemented for -d use_openssl yet')
|
||||
return
|
||||
}
|
||||
port := pick_port() or {
|
||||
assert false, 'pick_port: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &http.Server{
|
||||
addr: '127.0.0.1:${port}'
|
||||
cert: server_tls_cert
|
||||
cert_key: server_tls_key
|
||||
in_memory_verification: true
|
||||
accept_timeout: 100 * time.millisecond
|
||||
handler: EchoHandler{}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn srv.listen_and_serve()
|
||||
srv.wait_till_running() or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'server failed to start: ${err}'
|
||||
return
|
||||
}
|
||||
mut client := net.dial_tcp('127.0.0.1:${port}') or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'tcp dial failed: ${err}'
|
||||
return
|
||||
}
|
||||
defer {
|
||||
client.close() or {}
|
||||
}
|
||||
time.sleep(50 * time.millisecond)
|
||||
sw := time.new_stopwatch()
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert sw.elapsed() < time.second
|
||||
assert srv.status() == .closed
|
||||
}
|
||||
|
||||
fn test_server_tls_close_interrupts_idle_keep_alive() {
|
||||
$if use_openssl ? {
|
||||
eprintln('skipping: TLS server not implemented for -d use_openssl yet')
|
||||
return
|
||||
}
|
||||
port := pick_port() or {
|
||||
assert false, 'pick_port: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &http.Server{
|
||||
addr: '127.0.0.1:${port}'
|
||||
cert: server_tls_cert
|
||||
cert_key: server_tls_key
|
||||
in_memory_verification: true
|
||||
accept_timeout: time.second
|
||||
read_timeout: 5 * time.second
|
||||
handler: EchoHandler{}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn srv.listen_and_serve()
|
||||
srv.wait_till_running() or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'server failed to start: ${err}'
|
||||
return
|
||||
}
|
||||
mut client := mbedtls.new_ssl_conn(mbedtls.SSLConnectConfig{}) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'ssl client init failed: ${err}'
|
||||
return
|
||||
}
|
||||
defer {
|
||||
client.shutdown() or {}
|
||||
}
|
||||
client.dial('127.0.0.1', port) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'ssl dial failed: ${err}'
|
||||
return
|
||||
}
|
||||
request := 'GET /idle HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: keep-alive\r\n\r\n'
|
||||
client.write_string(request) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'ssl write failed: ${err}'
|
||||
return
|
||||
}
|
||||
mut buf := []u8{len: 4096}
|
||||
n := client.read(mut buf) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'ssl read failed: ${err}'
|
||||
return
|
||||
}
|
||||
response := buf[..n].bytestr()
|
||||
assert response.to_lower().contains('connection: keep-alive')
|
||||
assert response.contains('tls hello /idle')
|
||||
|
||||
sw := time.new_stopwatch()
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert sw.elapsed() < 2 * time.second
|
||||
assert srv.status() == .closed
|
||||
}
|
||||
|
||||
fn test_server_tls_close_interrupts_idle_h2() {
|
||||
$if use_openssl ? {
|
||||
eprintln('skipping: TLS server not implemented for -d use_openssl yet')
|
||||
return
|
||||
}
|
||||
port := pick_port() or {
|
||||
assert false, 'pick_port: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &http.Server{
|
||||
addr: '127.0.0.1:${port}'
|
||||
cert: server_tls_cert
|
||||
cert_key: server_tls_key
|
||||
in_memory_verification: true
|
||||
accept_timeout: time.second
|
||||
read_timeout: 5 * time.second
|
||||
enable_http2: true
|
||||
handler: EchoHandler{}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn srv.listen_and_serve()
|
||||
srv.wait_till_running() or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'server failed to start: ${err}'
|
||||
return
|
||||
}
|
||||
mut client := mbedtls.new_ssl_conn(mbedtls.SSLConnectConfig{
|
||||
alpn_protocols: ['h2']
|
||||
}) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'ssl client init failed: ${err}'
|
||||
return
|
||||
}
|
||||
defer {
|
||||
client.shutdown() or {}
|
||||
}
|
||||
client.dial('127.0.0.1', port) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'ssl dial failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert client.negotiated_alpn() == 'h2'
|
||||
mut h2 := http.new_h2_conn(client)
|
||||
resp := h2.do(http.H2ClientRequest{
|
||||
method: 'GET'
|
||||
scheme: 'https'
|
||||
authority: '127.0.0.1:${port}'
|
||||
path: '/h2-idle'
|
||||
}) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'h2 request failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status == 200
|
||||
assert resp.body.bytestr() == 'tls hello /h2-idle'
|
||||
|
||||
sw := time.new_stopwatch()
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert sw.elapsed() < 2 * time.second
|
||||
assert srv.status() == .closed
|
||||
}
|
||||
|
||||
fn test_server_tls_close_interrupts_incomplete_h2_request() {
|
||||
$if use_openssl ? {
|
||||
eprintln('skipping: TLS server not implemented for -d use_openssl yet')
|
||||
return
|
||||
}
|
||||
port := pick_port() or {
|
||||
assert false, 'pick_port: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &http.Server{
|
||||
addr: '127.0.0.1:${port}'
|
||||
cert: server_tls_cert
|
||||
cert_key: server_tls_key
|
||||
in_memory_verification: true
|
||||
accept_timeout: time.second
|
||||
read_timeout: 2 * time.second
|
||||
enable_http2: true
|
||||
handler: EchoHandler{}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn srv.listen_and_serve()
|
||||
srv.wait_till_running() or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'server failed to start: ${err}'
|
||||
return
|
||||
}
|
||||
mut client := mbedtls.new_ssl_conn(mbedtls.SSLConnectConfig{
|
||||
alpn_protocols: ['h2']
|
||||
}) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'ssl client init failed: ${err}'
|
||||
return
|
||||
}
|
||||
defer {
|
||||
client.shutdown() or {}
|
||||
}
|
||||
client.dial('127.0.0.1', port) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'ssl dial failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert client.negotiated_alpn() == 'h2'
|
||||
mut enc := http.H2HpackEncoder{}
|
||||
block := enc.encode([
|
||||
http.H2HeaderField{':method', 'POST'},
|
||||
http.H2HeaderField{':scheme', 'https'},
|
||||
http.H2HeaderField{':authority', '127.0.0.1:${port}'},
|
||||
http.H2HeaderField{':path', '/h2-incomplete'},
|
||||
])
|
||||
mut out := []u8{}
|
||||
out << http.h2_client_preface.bytes()
|
||||
out << http.H2Frame(http.H2SettingsFrame{}).encode()
|
||||
out << http.H2Frame(http.H2HeadersFrame{
|
||||
stream_id: 1
|
||||
fragment: block
|
||||
end_headers: true
|
||||
end_stream: false
|
||||
}).encode()
|
||||
written := client.write(out) or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'ssl write failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert written == out.len
|
||||
time.sleep(100 * time.millisecond)
|
||||
|
||||
sw := time.new_stopwatch()
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert sw.elapsed() < time.second
|
||||
assert srv.status() == .closed
|
||||
}
|
||||
|
||||
fn test_server_tls_h2_negotiation() {
|
||||
$if use_openssl ? {
|
||||
eprintln('skipping: TLS server not implemented for -d use_openssl yet')
|
||||
return
|
||||
}
|
||||
port := pick_port() or {
|
||||
assert false, 'pick_port: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &http.Server{
|
||||
addr: '127.0.0.1:${port}'
|
||||
cert: server_tls_cert
|
||||
cert_key: server_tls_key
|
||||
in_memory_verification: true
|
||||
accept_timeout: time.second
|
||||
enable_http2: true
|
||||
handler: EchoHandler{}
|
||||
show_startup_message: false
|
||||
}
|
||||
t := spawn srv.listen_and_serve()
|
||||
srv.wait_till_running() or {
|
||||
srv.close()
|
||||
t.wait()
|
||||
assert false, 'server failed to start: ${err}'
|
||||
return
|
||||
}
|
||||
defer {
|
||||
srv.close()
|
||||
t.wait()
|
||||
}
|
||||
time.sleep(50 * time.millisecond)
|
||||
|
||||
// Client opts into HTTP/2; server must select `h2` via ALPN and serve the
|
||||
// request through its HTTP/2 driver.
|
||||
resp := http.fetch(
|
||||
url: 'https://127.0.0.1:${port}/h2'
|
||||
enable_http2: true
|
||||
validate: false
|
||||
) or {
|
||||
assert false, 'h2 fetch failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.version() == .v2_0
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == 'tls hello /h2'
|
||||
|
||||
// With HTTP/2 disabled on the client, the server must keep speaking
|
||||
// HTTP/1.1 to the same listener. (enable_http2 defaults to true since
|
||||
// vlang/v#27384, so it must be opted out of explicitly here.)
|
||||
resp_h1 := http.fetch(
|
||||
url: 'https://127.0.0.1:${port}/h1'
|
||||
enable_http2: false
|
||||
validate: false
|
||||
) or {
|
||||
assert false, 'h1 fetch failed: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp_h1.version() == .v1_1
|
||||
assert resp_h1.status_code == 200
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
module http
|
||||
|
||||
import net
|
||||
import time
|
||||
|
||||
fn test_tls_accept_timeouts_finite_handshake_for_infinite_accept() {
|
||||
// An infinite accept timeout (<= 0, or net.infinite_timeout) must still
|
||||
// yield a finite handshake timeout, so a client stalling mid-handshake
|
||||
// cannot wedge the accept thread or block shutdown.
|
||||
// net.infinite_timeout is i64.max (positive), so the > 0 guard alone is
|
||||
// insufficient — mbedtls treats it as an infinite deadline.
|
||||
accept_poll_timeout, handshake_timeout := tls_accept_timeouts(0, tls_handshake_timeout)
|
||||
assert accept_poll_timeout == tls_accept_poll_timeout
|
||||
assert handshake_timeout == tls_handshake_timeout
|
||||
_, neg_handshake_timeout := tls_accept_timeouts(-1, tls_handshake_timeout)
|
||||
assert neg_handshake_timeout == tls_handshake_timeout
|
||||
_, inf_handshake_timeout := tls_accept_timeouts(net.infinite_timeout, tls_handshake_timeout)
|
||||
assert inf_handshake_timeout == tls_handshake_timeout
|
||||
}
|
||||
|
||||
fn test_tls_accept_timeouts_cap_poll_without_changing_handshake_timeout() {
|
||||
accept_poll_timeout, handshake_timeout := tls_accept_timeouts(time.second,
|
||||
tls_handshake_timeout)
|
||||
assert accept_poll_timeout == tls_accept_poll_timeout
|
||||
assert handshake_timeout == time.second
|
||||
}
|
||||
|
||||
fn test_tls_accept_timeouts_keep_short_accept_timeout() {
|
||||
accept_poll_timeout, handshake_timeout := tls_accept_timeouts(50 * time.millisecond,
|
||||
tls_handshake_timeout)
|
||||
assert accept_poll_timeout == 50 * time.millisecond
|
||||
assert handshake_timeout == 50 * time.millisecond
|
||||
}
|
||||
|
||||
fn test_tls_accept_timeouts_configurable_fallback() {
|
||||
// When accept_timeout is infinite, the handshake_fallback parameter
|
||||
// (Server.tls_handshake_timeout) governs the budget — not the constant.
|
||||
custom_fallback := 5 * time.second
|
||||
_, handshake_timeout := tls_accept_timeouts(0, custom_fallback)
|
||||
assert handshake_timeout == custom_fallback
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2020 Justin E. Jones. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// The status codes listed here are based on the comprehensive list,
|
||||
// available at:
|
||||
// https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
|
||||
pub enum Status {
|
||||
unknown = -1
|
||||
unassigned = 0
|
||||
cont = 100
|
||||
switching_protocols = 101
|
||||
processing = 102
|
||||
checkpoint_draft = 103
|
||||
ok = 200
|
||||
created = 201
|
||||
accepted = 202
|
||||
non_authoritative_information = 203
|
||||
no_content = 204
|
||||
reset_content = 205
|
||||
partial_content = 206
|
||||
multi_status = 207
|
||||
already_reported = 208
|
||||
im_used = 226
|
||||
multiple_choices = 300
|
||||
moved_permanently = 301
|
||||
found = 302
|
||||
see_other = 303
|
||||
not_modified = 304
|
||||
use_proxy = 305
|
||||
switch_proxy = 306
|
||||
temporary_redirect = 307
|
||||
permanent_redirect = 308
|
||||
bad_request = 400
|
||||
unauthorized = 401
|
||||
payment_required = 402
|
||||
forbidden = 403
|
||||
not_found = 404
|
||||
method_not_allowed = 405
|
||||
not_acceptable = 406
|
||||
proxy_authentication_required = 407
|
||||
request_timeout = 408
|
||||
conflict = 409
|
||||
gone = 410
|
||||
length_required = 411
|
||||
precondition_failed = 412
|
||||
request_entity_too_large = 413
|
||||
request_uri_too_long = 414
|
||||
unsupported_media_type = 415
|
||||
requested_range_not_satisfiable = 416
|
||||
expectation_failed = 417
|
||||
im_a_teapot = 418
|
||||
misdirected_request = 421
|
||||
unprocessable_entity = 422
|
||||
locked = 423
|
||||
failed_dependency = 424
|
||||
unordered_collection = 425
|
||||
upgrade_required = 426
|
||||
precondition_required = 428
|
||||
too_many_requests = 429
|
||||
request_header_fields_too_large = 431
|
||||
unavailable_for_legal_reasons = 451
|
||||
client_closed_request = 499
|
||||
internal_server_error = 500
|
||||
not_implemented = 501
|
||||
bad_gateway = 502
|
||||
service_unavailable = 503
|
||||
gateway_timeout = 504
|
||||
http_version_not_supported = 505
|
||||
variant_also_negotiates = 506
|
||||
insufficient_storage = 507
|
||||
loop_detected = 508
|
||||
bandwidth_limit_exceeded = 509
|
||||
not_extended = 510
|
||||
network_authentication_required = 511
|
||||
}
|
||||
|
||||
// status_from_int returns the corresponding enum field of Status
|
||||
// given the `code` in integer value.
|
||||
pub fn status_from_int(code int) Status {
|
||||
return match code {
|
||||
100 { Status.cont }
|
||||
101 { Status.switching_protocols }
|
||||
102 { Status.processing }
|
||||
103 { Status.checkpoint_draft }
|
||||
104...199 { Status.unassigned }
|
||||
200 { Status.ok }
|
||||
201 { Status.created }
|
||||
202 { Status.accepted }
|
||||
203 { Status.non_authoritative_information }
|
||||
204 { Status.no_content }
|
||||
205 { Status.reset_content }
|
||||
206 { Status.partial_content }
|
||||
207 { Status.multi_status }
|
||||
208 { Status.already_reported }
|
||||
209...225 { Status.unassigned }
|
||||
226 { Status.im_used }
|
||||
227...299 { Status.unassigned }
|
||||
300 { Status.multiple_choices }
|
||||
301 { Status.moved_permanently }
|
||||
302 { Status.found }
|
||||
303 { Status.see_other }
|
||||
304 { Status.not_modified }
|
||||
305 { Status.use_proxy }
|
||||
306 { Status.switch_proxy }
|
||||
307 { Status.temporary_redirect }
|
||||
308 { Status.permanent_redirect }
|
||||
309...399 { Status.unassigned }
|
||||
400 { Status.bad_request }
|
||||
401 { Status.unauthorized }
|
||||
402 { Status.payment_required }
|
||||
403 { Status.forbidden }
|
||||
404 { Status.not_found }
|
||||
405 { Status.method_not_allowed }
|
||||
406 { Status.not_acceptable }
|
||||
407 { Status.proxy_authentication_required }
|
||||
408 { Status.request_timeout }
|
||||
409 { Status.conflict }
|
||||
410 { Status.gone }
|
||||
411 { Status.length_required }
|
||||
412 { Status.precondition_failed }
|
||||
413 { Status.request_entity_too_large }
|
||||
414 { Status.request_uri_too_long }
|
||||
415 { Status.unsupported_media_type }
|
||||
416 { Status.requested_range_not_satisfiable }
|
||||
417 { Status.expectation_failed }
|
||||
418 { Status.im_a_teapot }
|
||||
419...420 { Status.unassigned }
|
||||
421 { Status.misdirected_request }
|
||||
422 { Status.unprocessable_entity }
|
||||
423 { Status.locked }
|
||||
424 { Status.failed_dependency }
|
||||
425 { Status.unordered_collection }
|
||||
426 { Status.upgrade_required }
|
||||
428 { Status.precondition_required }
|
||||
429 { Status.too_many_requests }
|
||||
431 { Status.request_header_fields_too_large }
|
||||
432...450 { Status.unassigned }
|
||||
451 { Status.unavailable_for_legal_reasons }
|
||||
452...499 { Status.unassigned }
|
||||
500 { Status.internal_server_error }
|
||||
501 { Status.not_implemented }
|
||||
502 { Status.bad_gateway }
|
||||
503 { Status.service_unavailable }
|
||||
504 { Status.gateway_timeout }
|
||||
505 { Status.http_version_not_supported }
|
||||
506 { Status.variant_also_negotiates }
|
||||
507 { Status.insufficient_storage }
|
||||
508 { Status.loop_detected }
|
||||
509 { Status.bandwidth_limit_exceeded }
|
||||
510 { Status.not_extended }
|
||||
511 { Status.network_authentication_required }
|
||||
512...599 { Status.unassigned }
|
||||
else { Status.unknown }
|
||||
}
|
||||
}
|
||||
|
||||
// str returns the string representation of Status `code`.
|
||||
pub fn (code Status) str() string {
|
||||
return match code {
|
||||
.cont { 'Continue' }
|
||||
.switching_protocols { 'Switching Protocols' }
|
||||
.processing { 'Processing' }
|
||||
.checkpoint_draft { 'Checkpoint Draft' }
|
||||
.ok { 'OK' }
|
||||
.created { 'Created' }
|
||||
.accepted { 'Accepted' }
|
||||
.non_authoritative_information { 'Non Authoritative Information' }
|
||||
.no_content { 'No Content' }
|
||||
.reset_content { 'Reset Content' }
|
||||
.partial_content { 'Partial Content' }
|
||||
.multi_status { 'Multi Status' }
|
||||
.already_reported { 'Already Reported' }
|
||||
.im_used { 'IM Used' }
|
||||
.multiple_choices { 'Multiple Choices' }
|
||||
.moved_permanently { 'Moved Permanently' }
|
||||
.found { 'Found' }
|
||||
.see_other { 'See Other' }
|
||||
.not_modified { 'Not Modified' }
|
||||
.use_proxy { 'Use Proxy' }
|
||||
.switch_proxy { 'Switch Proxy' }
|
||||
.temporary_redirect { 'Temporary Redirect' }
|
||||
.permanent_redirect { 'Permanent Redirect' }
|
||||
.bad_request { 'Bad Request' }
|
||||
.unauthorized { 'Unauthorized' }
|
||||
.payment_required { 'Payment Required' }
|
||||
.forbidden { 'Forbidden' }
|
||||
.not_found { 'Not Found' }
|
||||
.method_not_allowed { 'Method Not Allowed' }
|
||||
.not_acceptable { 'Not Acceptable' }
|
||||
.proxy_authentication_required { 'Proxy Authentication Required' }
|
||||
.request_timeout { 'Request Timeout' }
|
||||
.conflict { 'Conflict' }
|
||||
.gone { 'Gone' }
|
||||
.length_required { 'Length Required' }
|
||||
.precondition_failed { 'Precondition Failed' }
|
||||
.request_entity_too_large { 'Request Entity Too Large' }
|
||||
.request_uri_too_long { 'Request URI Too Long' }
|
||||
.unsupported_media_type { 'Unsupported Media Type' }
|
||||
.requested_range_not_satisfiable { 'Requested Range Not Satisfiable' }
|
||||
.expectation_failed { 'Expectation Failed' }
|
||||
.im_a_teapot { 'Im a teapot' }
|
||||
.misdirected_request { 'Misdirected Request' }
|
||||
.unprocessable_entity { 'Unprocessable Entity' }
|
||||
.locked { 'Locked' }
|
||||
.failed_dependency { 'Failed Dependency' }
|
||||
.unordered_collection { 'Unordered Collection' }
|
||||
.upgrade_required { 'Upgrade Required' }
|
||||
.precondition_required { 'Precondition Required' }
|
||||
.too_many_requests { 'Too Many Requests' }
|
||||
.request_header_fields_too_large { 'Request Header Fields Too Large' }
|
||||
.unavailable_for_legal_reasons { 'Unavailable For Legal Reasons' }
|
||||
.internal_server_error { 'Internal Server Error' }
|
||||
.not_implemented { 'Not Implemented' }
|
||||
.bad_gateway { 'Bad Gateway' }
|
||||
.service_unavailable { 'Service Unavailable' }
|
||||
.gateway_timeout { 'Gateway Timeout' }
|
||||
.http_version_not_supported { 'HTTP Version Not Supported' }
|
||||
.variant_also_negotiates { 'Variant Also Negotiates' }
|
||||
.insufficient_storage { 'Insufficient Storage' }
|
||||
.loop_detected { 'Loop Detected' }
|
||||
.bandwidth_limit_exceeded { 'Bandwidth Limit Exceeded' }
|
||||
.not_extended { 'Not Extended' }
|
||||
.network_authentication_required { 'Network Authentication Required' }
|
||||
.unassigned { 'Unassigned' }
|
||||
else { 'Unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
// int converts an assigned and known Status to its integral equivalent.
|
||||
// if a Status is unknown or unassigned, this method will return zero
|
||||
pub fn (code Status) int() int {
|
||||
if code in [.unknown, .unassigned] {
|
||||
return 0
|
||||
}
|
||||
return int(code)
|
||||
}
|
||||
|
||||
// is_valid returns true if the status code is assigned and known
|
||||
pub fn (code Status) is_valid() bool {
|
||||
number := code.int()
|
||||
return number >= 100 && number < 600
|
||||
}
|
||||
|
||||
// is_error will return true if the status code represents either a client or
|
||||
// a server error; otherwise will return false
|
||||
pub fn (code Status) is_error() bool {
|
||||
number := code.int()
|
||||
return number >= 400 && number < 600
|
||||
}
|
||||
|
||||
// is_success will return true if the status code represents either an
|
||||
// informational, success, or redirection response; otherwise will return false
|
||||
pub fn (code Status) is_success() bool {
|
||||
number := code.int()
|
||||
return number >= 100 && number < 400
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
module http
|
||||
|
||||
fn test_str() {
|
||||
code := Status.bad_gateway
|
||||
actual := code.str()
|
||||
assert actual == 'Bad Gateway'
|
||||
}
|
||||
|
||||
fn test_int() {
|
||||
code := Status.see_other
|
||||
actual := code.int()
|
||||
assert actual == 303
|
||||
}
|
||||
|
||||
fn test_is_valid() {
|
||||
code := Status.gateway_timeout
|
||||
actual := code.is_valid()
|
||||
assert actual == true
|
||||
}
|
||||
|
||||
fn test_is_valid_negative() {
|
||||
code := Status.unassigned
|
||||
actual := code.is_valid()
|
||||
assert actual == false
|
||||
}
|
||||
|
||||
fn test_is_error() {
|
||||
code := Status.too_many_requests
|
||||
actual := code.is_error()
|
||||
assert actual == true
|
||||
}
|
||||
|
||||
fn test_is_error_negative() {
|
||||
code := Status.cont
|
||||
actual := code.is_error()
|
||||
assert actual == false
|
||||
}
|
||||
|
||||
fn test_is_success() {
|
||||
code := Status.accepted
|
||||
actual := code.is_success()
|
||||
assert actual == true
|
||||
}
|
||||
|
||||
fn test_is_success_negative() {
|
||||
code := Status.forbidden
|
||||
actual := code.is_success()
|
||||
assert actual == false
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
@[has_globals]
|
||||
module http
|
||||
|
||||
import net
|
||||
import net.ssl
|
||||
import sync
|
||||
import time
|
||||
|
||||
// This file implements connection reuse for the HTTP client: a Transport owns
|
||||
// a pool of keep-alive connections, keyed by origin and TLS configuration, and
|
||||
// fetch()/Request.do() route through a process-global default Transport unless
|
||||
// the request opts out (`disable_connection_reuse`) or uses a proxy. The first
|
||||
// cut pools HTTP/1.1 connections (plain TCP and TLS); HTTP/2 requests run on
|
||||
// the existing one-shot driver until the multiplexed H2 connection lands.
|
||||
|
||||
// transport_err_unsafe_retry tags a failed exchange on a transport connection
|
||||
// for a non-idempotent method: the request bytes may have (partially) reached
|
||||
// the server — the write helpers cannot prove otherwise — so it must not be
|
||||
// replayed, neither by round_trip's inner loop nor the caller's outer retry
|
||||
// loop. is_no_need_retry_error recognizes this code so the outer loop honors it.
|
||||
const transport_err_unsafe_retry = -20013
|
||||
|
||||
// H1PooledConn is one keep-alive HTTP/1.1 connection in a Transport pool:
|
||||
// either a plain TCP connection or a TLS one (exactly one of the two is set).
|
||||
@[heap]
|
||||
struct H1PooledConn {
|
||||
mut:
|
||||
key string
|
||||
tcp &net.TcpConn = unsafe { nil }
|
||||
ssl &ssl.SSLConn = unsafe { nil }
|
||||
idle_since time.Time
|
||||
}
|
||||
|
||||
// close_conn shuts the underlying connection down and clears it.
|
||||
fn (mut c H1PooledConn) close_conn() {
|
||||
if c.ssl != unsafe { nil } {
|
||||
c.ssl.shutdown() or {}
|
||||
c.ssl = unsafe { nil }
|
||||
}
|
||||
if c.tcp != unsafe { nil } {
|
||||
c.tcp.close() or {}
|
||||
c.tcp = unsafe { nil }
|
||||
}
|
||||
}
|
||||
|
||||
// refresh_timeouts applies the current request's timeouts to a pooled
|
||||
// connection, which may have been dialled by a request with different ones.
|
||||
fn (mut c H1PooledConn) refresh_timeouts(req &Request) {
|
||||
if c.tcp != unsafe { nil } {
|
||||
c.tcp.set_read_timeout(req.read_timeout)
|
||||
c.tcp.set_write_timeout(req.write_timeout)
|
||||
} else if c.ssl != unsafe { nil } {
|
||||
if req.read_timeout > 0 {
|
||||
c.ssl.set_read_timeout(req.read_timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exchange runs one request/response over the pooled connection, leaving it
|
||||
// open. The bool result reports whether the response was precisely framed, so
|
||||
// the connection can safely carry another request.
|
||||
fn (mut c H1PooledConn) exchange(req &Request, raw string) !(Response, bool) {
|
||||
if c.ssl != unsafe { nil } {
|
||||
return req.h1_exchange_ssl(mut c.ssl, raw)
|
||||
}
|
||||
return req.h1_exchange_tcp(mut c.tcp, raw)
|
||||
}
|
||||
|
||||
// Transport holds the connection pools and reuse policy for HTTP requests. It
|
||||
// is safe for concurrent use; fetch()/Request.do() share one process-global
|
||||
// instance (see default_transport).
|
||||
//
|
||||
// Note: idle_timeout is enforced lazily on checkout of the same pool key; there
|
||||
// is no background reaper, so a connection to an origin that is never revisited
|
||||
// is reclaimed by the global max_idle_conns cap (or close_idle_connections())
|
||||
// rather than on a timer. A time-based reaper is a planned follow-up.
|
||||
@[heap]
|
||||
pub struct Transport {
|
||||
pub mut:
|
||||
// max_idle_conns_per_host caps the idle keep-alive connections retained per
|
||||
// pool key (origin + TLS configuration).
|
||||
max_idle_conns_per_host int = 4
|
||||
// max_idle_conns caps the total idle keep-alive connections kept across all
|
||||
// pool keys, bounding file-descriptor use when many distinct origins are
|
||||
// fetched. The least-recently-used idle connection is evicted on overflow.
|
||||
// 0 disables the global cap.
|
||||
max_idle_conns int = 100
|
||||
// idle_timeout is how long an idle connection may sit in the pool before it
|
||||
// is discarded instead of reused.
|
||||
idle_timeout time.Duration = 90 * time.second
|
||||
mut:
|
||||
mu &sync.Mutex = sync.new_mutex()
|
||||
h1_idle map[string][]&H1PooledConn
|
||||
}
|
||||
|
||||
// new_transport creates an empty Transport with default limits.
|
||||
pub fn new_transport() &Transport {
|
||||
return &Transport{}
|
||||
}
|
||||
|
||||
// Initialized eagerly in _vinit() (before main() and before any thread is
|
||||
// spawned), so the shared default Transport is fully constructed without a
|
||||
// concurrent-startup race. Lazy init via sync.Once is unsafe here: Once sets its
|
||||
// done flag before running the callback, so a second caller can observe "done"
|
||||
// and read this global while it is still nil (vlang/v#27456).
|
||||
__global http_transport_default = new_transport()
|
||||
|
||||
// default_transport returns the process-global Transport that fetch() and
|
||||
// Request.do() route through.
|
||||
pub fn default_transport() &Transport {
|
||||
return http_transport_default
|
||||
}
|
||||
|
||||
// close_idle_connections closes every idle pooled connection held by the
|
||||
// default Transport. Useful before fork()-style process handoffs, or in tests.
|
||||
pub fn close_idle_connections() {
|
||||
mut t := default_transport()
|
||||
t.close_idle()
|
||||
}
|
||||
|
||||
// close_idle closes every idle pooled connection held by this Transport.
|
||||
// In-flight requests are unaffected.
|
||||
pub fn (mut t Transport) close_idle() {
|
||||
mut all := []&H1PooledConn{}
|
||||
t.mu.lock()
|
||||
for _, list in t.h1_idle {
|
||||
for c in list {
|
||||
all << c
|
||||
}
|
||||
}
|
||||
t.h1_idle.clear()
|
||||
t.mu.unlock()
|
||||
for mut c in all {
|
||||
c.close_conn()
|
||||
}
|
||||
}
|
||||
|
||||
// transport_pool_key builds the pool key for a request: connections are only
|
||||
// shared between requests whose origin and TLS-relevant settings all match.
|
||||
// enable_http2 is part of the key because it changes what the connection
|
||||
// advertised via ALPN at dial time: a forced-HTTP/1.1 connection (no ALPN)
|
||||
// must not satisfy an HTTP/2-enabled request, which would otherwise silently
|
||||
// never negotiate h2 against that origin while the pooled connection lives.
|
||||
fn transport_pool_key(req &Request, scheme string, host string, port int) string {
|
||||
// enable_http2 only affects https dials (plain http ignores it), so keep
|
||||
// the plain-http pool unsplit.
|
||||
h2 := scheme == 'https' && req.enable_http2
|
||||
// Length-prefix the free-form string fields (host and the TLS paths/PEM
|
||||
// blobs) so a value containing the '|' separator cannot collide with a
|
||||
// different field split — e.g. cert='a|b',cert_key='c' vs cert='a',
|
||||
// cert_key='b|c' — which would let a request reuse a connection dialed with
|
||||
// the wrong CA or client certificate. Bools and the int port cannot contain
|
||||
// '|', and scheme is a fixed literal, so they need no prefixing.
|
||||
return '${scheme}|${pk_part(host)}|${port}|${h2}|${req.validate}|${pk_part(req.verify)}|${pk_part(req.cert)}|${pk_part(req.cert_key)}|${req.in_memory_verification}'
|
||||
}
|
||||
|
||||
// pk_part length-prefixes a string (`len:value`) so concatenated pool-key
|
||||
// components stay unambiguous regardless of the bytes they contain.
|
||||
fn pk_part(s string) string {
|
||||
return '${s.len}:${s}'
|
||||
}
|
||||
|
||||
// transport_is_idempotent reports whether a request with this method can be
|
||||
// replayed safely after a failure on a reused connection (RFC 7231 4.2.2).
|
||||
fn transport_is_idempotent(method Method) bool {
|
||||
return method in [.get, .head, .options, .trace, .put, .delete]
|
||||
}
|
||||
|
||||
// response_allows_reuse reports whether the server's response permits keeping
|
||||
// the connection open for another request.
|
||||
fn response_allows_reuse(resp &Response) bool {
|
||||
if resp.status_code < 200 {
|
||||
// 1xx handling stops at the headers; the connection state past them is
|
||||
// not tracked, so never reuse.
|
||||
return false
|
||||
}
|
||||
// A server may split Connection across repeated header lines; get() returns
|
||||
// only the first, so join all values before scanning for the close token.
|
||||
conn_tokens := resp.header.values(.connection).join(',').to_lower()
|
||||
if conn_tokens.contains('close') {
|
||||
return false
|
||||
}
|
||||
ver := resp.version()
|
||||
if ver == .v1_0 {
|
||||
// HTTP/1.0 defaults to close; reuse only with an explicit keep-alive.
|
||||
return conn_tokens.contains('keep-alive')
|
||||
}
|
||||
return ver == .v1_1
|
||||
}
|
||||
|
||||
// checkout pops the most recently used idle connection for `key`, discarding
|
||||
// any that sat idle past the timeout. Returns nil when the pool has none.
|
||||
fn (mut t Transport) checkout(key string) &H1PooledConn {
|
||||
mut expired := []&H1PooledConn{}
|
||||
mut found := &H1PooledConn(unsafe { nil })
|
||||
t.mu.lock()
|
||||
for {
|
||||
mut list := t.h1_idle[key] or { break }
|
||||
if list.len == 0 {
|
||||
break
|
||||
}
|
||||
mut c := list.pop()
|
||||
t.h1_idle[key] = list
|
||||
if time.now() - c.idle_since > t.idle_timeout {
|
||||
expired << c
|
||||
continue
|
||||
}
|
||||
found = c
|
||||
break
|
||||
}
|
||||
t.mu.unlock()
|
||||
for mut c in expired {
|
||||
c.close_conn()
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// checkin returns a healthy connection to the idle pool, or closes it when the
|
||||
// per-host pool is already at capacity. Adding it may push the total idle count
|
||||
// over max_idle_conns, in which case the least-recently-used idle connection
|
||||
// across all pools is evicted and closed.
|
||||
fn (mut t Transport) checkin(mut conn H1PooledConn) {
|
||||
conn.idle_since = time.now()
|
||||
mut evicted := &H1PooledConn(unsafe { nil })
|
||||
t.mu.lock()
|
||||
mut list := t.h1_idle[conn.key] or { []&H1PooledConn{} }
|
||||
if list.len >= t.max_idle_conns_per_host {
|
||||
t.mu.unlock()
|
||||
conn.close_conn()
|
||||
return
|
||||
}
|
||||
list << conn
|
||||
t.h1_idle[conn.key] = list
|
||||
if t.max_idle_conns > 0 && t.total_idle_locked() > t.max_idle_conns {
|
||||
evicted = t.evict_oldest_locked()
|
||||
}
|
||||
t.mu.unlock()
|
||||
if evicted != unsafe { nil } {
|
||||
evicted.close_conn()
|
||||
}
|
||||
}
|
||||
|
||||
// total_idle_locked sums the idle connections across all pool keys. The caller
|
||||
// must hold t.mu.
|
||||
fn (t &Transport) total_idle_locked() int {
|
||||
mut n := 0
|
||||
for _, list in t.h1_idle {
|
||||
n += list.len
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// evict_oldest_locked removes and returns the least-recently-used idle
|
||||
// connection across all pool keys (nil if the pool is empty), pruning an
|
||||
// emptied key. The caller must hold t.mu and close the returned connection.
|
||||
fn (mut t Transport) evict_oldest_locked() &H1PooledConn {
|
||||
mut oldest_key := ''
|
||||
mut oldest_idx := -1
|
||||
mut oldest_since := time.now()
|
||||
for k, list in t.h1_idle {
|
||||
for i, c in list {
|
||||
if oldest_idx == -1 || c.idle_since < oldest_since {
|
||||
oldest_key = k
|
||||
oldest_idx = i
|
||||
oldest_since = c.idle_since
|
||||
}
|
||||
}
|
||||
}
|
||||
if oldest_idx == -1 {
|
||||
return &H1PooledConn(unsafe { nil })
|
||||
}
|
||||
mut list := t.h1_idle[oldest_key] or { return &H1PooledConn(unsafe { nil }) }
|
||||
victim := list[oldest_idx]
|
||||
list.delete(oldest_idx)
|
||||
if list.len == 0 {
|
||||
t.h1_idle.delete(oldest_key)
|
||||
} else {
|
||||
t.h1_idle[oldest_key] = list
|
||||
}
|
||||
return victim
|
||||
}
|
||||
|
||||
// maybe_checkin pools the connection when both the read framing and the
|
||||
// response (and request) headers allow reuse; otherwise it closes it.
|
||||
fn (mut t Transport) maybe_checkin(mut conn H1PooledConn, header Header, reusable bool, resp &Response) {
|
||||
if reusable && !header.contains(.connection) && response_allows_reuse(resp) {
|
||||
t.checkin(mut conn)
|
||||
return
|
||||
}
|
||||
conn.close_conn()
|
||||
}
|
||||
|
||||
// round_trip performs one HTTP request through the connection pool: it reuses
|
||||
// an idle connection for the request's pool key when one exists (transparently
|
||||
// retrying once on a connection that turned out to be stale), dials otherwise,
|
||||
// and returns healthy connections to the pool afterwards.
|
||||
fn (mut t Transport) round_trip(req &Request, method Method, scheme string, host string, port int, path string, data string, header Header) !Response {
|
||||
$if windows && !no_vschannel ? {
|
||||
if scheme == 'https' {
|
||||
// The SChannel backend keeps its proven one-shot path until SChannel
|
||||
// pooling lands; plain-http pooling below already works on Windows.
|
||||
return req.ssl_do(port, method, host, path, data, header)
|
||||
}
|
||||
}
|
||||
raw := req.build_request_headers_opts(method, host, port, path, data, header, false)
|
||||
$if trace_http_request ? {
|
||||
eprint('> ')
|
||||
eprint(raw)
|
||||
eprintln('')
|
||||
}
|
||||
key := transport_pool_key(req, scheme, host, port)
|
||||
// A stale pooled connection (closed by the server while idle) fails the
|
||||
// exchange; drain through the pool, then dial fresh.
|
||||
for _ in 0 .. t.max_idle_conns_per_host + 1 {
|
||||
mut conn := t.checkout(key)
|
||||
mut reused := true
|
||||
if conn == unsafe { nil } {
|
||||
reused = false
|
||||
if scheme == 'https' {
|
||||
return t.tls_fresh_round_trip(req, key, raw, method, host, port, path, data, header)
|
||||
}
|
||||
conn = t.dial_h1_tcp(req, key, host, port)!
|
||||
} else {
|
||||
conn.refresh_timeouts(req)
|
||||
}
|
||||
resp, reusable := conn.exchange(req, raw) or {
|
||||
conn.close_conn()
|
||||
// The write helpers do partial writes and cannot report how many
|
||||
// bytes reached the server, so a failed exchange may have delivered
|
||||
// part of the request. Only idempotent methods are safe to replay;
|
||||
// a non-idempotent one is tagged so neither this loop nor the
|
||||
// caller's outer retry loop re-sends it (avoiding duplicate side
|
||||
// effects). A pre-write dial failure, by contrast, propagates before
|
||||
// this point and stays freely retryable.
|
||||
if !transport_is_idempotent(method) {
|
||||
return error_with_code(err.msg(), transport_err_unsafe_retry)
|
||||
}
|
||||
if reused {
|
||||
// Drain stale pooled connections, then fall through to a fresh dial.
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
t.maybe_checkin(mut conn, header, reusable, resp)
|
||||
return resp
|
||||
}
|
||||
return error('http.transport: request failed after retrying on a fresh connection')
|
||||
}
|
||||
|
||||
// dial_h1_tcp opens a fresh plain-TCP connection for `key`.
|
||||
fn (mut t Transport) dial_h1_tcp(req &Request, key string, host string, port int) !&H1PooledConn {
|
||||
mut client := net.dial_tcp('${host}:${port}')!
|
||||
client.set_read_timeout(req.read_timeout)
|
||||
client.set_write_timeout(req.write_timeout)
|
||||
return &H1PooledConn{
|
||||
key: key
|
||||
tcp: client
|
||||
}
|
||||
}
|
||||
|
||||
// tls_fresh_round_trip dials a fresh TLS connection (advertising ALPN h2 when
|
||||
// HTTP/2 is enabled) and completes the request on it. When the server selects
|
||||
// h2, the request runs on the existing one-shot HTTP/2 driver and the
|
||||
// connection is not pooled yet; an http/1.1 connection is pooled afterwards
|
||||
// like any other.
|
||||
fn (mut t Transport) tls_fresh_round_trip(req &Request, key string, raw string, method Method, host string, port int, path string, data string, header Header) !Response {
|
||||
alpn := if req.enable_http2 { ['h2', 'http/1.1'] } else { []string{} }
|
||||
mut ssl_conn := ssl.new_ssl_conn(
|
||||
verify: req.verify
|
||||
cert: req.cert
|
||||
cert_key: req.cert_key
|
||||
validate: req.validate
|
||||
in_memory_verification: req.in_memory_verification
|
||||
alpn_protocols: alpn
|
||||
)!
|
||||
ssl_conn.dial(host, port)!
|
||||
if req.read_timeout > 0 {
|
||||
ssl_conn.set_read_timeout(req.read_timeout)
|
||||
}
|
||||
if req.enable_http2 && ssl_conn.negotiated_alpn() == 'h2' {
|
||||
return req.h2_do(mut ssl_conn, method, host, port, path, data, header)
|
||||
}
|
||||
mut conn := &H1PooledConn{
|
||||
key: key
|
||||
ssl: ssl_conn
|
||||
}
|
||||
resp, reusable := conn.exchange(req, raw) or {
|
||||
conn.close_conn()
|
||||
// Past the TLS handshake the request bytes may have been (partially)
|
||||
// written; a non-idempotent method must not be replayed by the outer
|
||||
// retry loop. (A dial/handshake failure above propagates before this and
|
||||
// stays retryable, since no request byte was sent.)
|
||||
if !transport_is_idempotent(method) {
|
||||
return error_with_code(err.msg(), transport_err_unsafe_retry)
|
||||
}
|
||||
return err
|
||||
}
|
||||
t.maybe_checkin(mut conn, header, reusable, resp)
|
||||
return resp
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
module http
|
||||
|
||||
// Tests for the connection-pooling Transport (transport.v): keep-alive reuse
|
||||
// over plain TCP and TLS, no-reuse on `Connection: close` / truncated reads,
|
||||
// transparent retry on stale pooled connections, idle eviction, and the
|
||||
// `disable_connection_reuse` opt-out. Each test runs its own loopback server
|
||||
// with an accept counter: the accept count is the proof of (non-)reuse.
|
||||
import net
|
||||
import net.mbedtls
|
||||
import sync
|
||||
import time
|
||||
|
||||
const tls_test_cert_path = @VEXEROOT +
|
||||
'/vlib/net/websocket/tests/autobahn/fuzzing_server_wss/config/server.crt'
|
||||
const tls_test_key_path = @VEXEROOT +
|
||||
'/vlib/net/websocket/tests/autobahn/fuzzing_server_wss/config/server.key'
|
||||
|
||||
@[heap]
|
||||
struct KaSrv {
|
||||
mut:
|
||||
mu &sync.Mutex = sync.new_mutex()
|
||||
accepts int
|
||||
// connection_close: respond with `Connection: close` and close afterwards.
|
||||
connection_close bool
|
||||
// close_after_each: close after each response WITHOUT advertising it — the
|
||||
// classic stale-pooled-connection scenario.
|
||||
close_after_each bool
|
||||
// split_connection_close: emit the close token in a SECOND, repeated
|
||||
// `Connection` header (`keep-alive` first, then `close`) while keeping the
|
||||
// socket open — a server that says "do not reuse" via a split field. The
|
||||
// client must honor it and not pool, even though the connection stays usable.
|
||||
split_connection_close bool
|
||||
// drop_on_post: after reading a POST request head, close the connection
|
||||
// without responding — a reused-connection failure after the bytes were
|
||||
// written, for a non-idempotent method.
|
||||
drop_on_post bool
|
||||
posts int // POST request heads actually read by the server
|
||||
body string = 'hello'
|
||||
}
|
||||
|
||||
fn (mut s KaSrv) bump_accepts() {
|
||||
s.mu.lock()
|
||||
s.accepts++
|
||||
s.mu.unlock()
|
||||
}
|
||||
|
||||
fn (mut s KaSrv) accept_count() int {
|
||||
s.mu.lock()
|
||||
defer {
|
||||
s.mu.unlock()
|
||||
}
|
||||
return s.accepts
|
||||
}
|
||||
|
||||
fn (mut s KaSrv) post_count() int {
|
||||
s.mu.lock()
|
||||
defer {
|
||||
s.mu.unlock()
|
||||
}
|
||||
return s.posts
|
||||
}
|
||||
|
||||
// ka_read_request_head reads from `conn` until a full request head (terminated
|
||||
// by a blank line) has arrived, returning it. The test requests carry no bodies.
|
||||
fn ka_read_request_head(mut conn net.TcpConn) !string {
|
||||
mut buf := []u8{len: 4096}
|
||||
mut sofar := []u8{}
|
||||
for {
|
||||
n := conn.read(mut buf)!
|
||||
if n <= 0 {
|
||||
return error('closed')
|
||||
}
|
||||
sofar << buf[..n]
|
||||
if sofar.bytestr().contains('\r\n\r\n') {
|
||||
return sofar.bytestr()
|
||||
}
|
||||
}
|
||||
return error('closed')
|
||||
}
|
||||
|
||||
fn ka_srv_serve_conn(mut s KaSrv, mut conn net.TcpConn) {
|
||||
defer {
|
||||
conn.close() or {}
|
||||
}
|
||||
conn.set_read_timeout(10 * time.second)
|
||||
for {
|
||||
head := ka_read_request_head(mut conn) or { return }
|
||||
if s.drop_on_post && head.starts_with('POST ') {
|
||||
s.mu.lock()
|
||||
s.posts++
|
||||
s.mu.unlock()
|
||||
// Drop the connection without responding: the request bytes were
|
||||
// written, so this is not a stale-write.
|
||||
return
|
||||
}
|
||||
mut resp := 'HTTP/1.1 200 OK\r\nContent-Length: ${s.body.len}\r\n'
|
||||
if s.connection_close {
|
||||
resp += 'Connection: close\r\n'
|
||||
}
|
||||
if s.split_connection_close {
|
||||
resp += 'Connection: keep-alive\r\nConnection: close\r\n'
|
||||
}
|
||||
resp += '\r\n' + s.body
|
||||
conn.write(resp.bytes()) or { return }
|
||||
if s.connection_close || s.close_after_each {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ka_srv_loop(mut s KaSrv, mut listener net.TcpListener) {
|
||||
for {
|
||||
mut conn := listener.accept() or { return }
|
||||
s.bump_accepts()
|
||||
ka_srv_serve_conn(mut s, mut conn)
|
||||
}
|
||||
}
|
||||
|
||||
// start_ka_srv starts a keep-alive capable loopback HTTP server, returning its
|
||||
// port, listener and server thread.
|
||||
fn start_ka_srv(mut s KaSrv) !(int, &net.TcpListener, thread) {
|
||||
mut listener := net.listen_tcp(.ip, '127.0.0.1:0')!
|
||||
port := listener.addr()!.port()!
|
||||
th := spawn ka_srv_loop(mut s, mut listener)
|
||||
return port, listener, th
|
||||
}
|
||||
|
||||
// stop_ka_srv tears a test server down fully before the test returns: closing
|
||||
// the idle pool unblocks a server thread reading a kept-alive connection,
|
||||
// closing the listener aborts its accept, and the join guarantees the thread
|
||||
// is gone before the next test creates sockets (otherwise the OS can recycle
|
||||
// this listener's handle for the next test's listener while the old thread is
|
||||
// still calling accept on it, stealing its connections).
|
||||
fn stop_ka_srv(mut listener net.TcpListener, th thread) {
|
||||
close_idle_connections()
|
||||
listener.close() or {}
|
||||
th.wait()
|
||||
}
|
||||
|
||||
// The pool key must isolate distinct TLS configurations even when a field value
|
||||
// contains the '|' separator, or a request could reuse a connection dialed with
|
||||
// the wrong cert/CA. cert='a|b',cert_key='c' and cert='a',cert_key='b|c' must
|
||||
// not collide.
|
||||
fn test_transport_pool_key_no_delimiter_collision() {
|
||||
a := Request{
|
||||
cert: 'a|b'
|
||||
cert_key: 'c'
|
||||
}
|
||||
b := Request{
|
||||
cert: 'a'
|
||||
cert_key: 'b|c'
|
||||
}
|
||||
assert transport_pool_key(a, 'https', 'h', 443) != transport_pool_key(b, 'https', 'h', 443)
|
||||
// Identical configs still share a key.
|
||||
a2 := Request{
|
||||
cert: 'a|b'
|
||||
cert_key: 'c'
|
||||
}
|
||||
assert transport_pool_key(a, 'https', 'h', 443) == transport_pool_key(a2, 'https', 'h', 443)
|
||||
// A host containing '|' must not collide with a different host/port split.
|
||||
assert transport_pool_key(Request{}, 'https', 'h|x', 443) != transport_pool_key(Request{},
|
||||
'https', 'h', 443)
|
||||
}
|
||||
|
||||
fn test_h1_plain_reuse() {
|
||||
mut srv := &KaSrv{}
|
||||
port, mut listener, th := start_ka_srv(mut srv) or {
|
||||
assert false, 'server: ${err}'
|
||||
return
|
||||
}
|
||||
for i in 0 .. 3 {
|
||||
resp := fetch(url: 'http://127.0.0.1:${port}/r${i}') or {
|
||||
assert false, 'fetch ${i}: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == 'hello'
|
||||
}
|
||||
// All three requests must have shared one connection.
|
||||
assert srv.accept_count() == 1
|
||||
stop_ka_srv(mut listener, th)
|
||||
}
|
||||
|
||||
fn test_h1_no_reuse_on_connection_close_response() {
|
||||
mut srv := &KaSrv{
|
||||
connection_close: true
|
||||
}
|
||||
port, mut listener, th := start_ka_srv(mut srv) or {
|
||||
assert false, 'server: ${err}'
|
||||
return
|
||||
}
|
||||
for i in 0 .. 2 {
|
||||
resp := fetch(url: 'http://127.0.0.1:${port}/r${i}') or {
|
||||
assert false, 'fetch ${i}: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 200
|
||||
}
|
||||
// `Connection: close` responses must not be pooled.
|
||||
assert srv.accept_count() == 2
|
||||
stop_ka_srv(mut listener, th)
|
||||
}
|
||||
|
||||
// A close token carried in a repeated `Connection` header (after a keep-alive
|
||||
// one) must still be honored: parse_headers stores repeats separately and get()
|
||||
// returns only the first, so response_allows_reuse joins all values. The server
|
||||
// keeps the socket open, so a wrongly-pooled connection would be reused (1
|
||||
// accept); honoring the close dials fresh each time (2 accepts).
|
||||
fn test_h1_no_reuse_on_split_connection_close() {
|
||||
mut srv := &KaSrv{
|
||||
split_connection_close: true
|
||||
}
|
||||
port, mut listener, th := start_ka_srv(mut srv) or {
|
||||
assert false, 'server: ${err}'
|
||||
return
|
||||
}
|
||||
for i in 0 .. 2 {
|
||||
resp := fetch(url: 'http://127.0.0.1:${port}/r${i}') or {
|
||||
assert false, 'fetch ${i}: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 200
|
||||
}
|
||||
assert srv.accept_count() == 2
|
||||
stop_ka_srv(mut listener, th)
|
||||
}
|
||||
|
||||
// The total idle pool is bounded by max_idle_conns across all pool keys, not
|
||||
// just per host: checking in more distinct-keyed connections than the cap
|
||||
// evicts the least-recently-used ones (here k0, k1) and keeps the newest.
|
||||
fn test_transport_global_idle_cap() {
|
||||
mut t := new_transport()
|
||||
t.max_idle_conns = 3
|
||||
t.max_idle_conns_per_host = 10 // keep the per-host cap out of the way
|
||||
for i in 0 .. 5 {
|
||||
mut c := &H1PooledConn{
|
||||
key: 'k${i}'
|
||||
}
|
||||
t.checkin(mut c)
|
||||
}
|
||||
mut total := 0
|
||||
for _, list in t.h1_idle {
|
||||
total += list.len
|
||||
}
|
||||
assert total == 3, 'global idle cap not enforced: ${total}'
|
||||
assert 'k0' !in t.h1_idle
|
||||
assert 'k1' !in t.h1_idle
|
||||
assert 'k2' in t.h1_idle
|
||||
assert 'k3' in t.h1_idle
|
||||
assert 'k4' in t.h1_idle
|
||||
}
|
||||
|
||||
fn test_h1_stale_pooled_connection_is_retried() {
|
||||
mut srv := &KaSrv{
|
||||
close_after_each: true
|
||||
}
|
||||
port, mut listener, th := start_ka_srv(mut srv) or {
|
||||
assert false, 'server: ${err}'
|
||||
return
|
||||
}
|
||||
// First request succeeds and the connection is pooled (the response did not
|
||||
// advertise the close). The server then closes it. The second request picks
|
||||
// up the stale connection, fails, and must transparently retry on a fresh
|
||||
// one.
|
||||
for i in 0 .. 2 {
|
||||
resp := fetch(url: 'http://127.0.0.1:${port}/r${i}') or {
|
||||
assert false, 'fetch ${i}: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == 'hello'
|
||||
}
|
||||
assert srv.accept_count() == 2
|
||||
stop_ka_srv(mut listener, th)
|
||||
}
|
||||
|
||||
// A non-idempotent request that fails on a reused keep-alive connection after
|
||||
// its bytes were written must NOT be replayed: doing so could duplicate side
|
||||
// effects. The server reads the POST then drops the connection; the client must
|
||||
// surface the error without retrying on a fresh connection.
|
||||
fn test_h1_unsafe_pooled_post_is_not_retried() {
|
||||
mut srv := &KaSrv{
|
||||
drop_on_post: true
|
||||
}
|
||||
port, mut listener, th := start_ka_srv(mut srv) or {
|
||||
assert false, 'server: ${err}'
|
||||
return
|
||||
}
|
||||
// First, a GET to establish and pool a keep-alive connection.
|
||||
resp := fetch(url: 'http://127.0.0.1:${port}/warmup') or {
|
||||
assert false, 'warmup fetch: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 200
|
||||
// The POST reuses that connection; the server reads it and drops the
|
||||
// connection. The request must fail rather than be re-sent.
|
||||
fetch(method: .post, url: 'http://127.0.0.1:${port}/submit', data: 'payload') or {
|
||||
// expected: the POST failed and was not retried.
|
||||
assert srv.post_count() == 1, 'POST was replayed ${srv.post_count()} times'
|
||||
assert srv.accept_count() == 1, 'a fresh connection was opened to retry the POST'
|
||||
stop_ka_srv(mut listener, th)
|
||||
return
|
||||
}
|
||||
assert false, 'the POST unexpectedly succeeded'
|
||||
stop_ka_srv(mut listener, th)
|
||||
}
|
||||
|
||||
fn test_h1_opt_out_disables_reuse() {
|
||||
mut srv := &KaSrv{}
|
||||
port, mut listener, th := start_ka_srv(mut srv) or {
|
||||
assert false, 'server: ${err}'
|
||||
return
|
||||
}
|
||||
for i in 0 .. 2 {
|
||||
resp := fetch(
|
||||
url: 'http://127.0.0.1:${port}/r${i}'
|
||||
disable_connection_reuse: true
|
||||
) or {
|
||||
assert false, 'fetch ${i}: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 200
|
||||
}
|
||||
// Opt-out requests open one connection each.
|
||||
assert srv.accept_count() == 2
|
||||
stop_ka_srv(mut listener, th)
|
||||
}
|
||||
|
||||
fn test_h1_truncated_read_poisons_connection() {
|
||||
mut srv := &KaSrv{
|
||||
// Larger than the 64KB read buffer, so the body needs several reads and
|
||||
// the stop limit actually interrupts the transfer mid-stream.
|
||||
body: 'x'.repeat(200 * 1024)
|
||||
}
|
||||
port, mut listener, th := start_ka_srv(mut srv) or {
|
||||
assert false, 'server: ${err}'
|
||||
return
|
||||
}
|
||||
// A stop_receiving_limit read leaves unread response bytes on the wire, so
|
||||
// that connection must not be reused.
|
||||
resp1 := fetch(url: 'http://127.0.0.1:${port}/big', stop_receiving_limit: 1000) or {
|
||||
assert false, 'fetch 1: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp1.status_code == 200
|
||||
resp2 := fetch(url: 'http://127.0.0.1:${port}/after') or {
|
||||
assert false, 'fetch 2: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp2.status_code == 200
|
||||
assert srv.accept_count() == 2
|
||||
stop_ka_srv(mut listener, th)
|
||||
}
|
||||
|
||||
fn test_h1_idle_eviction() {
|
||||
mut srv := &KaSrv{}
|
||||
port, mut listener, th := start_ka_srv(mut srv) or {
|
||||
assert false, 'server: ${err}'
|
||||
return
|
||||
}
|
||||
mut t := new_transport()
|
||||
t.idle_timeout = 50 * time.millisecond
|
||||
req := prepare(url: 'http://127.0.0.1:${port}/') or {
|
||||
assert false, 'prepare: ${err}'
|
||||
return
|
||||
}
|
||||
r1 := t.round_trip(req, .get, 'http', '127.0.0.1', port, '/', '', req.header) or {
|
||||
assert false, 'round_trip 1: ${err}'
|
||||
return
|
||||
}
|
||||
assert r1.status_code == 200
|
||||
time.sleep(150 * time.millisecond)
|
||||
// The pooled connection has sat idle past the timeout: it must be evicted
|
||||
// and a fresh one dialled.
|
||||
r2 := t.round_trip(req, .get, 'http', '127.0.0.1', port, '/', '', req.header) or {
|
||||
assert false, 'round_trip 2: ${err}'
|
||||
return
|
||||
}
|
||||
assert r2.status_code == 200
|
||||
assert srv.accept_count() == 2
|
||||
// This test pools in its own private Transport, so flush that one before
|
||||
// the joint teardown.
|
||||
t.close_idle()
|
||||
stop_ka_srv(mut listener, th)
|
||||
}
|
||||
|
||||
// --- TLS (h1 over mbedtls) reuse ---
|
||||
|
||||
@[heap]
|
||||
struct TlsKaSrv {
|
||||
mut:
|
||||
mu &sync.Mutex = sync.new_mutex()
|
||||
accepts int
|
||||
}
|
||||
|
||||
fn (mut s TlsKaSrv) bump_accepts() {
|
||||
s.mu.lock()
|
||||
s.accepts++
|
||||
s.mu.unlock()
|
||||
}
|
||||
|
||||
fn (mut s TlsKaSrv) accept_count() int {
|
||||
s.mu.lock()
|
||||
defer {
|
||||
s.mu.unlock()
|
||||
}
|
||||
return s.accepts
|
||||
}
|
||||
|
||||
fn tls_ka_srv_serve_conn(mut conn mbedtls.SSLConn) {
|
||||
defer {
|
||||
conn.shutdown() or {}
|
||||
}
|
||||
body := 'tls hello'
|
||||
for {
|
||||
mut buf := []u8{len: 4096}
|
||||
mut sofar := []u8{}
|
||||
for !sofar.bytestr().contains('\r\n\r\n') {
|
||||
n := conn.read(mut buf) or { return }
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
sofar << buf[..n]
|
||||
}
|
||||
conn.write_string('HTTP/1.1 200 OK\r\nContent-Length: ${body.len}\r\n\r\n${body}') or {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tls_ka_srv_loop(mut s TlsKaSrv, mut listener mbedtls.SSLListener) {
|
||||
for {
|
||||
mut conn := listener.accept() or { return }
|
||||
s.bump_accepts()
|
||||
// Serve each connection on its own thread: a pooled idle connection
|
||||
// keeps its serve loop parked in read, which must not block accepting
|
||||
// the next connection. (The serve threads exit when the test closes
|
||||
// the pooled connections via close_idle_connections.)
|
||||
spawn tls_ka_srv_serve_conn(mut conn)
|
||||
}
|
||||
}
|
||||
|
||||
fn test_h1_tls_reuse() {
|
||||
$if windows && !no_vschannel ? {
|
||||
// The default Windows TLS backend (SChannel) keeps its one-shot path
|
||||
// until SChannel pooling lands.
|
||||
eprintln('skipping: SChannel connection pooling is not implemented yet')
|
||||
return
|
||||
}
|
||||
mut port_listener := net.listen_tcp(.ip, '127.0.0.1:0') or {
|
||||
assert false, 'port: ${err}'
|
||||
return
|
||||
}
|
||||
port := port_listener.addr() or {
|
||||
assert false, 'addr: ${err}'
|
||||
return
|
||||
}.port() or {
|
||||
assert false, 'port: ${err}'
|
||||
return
|
||||
}
|
||||
port_listener.close() or {}
|
||||
mut listener := mbedtls.new_ssl_listener('127.0.0.1:${port}', mbedtls.SSLConnectConfig{
|
||||
cert: tls_test_cert_path
|
||||
cert_key: tls_test_key_path
|
||||
validate: false
|
||||
}) or {
|
||||
assert false, 'listener: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &TlsKaSrv{}
|
||||
th := spawn tls_ka_srv_loop(mut srv, mut listener)
|
||||
for i in 0 .. 2 {
|
||||
resp := fetch(url: 'https://127.0.0.1:${port}/r${i}', validate: false) or {
|
||||
assert false, 'fetch ${i}: ${err}'
|
||||
return
|
||||
}
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == 'tls hello'
|
||||
}
|
||||
// Both https requests must have shared one TLS connection.
|
||||
assert srv.accept_count() == 1
|
||||
// Free the pooled TLS connection (unblocking the server read), then abort
|
||||
// the accept and join the server thread before the test returns.
|
||||
close_idle_connections()
|
||||
listener.shutdown() or {}
|
||||
th.wait()
|
||||
}
|
||||
|
||||
// A TLS connection dialled with HTTP/2 disabled (no ALPN) must not satisfy an
|
||||
// HTTP/2-enabled request to the same origin — the ALPN preference is part of
|
||||
// the pool key. Forced-h1 requests still share among themselves.
|
||||
fn test_h1_tls_no_reuse_across_alpn_preference() {
|
||||
$if windows && !no_vschannel ? {
|
||||
eprintln('skipping: SChannel connection pooling is not implemented yet')
|
||||
return
|
||||
}
|
||||
mut port_listener := net.listen_tcp(.ip, '127.0.0.1:0') or {
|
||||
assert false, 'port: ${err}'
|
||||
return
|
||||
}
|
||||
port := port_listener.addr() or {
|
||||
assert false, 'addr: ${err}'
|
||||
return
|
||||
}.port() or {
|
||||
assert false, 'port: ${err}'
|
||||
return
|
||||
}
|
||||
port_listener.close() or {}
|
||||
mut listener := mbedtls.new_ssl_listener('127.0.0.1:${port}', mbedtls.SSLConnectConfig{
|
||||
cert: tls_test_cert_path
|
||||
cert_key: tls_test_key_path
|
||||
validate: false
|
||||
}) or {
|
||||
assert false, 'listener: ${err}'
|
||||
return
|
||||
}
|
||||
mut srv := &TlsKaSrv{}
|
||||
th := spawn tls_ka_srv_loop(mut srv, mut listener)
|
||||
// 1. Forced-HTTP/1.1 request: dialled without ALPN, pooled under its key.
|
||||
r1 := fetch(url: 'https://127.0.0.1:${port}/h1', validate: false, enable_http2: false) or {
|
||||
assert false, 'fetch 1: ${err}'
|
||||
return
|
||||
}
|
||||
assert r1.status_code == 200
|
||||
// 2. Default (HTTP/2-enabled) request: must NOT reuse the no-ALPN
|
||||
// connection; it dials fresh and advertises h2.
|
||||
r2 := fetch(url: 'https://127.0.0.1:${port}/h2pref', validate: false) or {
|
||||
assert false, 'fetch 2: ${err}'
|
||||
return
|
||||
}
|
||||
assert r2.status_code == 200
|
||||
assert srv.accept_count() == 2
|
||||
// 3. Another forced-HTTP/1.1 request: reuses connection 1.
|
||||
r3 := fetch(url: 'https://127.0.0.1:${port}/h1again', validate: false, enable_http2: false) or {
|
||||
assert false, 'fetch 3: ${err}'
|
||||
return
|
||||
}
|
||||
assert r3.status_code == 200
|
||||
assert srv.accept_count() == 2
|
||||
close_idle_connections()
|
||||
listener.shutdown() or {}
|
||||
th.wait()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
import net
|
||||
|
||||
const vschannel_connect_failed_msg = 'Failed to connect to host'
|
||||
|
||||
const vschannel_sec_e_internal_error = -2146893052
|
||||
|
||||
// A fast version that avoids allocations in s.split()
|
||||
// returns the locations of the 2 spaces
|
||||
// "GET / HTTP/1.1" => ["GET" "/" "HTTP/1.1"]
|
||||
fn fast_request_words(line string) (int, int) {
|
||||
space1 := line.index(' ') or { return 0, 0 }
|
||||
space2 := line.index_after(' ', space1 + 1) or { return 0, 0 }
|
||||
return space1, space2
|
||||
}
|
||||
|
||||
fn vschannel_error_message(err_code int) string {
|
||||
$if windows {
|
||||
if err_code >= int(net.WsaError.wsaeintr)
|
||||
&& err_code <= int(net.WsaError.wsa_ipsec_name_policy_error) {
|
||||
return '(${err_code}) ${net.wsa_error(err_code)}'
|
||||
}
|
||||
}
|
||||
if err_code < 0 {
|
||||
return '0x${u32(err_code):08x}'
|
||||
}
|
||||
return '${err_code}'
|
||||
}
|
||||
|
||||
fn vschannel_connect_error(err_code int) IError {
|
||||
if err_code == 0 {
|
||||
return error(vschannel_connect_failed_msg)
|
||||
}
|
||||
return error_with_code(vschannel_connect_failed_msg, err_code)
|
||||
}
|
||||
|
||||
fn vschannel_should_report_connect_failure(err_code int) bool {
|
||||
if err_code == vschannel_sec_e_internal_error {
|
||||
return true
|
||||
}
|
||||
$if windows {
|
||||
return err_code in [int(net.WsaError.wsaenotconn), int(net.WsaError.wsaenetdown),
|
||||
int(net.WsaError.wsaenetunreach), int(net.WsaError.wsaetimedout),
|
||||
int(net.WsaError.wsaeconnrefused), int(net.WsaError.wsaehostdown),
|
||||
int(net.WsaError.wsaehostunreach), int(net.WsaError.wsahost_not_found),
|
||||
int(net.WsaError.wsatry_again), int(net.WsaError.wsano_recovery),
|
||||
int(net.WsaError.wsano_data)]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fn vschannel_looks_like_connect_failure_response(response_text string) bool {
|
||||
trimmed := response_text.trim_space()
|
||||
return trimmed.starts_with('Error ') && (trimmed.contains('sending data to server')
|
||||
|| trimmed.contains('Error performing handshake'))
|
||||
}
|
||||
|
||||
fn vschannel_request_error(err_code int) IError {
|
||||
if vschannel_should_report_connect_failure(err_code) {
|
||||
return vschannel_connect_error(err_code)
|
||||
}
|
||||
return error_with_code('http: vschannel request failed: ${vschannel_error_message(err_code)}',
|
||||
err_code)
|
||||
}
|
||||
|
||||
fn vschannel_parse_response(response_text string, err_code int) !Response {
|
||||
if response_text.len < 5 || response_text[..5].to_lower() != 'http/' {
|
||||
if vschannel_should_report_connect_failure(err_code)
|
||||
|| vschannel_looks_like_connect_failure_response(response_text) {
|
||||
return vschannel_connect_error(err_code)
|
||||
}
|
||||
}
|
||||
return parse_response(response_text)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// The versions listed here are the most common ones.
|
||||
pub enum Version {
|
||||
unknown
|
||||
v1_1
|
||||
v2_0
|
||||
v1_0
|
||||
}
|
||||
|
||||
pub fn (v Version) str() string {
|
||||
return match v {
|
||||
.v1_1 { 'HTTP/1.1' }
|
||||
.v2_0 { 'HTTP/2.0' }
|
||||
.v1_0 { 'HTTP/1.0' }
|
||||
.unknown { 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn version_from_str(v string) Version {
|
||||
// println('VERSION FROM STR v="${v.to_lower()}"')
|
||||
return match v.to_lower() {
|
||||
'http/1.1' { Version.v1_1 }
|
||||
'http/2.0' { Version.v2_0 }
|
||||
'http/1.0' { Version.v1_0 }
|
||||
else { Version.unknown }
|
||||
}
|
||||
}
|
||||
|
||||
// protos returns the version major and minor numbers
|
||||
pub fn (v Version) protos() (int, int) {
|
||||
match v {
|
||||
.v1_1 { return 1, 1 }
|
||||
.v2_0 { return 2, 0 }
|
||||
.v1_0 { return 1, 0 }
|
||||
.unknown { return 0, 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// ALPN (RFC 7301) support for the Windows SChannel backend. The handshake-level
|
||||
// plumbing lives in thirdparty/vschannel/vschannel.c; this exposes it to V.
|
||||
//
|
||||
// This adds the *capability* to advertise ALPN and read the negotiated protocol
|
||||
// on SChannel. The one-shot vschannel request() path still speaks HTTP/1.1, so
|
||||
// it is not wired into fetch() yet (advertising `h2` without an HTTP/2 driver
|
||||
// would let a server pick a protocol we cannot speak). Negotiation is exercised
|
||||
// via schannel_alpn_probe(). Wiring the request path to HTTP/2 is the follow-up.
|
||||
// See vlang/v#27383.
|
||||
|
||||
fn C.vschannel_set_alpn(tls_ctx &C.TlsContext, wire &char, len int)
|
||||
fn C.vschannel_get_alpn(tls_ctx &C.TlsContext, out &char, out_cap int) int
|
||||
fn C.vschannel_alpn_probe(tls_ctx &C.TlsContext, iport int, host &u16, out &char, out_cap int) int
|
||||
|
||||
// alpn_wire encodes ALPN protocol names into the wire format SChannel expects:
|
||||
// each name preceded by a single length byte, e.g. `['h2', 'http/1.1']` becomes
|
||||
// "\x02h2\x08http/1.1". Empty names, or names longer than 255 bytes, are skipped.
|
||||
fn alpn_wire(protocols []string) []u8 {
|
||||
mut out := []u8{}
|
||||
for p in protocols {
|
||||
if p.len == 0 || p.len > 255 {
|
||||
continue
|
||||
}
|
||||
out << u8(p.len)
|
||||
out << p.bytes()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// schannel_alpn_probe performs a TLS handshake to `host`:`port` advertising
|
||||
// `protocols` via ALPN and returns the protocol the server selected (e.g. 'h2'),
|
||||
// or '' if none was negotiated. It sends no application data, so it works as a
|
||||
// pure ALPN-negotiation check independent of the HTTP/1.1 request path.
|
||||
// Windows/SChannel only. Pass `validate` = false to skip certificate validation
|
||||
// (e.g. against a local test server with a self-signed cert).
|
||||
fn schannel_alpn_probe(host string, port int, protocols []string, validate bool) string {
|
||||
mut ctx := C.new_tls_context()
|
||||
C.vschannel_use_tls12_client_protocol()
|
||||
C.vschannel_init(&ctx, C.BOOL(if validate { 1 } else { 0 }))
|
||||
wire := alpn_wire(protocols)
|
||||
if wire.len > 0 {
|
||||
C.vschannel_set_alpn(&ctx, &char(wire.data), wire.len)
|
||||
}
|
||||
mut buf := []u8{len: 256}
|
||||
n := C.vschannel_alpn_probe(&ctx, port, host.to_wide(), &char(buf.data), buf.len)
|
||||
C.vschannel_cleanup(&ctx)
|
||||
if n <= 0 {
|
||||
return ''
|
||||
}
|
||||
return buf[..n].bytestr()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
module http
|
||||
|
||||
// ALPN negotiation on the Windows SChannel backend (vlang/v#27383).
|
||||
//
|
||||
// The wire-encoding test runs anywhere on Windows. The negotiation tests are
|
||||
// network-dependent: run with `-d network`, e.g.
|
||||
// v -d network test vlib/net/http/vschannel_alpn_windows_test.v
|
||||
|
||||
fn test_alpn_wire_encoding() {
|
||||
mut want := [u8(0x02)]
|
||||
want << 'h2'.bytes()
|
||||
want << 0x08
|
||||
want << 'http/1.1'.bytes()
|
||||
assert alpn_wire(['h2', 'http/1.1']) == want
|
||||
// Empty and over-long (>255) names are skipped.
|
||||
assert alpn_wire([]string{}) == []u8{}
|
||||
assert alpn_wire(['', 'h2']) == [u8(0x02), 0x68, 0x32] // 0x68='h', 0x32='2'
|
||||
}
|
||||
|
||||
fn test_schannel_alpn_negotiates_h2() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
// A public HTTP/2 server must select `h2` when offered.
|
||||
selected := schannel_alpn_probe('www.google.com', 443, ['h2', 'http/1.1'], false)
|
||||
assert selected == 'h2', 'expected h2, got "${selected}"'
|
||||
}
|
||||
|
||||
fn test_schannel_alpn_falls_back_to_http1() {
|
||||
$if !network ? {
|
||||
return
|
||||
}
|
||||
// Offer only HTTP/1.1: the server must not select h2.
|
||||
selected := schannel_alpn_probe('www.google.com', 443, ['http/1.1'], false)
|
||||
assert selected == 'http/1.1', 'expected http/1.1, got "${selected}"'
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module http
|
||||
|
||||
// HTTP/2 over the Windows SChannel backend. This wires the streaming transport
|
||||
// primitives in thirdparty/vschannel/vschannel.c into the backend-agnostic
|
||||
// HTTP/2 client (h2_conn.v / h2_client.v), mirroring what net_ssl_do does with
|
||||
// net.ssl on the other platforms. See vlang/v#27383.
|
||||
|
||||
fn C.vschannel_h2_connect(tls_ctx &C.TlsContext, iport int, host &u16) int
|
||||
fn C.vschannel_write(tls_ctx &C.TlsContext, buf &char, len int) int
|
||||
fn C.vschannel_read(tls_ctx &C.TlsContext, buf &char, cap int) int
|
||||
fn C.vschannel_h2_close(tls_ctx &C.TlsContext)
|
||||
|
||||
// VSchannelH2Transport adapts the SChannel streaming C API (vschannel_read /
|
||||
// vschannel_write over an open TLS connection) to the H2Transport interface
|
||||
// that H2Conn drives. It borrows a &C.TlsContext owned by the caller, which
|
||||
// must outlive the transport.
|
||||
struct VSchannelH2Transport {
|
||||
mut:
|
||||
ctx &C.TlsContext = unsafe { nil }
|
||||
}
|
||||
|
||||
// read fills `buf` with up to buf.len decrypted application bytes, returning the
|
||||
// number read (0 at end of stream). H2Conn treats a 0/closed read as the
|
||||
// connection closing, matching net.ssl semantics.
|
||||
fn (mut t VSchannelH2Transport) read(mut buf []u8) !int {
|
||||
if buf.len == 0 {
|
||||
return 0
|
||||
}
|
||||
n := C.vschannel_read(t.ctx, &char(buf.data), buf.len)
|
||||
if n < 0 {
|
||||
return error('http: vschannel_read failed')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// write encrypts and sends all of `buf`, returning the number of bytes consumed.
|
||||
fn (mut t VSchannelH2Transport) write(buf []u8) !int {
|
||||
if buf.len == 0 {
|
||||
return 0
|
||||
}
|
||||
n := C.vschannel_write(t.ctx, &char(buf.data), buf.len)
|
||||
if n < 0 {
|
||||
return error('http: vschannel_write failed')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// vschannel_h2_do opens an SChannel TLS connection advertising ALPN
|
||||
// `h2`/`http/1.1`. If the server selects `h2`, it runs the request over HTTP/2.
|
||||
// Otherwise it speaks HTTP/1.1 over the *same* open connection (so a server
|
||||
// that does not do HTTP/2 — or ALPN at all — costs no extra handshake, and
|
||||
// single-connection servers are not broken by a probe-then-reconnect). Any
|
||||
// error once HTTP/2 is in use propagates as-is.
|
||||
fn vschannel_h2_do(req &Request, port int, method Method, host_name string, path string, data string, header Header) !Response {
|
||||
mut ctx := C.new_tls_context()
|
||||
C.vschannel_use_tls12_client_protocol()
|
||||
C.vschannel_init(&ctx, C.BOOL(if req.validate { 1 } else { 0 }))
|
||||
wire := alpn_wire(['h2', 'http/1.1'])
|
||||
C.vschannel_set_alpn(&ctx, &char(wire.data), wire.len)
|
||||
|
||||
if C.vschannel_h2_connect(&ctx, port, host_name.to_wide()) != 0 {
|
||||
err_code := C.vschannel_last_error(&ctx)
|
||||
C.vschannel_cleanup(&ctx)
|
||||
if err_code != 0 {
|
||||
return vschannel_request_error(err_code)
|
||||
}
|
||||
return error('http: vschannel connect failed')
|
||||
}
|
||||
|
||||
mut abuf := []u8{len: 16}
|
||||
an := C.vschannel_get_alpn(&ctx, &char(abuf.data), abuf.len)
|
||||
if an > 0 && abuf[..an].bytestr() == 'h2' {
|
||||
defer {
|
||||
C.vschannel_h2_close(&ctx)
|
||||
}
|
||||
mut transport := &VSchannelH2Transport{
|
||||
ctx: &ctx
|
||||
}
|
||||
mut conn := new_h2_conn(transport)
|
||||
return req.h2_exchange(mut conn, method, host_name, port, path, data, header)!
|
||||
}
|
||||
|
||||
// Server chose HTTP/1.1 (or no ALPN): reuse the open connection for h1.
|
||||
return req.vschannel_h1_on_open(&ctx, method, host_name, port, path, data, header)!
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
module http
|
||||
|
||||
import net
|
||||
import net.mbedtls
|
||||
|
||||
const vschannel_test_cert_path = @VEXEROOT +
|
||||
'/vlib/net/websocket/tests/autobahn/fuzzing_server_wss/config/server.crt'
|
||||
const vschannel_test_key_path = @VEXEROOT +
|
||||
'/vlib/net/websocket/tests/autobahn/fuzzing_server_wss/config/server.key'
|
||||
|
||||
fn start_vschannel_test_https_server() !(int, thread) {
|
||||
mut port_listener := net.listen_tcp(.ip, '127.0.0.1:0')!
|
||||
port := port_listener.addr()!.port()!
|
||||
port_listener.close()!
|
||||
mut listener := mbedtls.new_ssl_listener('127.0.0.1:${port}', mbedtls.SSLConnectConfig{
|
||||
cert: vschannel_test_cert_path
|
||||
cert_key: vschannel_test_key_path
|
||||
validate: false
|
||||
})!
|
||||
return port, spawn serve_vschannel_test_https_once(mut listener)
|
||||
}
|
||||
|
||||
fn serve_vschannel_test_https_once(mut listener mbedtls.SSLListener) {
|
||||
defer {
|
||||
listener.shutdown() or {}
|
||||
}
|
||||
mut conn := listener.accept() or { return }
|
||||
defer {
|
||||
conn.shutdown() or {}
|
||||
}
|
||||
mut request_buf := []u8{len: 2048}
|
||||
_ = conn.read(mut request_buf) or { return }
|
||||
conn.write_string('HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok') or {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fn test_vschannel_accepts_self_signed_certificate_when_validation_is_disabled() {
|
||||
port, server := start_vschannel_test_https_server()!
|
||||
resp := fetch(
|
||||
url: 'https://127.0.0.1:${port}/'
|
||||
validate: false
|
||||
)!
|
||||
server.wait()
|
||||
assert resp.status_code == 200
|
||||
assert resp.body == 'ok'
|
||||
}
|
||||
|
||||
fn test_vschannel_rejects_self_signed_certificate_when_validation_is_enabled() {
|
||||
port, server := start_vschannel_test_https_server()!
|
||||
fetch(
|
||||
url: 'https://127.0.0.1:${port}/'
|
||||
validate: true
|
||||
) or {
|
||||
server.wait()
|
||||
return
|
||||
}
|
||||
server.wait()
|
||||
assert false, 'expected certificate validation to reject the self-signed certificate'
|
||||
}
|
||||
Reference in new issue
Block a user