Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

+165
View File
@@ -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')
}
+76
View File
@@ -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.'
}
+394
View File
@@ -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
}
+267
View File
@@ -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
}
}
+36
View File
@@ -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)!
}
+4
View File
@@ -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
+87
View File
@@ -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>
+28
View File
@@ -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
}
}
+118
View File
@@ -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('&nbsp;/&nbsp;')
sb.writeln('<h2>Index of <a href="/">/</a>&nbsp;${crlinks_text}&nbsp;:</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>')
}
+123
View File
@@ -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
}
+92
View File
@@ -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
}
}
+107
View File
@@ -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')
}
+499
View File
@@ -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()})'
}
+554
View File
@@ -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'
}
+43
View File
@@ -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' }
}
}
+524
View File
@@ -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)
}
}
}
+306
View File
@@ -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
}
+329
View File
@@ -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)
}
}
+95
View File
@@ -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,
]!
+73
View File
@@ -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
+335
View File
@@ -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'
}
+564
View File
@@ -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
}
}
+305
View File
@@ -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 == ''
}
+827
View File
@@ -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
}
+392
View File
@@ -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'
}
+284
View File
@@ -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()
}
+96
View File
@@ -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'
}
}
+224
View File
@@ -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')
}
}
+395
View File
@@ -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()
}
+203
View File
@@ -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}"')
}
+145
View File
@@ -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
}
}
+38
View File
@@ -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
+39
View File
@@ -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
}
+29
View File
@@ -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')
}
+420
View File
@@ -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
}
+235
View File
@@ -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')
}
+147
View File
@@ -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
}
+333
View File
@@ -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
}
+713
View File
@@ -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')
}
+52
View File
@@ -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
}
}
}
+541
View File
@@ -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
}
+258
View File
@@ -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
}
+49
View File
@@ -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
}
+403
View File
@@ -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
}
+543
View File
@@ -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()
}
+78
View File
@@ -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)
}
+41
View File
@@ -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'
}