/
nv-lang
/
nova-http
Обзор
Документация
Войти
/
nv-lang
/
nova-http
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
src/header.nv
285 строк
11 KB
Evgeniy Golovin
fix(конвенций-аудит владельца 2026-07-25, §1а ось владения): mutable-heap to_* больше не алиасят источник
25 июл 2026, 01:33
25 июл 2026, 01:33
3adb6c5
Код
Авторство
О чём код?
// SPDX-License-Identifier: MIT OR Apache-2.0 // nova-http: header.nv — HeaderName / HeaderValue / HeaderMap (D358). // Extracted from monorepo std/http (Plan 203 Ф.1). // // Заголовки — case-insensitive по имени, ordered, multi-value (RFC 7230 §3.2). // Имя = ASCII tchar, нормализовано lowercase; значение = байты (visible ASCII + // obs-text), `str`-API — latin1 fast-path, `@to_str()` fallible на non-ASCII (Q18). // // БЕЗОПАСНОСТЬ (differentiator): `insert`/`append`/`@to_header_value` ОТВЕРГАЮТ // CR/LF/NUL в значениях и не-tchar/non-ASCII в именах → request/response-splitting // невозможен by construction (Q14). `@content_length` ловит конфликт // `Transfer-Encoding` + `Content-Length` (request-smuggling, RFC 7230 §3.3.3). module http /// Валидированное имя заголовка (RFC 7230 tchar, ASCII-only). Хранит канон. /// lowercase-форму; сравнение case-insensitive. #stable(since = "0.1") export type HeaderName value { priv lower str } /// Разобрать имя. Пустое / не-tchar / non-ASCII → `InvalidHeader`. #stable(since = "0.1") export fn str @to_header_name() -> Result[HeaderName, HttpError] { if @byte_len() == 0 { return Err(HttpError.invalid_header("empty header name")) } for c in @chars() { if !is_tchar(c) { return Err(HttpError.invalid_header("non-tchar in header name")) } } Ok({ lower: @to_ascii_lower() }) } /// Канон. lowercase-имя. #stable(since = "0.1") export fn HeaderName @str() -> str => @lower /// Значение заголовка — байты (visible ASCII + obs-text); CR/LF/NUL запрещены. #stable(since = "0.1") export type HeaderValue value { priv bytes []u8 } /// Из сырых байт. CR/LF/NUL → `InvalidHeader` (anti-injection). /// /// Имя `into_header_value` (consume, перенос владения байтов — §1а); str-дверь = `to_header_value` — одноимённые расширения /// на РАЗНЫХ конкретных ресиверах (`[]u8`/`str`) компилятор не codegen'ит /// корректно: `method_receivers` single-key last-wins резолвит ЛЮБОЙ вызов /// `.to_header_value()` в ПОСЛЕДНЮЮ объявленную перегрузку /// (`[E_RECV_METHOD_MISMATCH]`, изолированный repro подтвердил на минимальном /// файле вне этого пакета — известное семейство багов, /// docs/plans/wip/196.5-facet-c-map.md и родня). `str @to_header_value()` /// ниже — первичное/чаще используемое имя (2 внутренних call-site vs 1 у /// байтовой формы); `[]u8`-форма несёт различимое имя. /// `[M-samename-extension-method-recv-type-collision]` #stable(since = "0.1") export fn []u8 consume @into_header_value() -> Result[HeaderValue, HttpError] { for byte in @ { if is_forbidden_octet(byte) { return Err(HttpError.invalid_header("CR/LF/NUL in header value")) } } Ok({ bytes: @ }) } /// Из строки (latin1 fast-path: codepoint 0..255 → один байт). CR/LF/NUL или /// codepoint > 0xFF → `InvalidHeader`. /// /// Имя `to_header_value` — первичная форма, см. маркер /// `[M-samename-extension-method-recv-type-collision]` на `[]u8 consume @into_header_value()` выше. #stable(since = "0.1") export fn str @to_header_value() -> Result[HeaderValue, HttpError] { mut buf = WriteBuffer.new() buf.cap(@byte_len()) for c in @chars() { ro cp = c as int if cp > 0xFF { return Err(HttpError.invalid_header("non-latin1 character in header value")) } if cp == 0x0D || cp == 0x0A || cp == 0x00 { return Err(HttpError.invalid_header("CR/LF/NUL in header value")) } buf.write_byte(cp as u8) } Ok({ bytes: buf.into_bytes() }) } /// Сырые байты значения. #stable(since = "0.1") export fn HeaderValue @bytes() -> []u8 => @bytes /// Декодировать в `str`. non-ASCII/obs-text → `InvalidHeader` (fallible; для /// произвольных октетов бери `@bytes()` выше — стейл-имя `@as_bytes` /// поправлено, метода с таким именем в пакете никогда не было). #stable(since = "0.1") export fn HeaderValue @to_str() -> Result[str, HttpError] { for byte in @bytes { if (byte as int) > 0x7F { return Err(HttpError.invalid_header("non-ASCII header value; use bytes()")) } } Ok(unsafe { @bytes.to_str_unchecked() }) } // Пара имя→значение. Порядок вставки сохранён. type HeaderEntry value { ro name HeaderName, ro value HeaderValue } /// Упорядоченная мультимапа заголовков, case-insensitive по имени. #stable(since = "0.1") export type HeaderMap value { priv entries []HeaderEntry } /// Пустая карта. #stable(since = "0.1") export fn HeaderMap.new() -> HeaderMap => { entries: []HeaderEntry.new() } /// ПЕРВОЕ значение (case-insensitive) или `None`. #stable(since = "0.1") export fn HeaderMap @get(name str) -> Option[HeaderValue] { ro key = name.to_ascii_lower() for e in @entries { if e.name.str() == key { return Some(e.value) } } None } /// ВСЕ значения имени, в порядке вставки. #stable(since = "0.1") export fn HeaderMap @get_all(name str) -> []HeaderValue { ro key = name.to_ascii_lower() mut out = []HeaderValue.new() for e in @entries { if e.name.str() == key { out.push(e.value) } } out } /// Есть ли заголовок с этим именем. #stable(since = "0.1") export fn HeaderMap @contains(name str) -> bool { ro key = name.to_ascii_lower() for e in @entries { if e.name.str() == key { return true } } false } /// ЗАМЕНИТЬ все прежние значения имени одним новым (set-семантика, D358 §13.1). #stable(since = "0.1") export fn HeaderMap mut @insert(name str, value str) -> Result[(), HttpError] { ro hn = name.to_header_name()? ro hv = value.to_header_value()? @entries = without_name(@entries, hn.str()) @entries.push(HeaderEntry { name: hn, value: hv }) Ok(()) } /// ДОБАВИТЬ ещё одно значение (multi-value append, D358 §13.1). #stable(since = "0.1") export fn HeaderMap mut @append(name str, value str) -> Result[(), HttpError] { ro hn = name.to_header_name()? ro hv = value.to_header_value()? @entries.push(HeaderEntry { name: hn, value: hv }) Ok(()) } /// ДОБАВИТЬ значение из СЫРЫХ байт (multi-value append; byte-correct для /// obs-text/latin1 при разборе ответа — Ф.2). CR/LF/NUL / не-tchar → reject. #stable(since = "0.1") export fn HeaderMap mut @append_bytes(name str, value []u8) -> Result[(), HttpError] { ro hn = name.to_header_name()? ro hv = value.into_header_value()? @entries.push(HeaderEntry { name: hn, value: hv }) Ok(()) } /// Удалить все значения имени; вернуть удалённые. #stable(since = "0.1") export fn HeaderMap mut @remove(name str) -> []HeaderValue { ro removed = @get_all(name) ro key = name.to_ascii_lower() @entries = without_name(@entries, key) removed } /// Число записей (мультизначные считаются по отдельности). #stable(since = "0.1") export fn HeaderMap @len() -> int => @entries.len() /// Пары `(HeaderName, HeaderValue)` в порядке вставки. #stable(since = "0.1") export fn HeaderMap @iter() -> [](HeaderName, HeaderValue) { mut out [](HeaderName, HeaderValue) = [] for e in @entries { out.push((e.name, e.value)) } out } fn without_name(entries []HeaderEntry, key str) -> []HeaderEntry { mut out = []HeaderEntry.new() for e in entries { if e.name.str() != key { out.push(e) } } out } // ── Типизированные хелперы общих заголовков ────────────────────────────────── /// `Host`-значение (h1-обязателен). #stable(since = "0.1") export fn HeaderMap @host() -> Option[str] { match @get("host") { Some(hv) => match hv.to_str() { Ok(s) => Some(s), Err(_) => None } None => None } } /// `Transfer-Encoding`-токены, нормализованные lowercase (`["chunked"]`). #stable(since = "0.1") export fn HeaderMap @transfer_encoding() -> []str { mut out = []str.new() for hv in @get_all("transfer-encoding") { ro s = match hv.to_str() { Ok(t) => t, Err(_) => "" } for part in s.split(",") { ro tok = part.trim_ascii().to_ascii_lower() if tok.byte_len() > 0 { out.push(tok) } } } out } /// Разобрать `Content-Length`. Дублирующиеся-конфликтные значения ИЛИ /// одновременный `Transfer-Encoding` → `Protocol` (request-smuggling defense). #stable(since = "0.1") export fn HeaderMap @content_length() -> Result[Option[int], HttpError] { ro cls = @get_all("content-length") if @transfer_encoding().len() > 0 && cls.len() > 0 { return Err(HttpError.protocol_error("both Transfer-Encoding and Content-Length present")) } if cls.len() == 0 { return Ok(None) } mut first = -1 for hv in cls { ro s = match hv.to_str() { Ok(t) => t Err(_) => return Err(HttpError.protocol_error("invalid Content-Length octets")) } for c in s.chars() { if !c.is_ascii_digit() { return Err(HttpError.protocol_error("non-digit in Content-Length")) } } ro n = match s.to_int() { Ok(x) => x Err(_) => return Err(HttpError.protocol_error("malformed Content-Length")) } if first == -1 { first = n } else if first != n { return Err(HttpError.protocol_error("conflicting Content-Length values")) } } Ok(Some(first)) } /// Разобрать `Content-Type` в `ContentType`. #stable(since = "0.1") export fn HeaderMap @content_type() -> Option[ContentType] { match @get("content-type") { Some(hv) => match hv.to_str() { Ok(s) => match s.to_content_type() { Ok(ct) => Some(ct), Err(_) => None } Err(_) => None } None => None } } /// Установить `Content-Type` (заменяет прежний). #stable(since = "0.1") export fn HeaderMap mut @set_content_type(ct ContentType) -> () { ro _ = @insert("content-type", ct.to_header()) () } fn is_forbidden_octet(b u8) -> bool { ro n = b as int n == 0x0D || n == 0x0A || n == 0x00 }