/
nv-lang
/
nova-http
Обзор
Документация
Войти
/
nv-lang
/
nova-http
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
src/client/wire.nv
404 строки
14 KB
Evgeniy Golovin
style(225.1): вертикальный ритм — sweep классов a/b/e (src)
26 июл 2026, 02:21
26 июл 2026, 02:21
d0e6b36
Код
Авторство
О чём код?
// SPDX-License-Identifier: MIT OR Apache-2.0 // nova-http: wire.nv — HTTP/1.1 wire codec (pure Nova, D357/D360). Extracted // from monorepo std/http (Plan 203 Ф.1). // // Serialize a request (request-line + headers + CRLF + body) and parse a response // (status-line + headers + framed body). Body framing is `Content-Length` // (identity) OR `Transfer-Encoding: chunked` (decoded here) — both landed. Double // `Content-Length` / `CL`+`TE` (request-smuggling, RFC 9112 §6.1) is rejected by // `HeaderMap.@content_length` (Ф.1). Header-name/value validation (CR/LF/NUL, // tchar) is enforced by `HeaderMap`/`HeaderValue` (anti-injection, Ф.1). // // This is the SAME codec exercised by both `real_http()` (over a live socket) and // `mock_http()` (over programmed bytes) — so chunked-decode and malformed-response // detection are deterministically covered by mock tests (no sockets). All parsing // works on `[]u8` (byte-correct incl. non-UTF-8 bodies); the seam carries the // bytes as a byte-string (`str.from_bytes_unchecked`), see effect.nv. module http.client import http.{HeaderMap, StatusCode, Version, HttpError} // Parsed HTTP/1.1 response (module-internal; body fully materialized in memory — // the CORE client buffers the whole body, streaming reader is deferred, see // body.nv [M-178-body-transport-reader]). type ParsedResponse value { ro version Version ro status StatusCode ro headers HeaderMap ro body []u8 } // ── Serialization ──────────────────────────────────────────────────────────── // Serialize an HTTP/1.1 request to raw wire bytes (carried as a byte-string). // `target` is the origin-form request-target (path + optional "?query"). // Adds Host / Content-Length / Connection:close when not already present. fn serialize_request(method str, target str, host_header str, headers HeaderMap, body []u8) -> str { mut buf = WriteBuffer.new() buf.cap(256 + body.len()) buf.write_str(method) buf.write_str(" ") buf.write_str(target) buf.write_str(" HTTP/1.1\r\n") if !headers.contains("host") { buf.write_str("Host: ") buf.write_str(host_header) buf.write_str("\r\n") } for (n, v) in headers { buf.write_str(n.str()) buf.write_str(": ") buf.write_bytes(v.bytes()) buf.write_str("\r\n") } if body.len() > 0 && !headers.contains("content-length") && !headers.contains("transfer-encoding") { buf.write_str("Content-Length: ") buf.write_str(body.len().to_str()) buf.write_str("\r\n") } if !headers.contains("connection") { // CORE: one hop per connection (keep-alive/pool deferred, see client.nv). buf.write_str("Connection: close\r\n") } buf.write_str("\r\n") if body.len() > 0 { buf.write_bytes(body) } // [M-174.1-vec-method-chain-elem-erasure]: `.into_bytes()`'s `[]u8` result loses // its concrete element type for facade-imported method lookup when chained // directly — bind to an explicit `[]u8` local first (see marker in // backlog-followups.md). ro wire_bytes []u8 = buf.into_bytes() unsafe { wire_bytes.to_str_unchecked() } } // Serialize an HTTP/1.1 response to raw wire bytes (used by mock_http to feed the // SAME parser real_http uses). Adds Content-Length + Connection:close if absent. fn serialize_response(status StatusCode, headers HeaderMap, body []u8) -> str { mut buf = WriteBuffer.new() buf.cap(128 + body.len()) buf.write_str("HTTP/1.1 ") buf.write_str(status.code().to_str()) buf.write_str(" ") buf.write_str(status.reason()) buf.write_str("\r\n") for (n, v) in headers { buf.write_str(n.str()) buf.write_str(": ") buf.write_bytes(v.bytes()) buf.write_str("\r\n") } if !headers.contains("content-length") && !headers.contains("transfer-encoding") { buf.write_str("Content-Length: ") buf.write_str(body.len().to_str()) buf.write_str("\r\n") } if !headers.contains("connection") { buf.write_str("Connection: close\r\n") } buf.write_str("\r\n") if body.len() > 0 { buf.write_bytes(body) } // [M-174.1-vec-method-chain-elem-erasure] (see marker above / backlog). ro wire_bytes []u8 = buf.into_bytes() unsafe { wire_bytes.to_str_unchecked() } } // Parse the request-line of a serialized request → (method, path without query). // Used by mock_http to route. Malformed → None. fn parse_request_line(request str) -> Option[(str, str)] { ro b = request.bytes() ro eol = match find_crlf(b, 0) { Some(x) => x, None => return None } ro sp1 = match index_of(b, 0x20, 0) { Some(x) => x, None => return None } if sp1 >= eol { return None } // [M-174.1-vec-method-chain-elem-erasure]: bind the slice-view to an // explicit `[]u8` local before `.to_str_unchecked()` — chained directly // on an expression result, the facade-imported method lookup falls back to // an unspecialized `Vec` (backlog-followups.md marker). ro method_bytes []u8 = b[0..sp1] ro method = unsafe { method_bytes.to_str_unchecked() } ro sp2 = match index_of(b, 0x20, sp1 + 1) { Some(x) => x, None => return None } if sp2 > eol { return None } ro target_bytes []u8 = b[sp1 + 1..sp2] ro target = unsafe { target_bytes.to_str_unchecked() } ro path = match target.find("?") { Some(q) => target[0..q] None => target } Some((method, path)) } // Look up the first value of a request header by name (case-insensitive) in a // serialized request. Used by mock_http echo-routes to verify what the client // actually sent (e.g. that Authorization was stripped cross-origin). None if // absent. fn find_request_header(request str, name str) -> Option[str] { ro b = request.bytes() ro key = name.to_ascii_lower() ro head_end = match find_double_crlf(b, 0) { Some(x) => x, None => b.len() } ro first_eol = match find_crlf(b, 0) { Some(x) => x, None => return None } mut i = first_eol + 2 mut running = true while running && i < head_end { ro eol = match find_crlf(b, i) { Some(x) => x, None => head_end } if eol <= i { running = false } else { ro colon = index_of(b[i..eol], 0x3A, 0) match colon { Some(c) => { // [M-174.1-vec-method-chain-elem-erasure]: explicit `[]u8` locals. ro hn_bytes []u8 = b[i..i + c] ro hn = unsafe { hn_bytes.to_str_unchecked() }.to_ascii_lower() if hn == key { ro val []u8 = trim_ows(b[i + c + 1..eol]) return Some(unsafe { val.to_str_unchecked() }) } } None => () } i = eol + 2 } } None } // ── Parsing ────────────────────────────────────────────────────────────────── // Parse a full raw HTTP/1.1 response. Malformed status-line/headers/framing → // `Err(Protocol(..))`. fn parse_response(raw str) -> Result[ParsedResponse, HttpError] { ro b = raw.bytes() ro n = b.len() // 1. Locate end of header block (CRLF CRLF). ro head_end = match find_double_crlf(b, 0) { Some(x) => x None => return Err(HttpError.protocol_error("response: no header terminator")) } ro body_start = head_end + 4 // 2. Split header block into lines. ro first_eol = match find_crlf(b, 0) { Some(x) => x None => return Err(HttpError.protocol_error("response: empty status line")) } // 3. Status line: "HTTP/1.1 200 Reason". ro (version, status) = parse_status_line(b[0..first_eol])? // 4. Header lines (until head_end). mut hm = HeaderMap.new() mut i = first_eol + 2 mut running = true while running && i < head_end { ro eol = match find_crlf(b, i) { Some(x) => x None => head_end } if eol <= i { running = false } else { parse_header_line(hm, b[i..eol])? i = eol + 2 } } // 5. Body framing: chunked wins; else Content-Length; else read-to-end. ro te = hm.transfer_encoding() ro body = if contains_str(te, "chunked") { decode_chunked(b, body_start)? } else { match hm.content_length()? { Some(len) => { ro avail = n - body_start if len > avail { return Err(HttpError.protocol_error("response: body shorter than Content-Length")) } b[body_start..body_start + len] } None => b[body_start..n] } } Ok({ version, status, headers: hm, body }) } fn parse_status_line(line []u8) -> Result[(Version, StatusCode), HttpError] { ro sp1 = match index_of(line, 0x20, 0) { Some(x) => x None => return Err(HttpError.protocol_error("status line: no space after version")) } // [M-174.1-vec-method-chain-elem-erasure]: explicit `[]u8` local. ro ver_bytes []u8 = line[0..sp1] ro version = parse_version(unsafe { ver_bytes.to_str_unchecked() })? ro code_start = sp1 + 1 ro sp2 = match index_of(line, 0x20, code_start) { Some(x) => x None => line.len() // status may have no reason phrase } ro code_bytes = line[code_start..sp2] if code_bytes.len() != 3 { return Err(HttpError.protocol_error("status line: status code not 3 digits")) } mut code = 0 for cb in code_bytes { ro d = cb as int if d < 0x30 || d > 0x39 { return Err(HttpError.protocol_error("status line: non-digit status code")) } code = code * 10 + (d - 0x30) } ro status = StatusCode.new(code)? Ok((version, status)) } fn parse_version(tok str) -> Result[Version, HttpError] { match tok { "HTTP/1.0" => Ok(Http10) "HTTP/1.1" => Ok(Http11) "HTTP/2" => Ok(Http2) "HTTP/2.0" => Ok(Http2) _ => Err(HttpError.protocol_error("status line: unsupported HTTP version")) } } fn parse_header_line(hm mut HeaderMap, line []u8) -> Result[(), HttpError] { ro colon = match index_of(line, 0x3A, 0) { // ':' Some(x) => x None => return Err(HttpError.protocol_error("header line: no colon")) } // [M-174.1-vec-method-chain-elem-erasure]: explicit `[]u8` local. ro name_bytes []u8 = line[0..colon] ro name = unsafe { name_bytes.to_str_unchecked() } ro value = trim_ows(line[colon + 1..line.len()]) hm.append_bytes(name, value)? Ok(()) } // ── chunked transfer decoding (RFC 9112 §7.1) ─────────────────────────────── fn decode_chunked(b []u8, start int) -> Result[[]u8, HttpError] { mut out = WriteBuffer.new() out.cap(256) ro n = b.len() mut i = start mut running = true while running { ro eol = match find_crlf(b, i) { Some(x) => x None => return Err(HttpError.protocol_error("chunk: missing size CRLF")) } ro size = match parse_chunk_size(b[i..eol]) { Some(sz) => sz None => return Err(HttpError.protocol_error("chunk: invalid size")) } ro data_start = eol + 2 if size == 0 { running = false // last-chunk; trailers (if any) ignored (CORE) } else { ro data_end = data_start + size if data_end + 2 > n { return Err(HttpError.protocol_error("chunk: premature EOF")) } if !((b[data_end] as int) == 0x0D && (b[data_end + 1] as int) == 0x0A) { return Err(HttpError.protocol_error("chunk: missing data CRLF")) } out.write_bytes(b[data_start..data_end]) i = data_end + 2 } } Ok(out.into_bytes()) } // Parse a chunk-size line (hex, up to optional ";chunk-ext"). Empty/no-hex → None. fn parse_chunk_size(line []u8) -> Option[int] { mut val = 0 mut seen = false ro n = line.len() mut i = 0 mut running = true while running && i < n { ro c = line[i] as int if c == 0x3B || c == 0x20 || c == 0x09 { // ';' / space / tab ends size running = false } else { ro d = hex_digit(c) if d < 0 { return None } val = val * 16 + d seen = true i += 1 } } if seen { Some(val) } else { None } } fn hex_digit(c int) -> int { if c >= 0x30 && c <= 0x39 { c - 0x30 } else if c >= 0x61 && c <= 0x66 { c - 0x61 + 10 } else if c >= 0x41 && c <= 0x46 { c - 0x41 + 10 } else { -1 } } // ── byte helpers ───────────────────────────────────────────────────────────── fn index_of(b []u8, target int, from int) -> Option[int] { ro n = b.len() mut i = from while i < n { if (b[i] as int) == target { return Some(i) } i += 1 } None } fn find_crlf(b []u8, from int) -> Option[int] { ro n = b.len() mut i = from while i + 1 < n { if (b[i] as int) == 0x0D && (b[i + 1] as int) == 0x0A { return Some(i) } i += 1 } None } fn find_double_crlf(b []u8, from int) -> Option[int] { ro n = b.len() mut i = from while i + 3 < n { if (b[i] as int) == 0x0D && (b[i + 1] as int) == 0x0A && (b[i + 2] as int) == 0x0D && (b[i + 3] as int) == 0x0A { return Some(i) } i += 1 } None } fn trim_ows(b []u8) -> []u8 { ro n = b.len() mut lo = 0 while lo < n && is_ows(b[lo] as int) { lo += 1 } mut hi = n while hi > lo && is_ows(b[hi - 1] as int) { hi -= 1 } b[lo..hi] } fn is_ows(c int) -> bool => c == 0x20 || c == 0x09 fn contains_str(items []str, needle str) -> bool { for s in items { if s == needle { return true } } false }