From 57badade8f66da0ba48442bccf5773ffae8bbd2c Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Fri, 5 Jun 2026 22:11:06 -0400 Subject: [PATCH] net.http: add HTTP/2 frame codec (RFC 7540) (#27356) * net.http: add HTTP/2 frame codec (RFC 7540) for HTTP/2 Add the HTTP/2 binary framing layer: encoders and decoders for all ten frame types plus a fallthrough for unknown types. Like the HPACK PR, this is purely additive (new files in the http module) and does not touch any existing net.http code path; nothing calls it yet. Included: - H2FrameHeader parsing of the 9-byte header, ignoring the reserved bit. - A H2Frame sum type with DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION, and an UnknownFrame variant. Unknown frame types are preserved rather than rejected, as required by RFC 7540 Section 4.1. - h2_read_frame / h2_parse_frame decoders and an .encode() method, with PADDED and PRIORITY handling for HEADERS/DATA/PUSH_PROMISE. - Structural validation that maps to FRAME_SIZE_ERROR / PROTOCOL_ERROR at the connection layer: fixed-size frames (PRIORITY, RST_STREAM, PING, WINDOW_UPDATE), SETTINGS payload as a multiple of 6 with empty ACK, and the stream-id rules (DATA/HEADERS/etc. non-zero; SETTINGS/PING/GOAWAY zero). - H2ErrorCode enum (Section 7) and the SETTINGS identifier and frame type/flag constants. Tests cover an encode/decode round-trip for every frame type, the on-wire header layout, reserved-bit masking, padding decode (including pad length exceeding the frame), the priority section on HEADERS, unknown-frame preservation and re-encoding, reading consecutive frames from one buffer, and the structural validation errors above. Passes under -W -cstrict -cc clang. Co-Authored-By: Claude Opus 4.8 * net.http: document deferred HTTP/2 semantic checks in frame codec Add comments noting that PRIORITY self-dependency (RFC 7540 Section 5.3.1) and zero WINDOW_UPDATE increments (Section 6.9) are intentionally not rejected by the frame codec. Both are stream errors whose correct handling is RST_STREAM on the affected stream (and, for a zero increment on stream 0, a connection error). That stream-vs-connection distinction belongs to the connection layer, which needs the decoded frame to respond; rejecting in the codec would turn a recoverable stream error into a connection-fatal decode error. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Richard Wheeler Co-authored-by: Claude Opus 4.8 --- vlib/net/http/h2_error.v | 43 +++ vlib/net/http/h2_frame.v | 520 ++++++++++++++++++++++++++++++++++ vlib/net/http/h2_frame_test.v | 306 ++++++++++++++++++++ 3 files changed, 869 insertions(+) create mode 100644 vlib/net/http/h2_error.v create mode 100644 vlib/net/http/h2_frame.v create mode 100644 vlib/net/http/h2_frame_test.v diff --git a/vlib/net/http/h2_error.v b/vlib/net/http/h2_error.v new file mode 100644 index 000000000..43ef78227 --- /dev/null +++ b/vlib/net/http/h2_error.v @@ -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' } + } +} diff --git a/vlib/net/http/h2_frame.v b/vlib/net/http/h2_frame.v new file mode 100644 index 000000000..95cee602e --- /dev/null +++ b/vlib/net/http/h2_frame.v @@ -0,0 +1,520 @@ +// 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) + +// 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) + } + } +} diff --git a/vlib/net/http/h2_frame_test.v b/vlib/net/http/h2_frame_test.v new file mode 100644 index 000000000..a37a6fe6f --- /dev/null +++ b/vlib/net/http/h2_frame_test.v @@ -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 +} -- 2.39.5