/
nv-lang
/
nova-http
Обзор
Документация
Войти
/
nv-lang
/
nova-http
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
src/url.nv
623 строки
25 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: url.nv — URL parser + СТРОГИЙ host/SSRF-валидатор (RFC 3986, // D357-D358; промоут из монорепо _experimental/encoding/url.nv, Plan 178 // Ф.1). Extracted from monorepo std/http (Plan 203 Ф.1). // // Промоут-дельта (§3.8 / §8.2 acceptance — НЕ «доводка»): // 1. `Url.from(s) Fail[ParseUrlError]` → `s.to_url() -> Result[Url, HttpError]` (174.1) // (D325 R2); `into()` → `@to_str()`. `ParseUrlError` детализирует, маппится в // `HttpError{kind: InvalidUrl, source: Url(ParseUrlError)}` (source-chaining). // 2. Байт-корректный парсер: смещения — БАЙТОВЫЕ (`find`/byte-scan), срезы `s[a..b]` // режутся по ASCII-делимитерам (codepoint-boundary-safe). Прежний код мешал // char-index (`chars().nth`) с байтовым срезом — латентный баг на non-ASCII. // 3. `encode_query` multi-byte-FIX: percent-encode КАЖДОГО UTF-8-байта (прежний // код эмитил ОДИН байт для >127 → порча). `decode_query` — self-contained UTF-8 // валидатор (str.try_from не существует; canonical str.from_bytes+Utf8Error ← // 176 Ф.0.5 → `[M-178-url-decode-canonical-from-bytes]`). // 4. Строгий host/SSRF-валидатор: bracket-IPv6, canonical dotted-quad IPv4, REJECT // control/NUL/whitespace/non-ASCII в host, REJECT decimal/octal/hex IP-обфускации // (`0x7f.1` / `0177.0.0.1` / `2130706433` / `127.1`). `@is_private_target()` — // SSRF-guard hook (loopback/link-local/RFC1918/metadata) для Ф.2 `ssrf_guard`. module http /// URL (RFC 3986 generic syntax). Иммутабельная value-запись. Для IPv6-host /// `host` хранит ВНУТРЕННЮЮ форму без скобок (`"::1"`); `@to_str` re-брекетит. /// /// # Examples /// ```nova /// let u = "https://user:pass@example.com:8443/api?k=v#top".to_url()!! /// assert(u.scheme == "https") /// assert(u.host == Some("example.com")) /// assert(u.port == Some(8443)) /// ``` #stable(since = "0.1") export type Url value { ro scheme str ro user Option[str] ro password Option[str] ro host Option[str] ro port Option[int] ro path str ro query Option[str] ro fragment Option[str] } /// Детальная ошибка разбора URL (source-chain внутри `HttpError`). #stable(since = "0.1") export type ParseUrlError enum | EmptyInput | MissingScheme | InvalidScheme(str) // невалидный scheme-токен | MalformedPort(str) // порт не число / вне 0..65535 | InvalidPercentEncoding(int) // позиция malformed `%XX` | InvalidHost(str) // control/NUL/whitespace/non-ASCII/malformed | ObfuscatedIp(str) // decimal/octal/hex IP-обфускация (SSRF) | UnterminatedIpv6 // '[' без ']' /// Человекочитаемое описание (для `HttpError.to_str` source-chain). #stable(since = "0.1") export fn ParseUrlError @to_str() -> str { match @ { EmptyInput => "empty input" MissingScheme => "missing scheme" InvalidScheme(value) => "invalid scheme '${value}'" MalformedPort(value) => "invalid port '${value}'" InvalidPercentEncoding(position) => "invalid percent-encoding at ${position}" InvalidHost(reason) => "invalid host: ${reason}" ObfuscatedIp(value) => "obfuscated IP literal '${value}' rejected" UnterminatedIpv6 => "unterminated IPv6 literal" } } // ────────────────────────────────────────────────────────────────────────── // Парсинг → Result[Url, HttpError] // ────────────────────────────────────────────────────────────────────────── /// Разобрать URL (RFC 3986). Ошибка → `HttpError{kind: InvalidUrl}` с /// source-chain на детальный `ParseUrlError`. /// /// Plan 174.1 (2026-07-08, канон владельца): конверсия — метод НА ИСТОЧНИКЕ /// (`s.to_url()`, зеркально `s.to_int()`/`s.to_complex()`); статик /// `Url.parse(s)` РЕТРАКТИРОВАН (одно имя = одна дверь, §22). #stable(since = "0.1") export fn str @to_url() -> Result[Url, HttpError] { match parse_url_inner(@) { Ok(u) => Ok(u) Err(e) => Err(HttpError.invalid_url(e)) } } fn parse_url_inner(s str) -> Result[Url, ParseUrlError] { if s.byte_len() == 0 { return Err(EmptyInput) } // 1. scheme: ro (scheme, after_scheme) = parse_scheme(s)? // 2. authority (если "//") ro (user, password, host, port, after_authority) = if after_scheme.starts_with("//") { parse_authority(after_scheme[2..after_scheme.byte_len()])? } else { (none_str(), none_str(), none_str(), none_int(), after_scheme) } // 3. path — до "?" / "#" ro (path, after_path) = parse_path(after_authority) // 4. query — после "?", до "#" ro (query, after_query) = if after_path.starts_with("?") { ro rest = after_path[1..after_path.byte_len()] match rest.find("#") { Some(i) => (Some(rest[0..i]), rest[i..rest.byte_len()]) None => (Some(rest), "") } } else { (none_str(), after_path) } // 5. fragment — после "#" ro fragment = if after_query.starts_with("#") { Some(after_query[1..after_query.byte_len()]) } else { None } Ok({ scheme, user, password, host, port, path, query, fragment }) } // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) (RFC 3986 §3.1). Case-insens // на проводе; нормализуем lowercase. fn parse_scheme(s str) -> Result[(str, str), ParseUrlError] { match s.find(":") { None => Err(MissingScheme) Some(i) => { if i == 0 { return Err(MissingScheme) } ro scheme = s[0..i] // D410: materialise `scheme.chars()` manually — the generic // `Next[T] @collect()` blanket impl (std.collections.vec_iter) hits a // codegen gap for custom `value priv(type)` iterators like CharsIter // ("anonymous record literal: expected struct 'Vec____Nova_T_p' not in // record_schemas") — found during this migration, reported separately; // this manual push-loop is the exact same materialization, bug-free. mut chars []char = []char.new() for c in scheme.chars() { chars.push(c) } if chars.len() == 0 { return Err(MissingScheme) } if !is_alpha(chars[0]) { return Err(InvalidScheme(scheme)) } mut j = 1 while j < chars.len() { if !is_scheme_char(chars[j]) { return Err(InvalidScheme(scheme)) } j += 1 } Ok((scheme.to_ascii_lower(), s[i + 1..s.byte_len()])) } } } // authority = [ userinfo "@" ] host [ ":" port ]; конец на "/", "?", "#" или EOF. fn parse_authority(s str) -> Result[(Option[str], Option[str], Option[str], Option[int], str), ParseUrlError] { ro auth_end = find_first_of(s, "/?#") ro (auth, rest) = match auth_end { Some(i) => (s[0..i], s[i..s.byte_len()]) None => (s, "") } if auth.byte_len() == 0 { // "//" присутствовал, но authority пуст (file:///) → host = Some("") // сохраняет "//" при сериализации (round-trip). return Ok((none_str(), none_str(), Some(""), none_int(), rest)) } // userinfo — до первого "@" ro (userinfo_opt, host_port) = match auth.find("@") { Some(i) => (Some(auth[0..i]), auth[i + 1..auth.byte_len()]) None => (none_str(), auth) } ro (user, password) = match userinfo_opt { None => (none_str(), none_str()) Some(ui) => match ui.find(":") { Some(i) => (Some(ui[0..i]), Some(ui[i + 1..ui.byte_len()])) None => (Some(ui), none_str()) } } // host[:port] — bracket-aware для IPv6 ro (host, port) = parse_host_port(host_port)? validate_host(host)? Ok((user, password, Some(host), port, rest)) } // Разобрать host[:port]. IPv6 — в скобках `[...]`; host возвращается БЕЗ скобок. fn parse_host_port(hp str) -> Result[(str, Option[int]), ParseUrlError] { if hp.starts_with("[") { match hp.find("]") { None => Err(UnterminatedIpv6) Some(close) => { ro inner = hp[1..close] ro after = hp[close + 1..hp.byte_len()] if after.byte_len() == 0 { Ok((inner, None)) } else if after.starts_with(":") { ro p = parse_port(after[1..after.byte_len()])? Ok((inner, Some(p))) } else { Err(InvalidHost("unexpected characters after IPv6 literal")) } } } } else { match hp.find(":") { Some(i) => { ro p = parse_port(hp[i + 1..hp.byte_len()])? Ok((hp[0..i], Some(p))) } None => Ok((hp, None)) } } } fn parse_port(port_str str) -> Result[int, ParseUrlError] { if port_str.byte_len() == 0 { return Err(MalformedPort(port_str)) } for c in port_str.chars() { if !c.is_ascii_digit() { return Err(MalformedPort(port_str)) } } match port_str.to_int() { Ok(n) => if n >= 0 && n <= 65535 { Ok(n) } else { Err(MalformedPort(port_str)) } Err(_) => Err(MalformedPort(port_str)) } } fn parse_path(s str) -> (str, str) { match find_first_of(s, "?#") { Some(i) => (s[0..i], s[i..s.byte_len()]) None => (s, "") } } // ────────────────────────────────────────────────────────────────────────── // Строгий host/SSRF-валидатор // ────────────────────────────────────────────────────────────────────────── // Отвергает: control/NUL/whitespace/non-ASCII; IP-обфускацию (hex/octal/decimal/ // short-form); malformed IPv6. Пустой host (`file://`) допускается. fn validate_host(host str) -> Result[(), ParseUrlError] { if host.byte_len() == 0 { return Ok(()) } // 1. Байтовая чистота: reject control (<0x21 → incl. NUL/space/tab/CR/LF), DEL, // non-ASCII (IRI/punycode-обфускация вне scope — §3.8). for b in host.bytes() { ro n = b as int if n < 0x21 || n == 0x7f { return Err(InvalidHost("control or whitespace character in host")) } if n > 0x7f { return Err(InvalidHost("non-ASCII character in host (IRI unsupported)")) } } // 2. IPv6 inner (host без скобок содержит ':') if host.contains(":") { if is_valid_ipv6(host) { return Ok(()) } return Err(InvalidHost("malformed IPv6 literal")) } // 3. Числовой IP-литерал (все метки числовые) → канонический dotted-quad ИЛИ reject. if is_ip_literal_attempt(host) { return validate_ipv4_canonical(host) } // 4. reg-name (DNS hostname) — строгий ASCII-набор. validate_reg_name(host) } // Все ли метки (split '.') — числовые (decimal/hex)? Тогда host = IP-литерал-попытка. fn is_ip_literal_attempt(host str) -> bool { for lab in host.split(".") { if !is_numeric_label(lab) { return false } } true } fn is_numeric_label(lab str) -> bool { if lab.byte_len() == 0 { return false } if lab.byte_len() >= 2 && (lab.starts_with("0x") || lab.starts_with("0X")) { ro rest = lab[2..lab.byte_len()] if rest.byte_len() == 0 { return false } for c in rest.chars() { if !c.is_ascii_hexdigit() { return false } } return true } for c in lab.chars() { if !c.is_ascii_digit() { return false } } true } // Канонический IPv4: РОВНО 4 dotted-decimal октета, 0..255, БЕЗ leading-zero // (octal-ambiguity), БЕЗ hex. Всё иное (2130706433 / 127.1 / 0x7f.. / 0177..) → reject. fn validate_ipv4_canonical(host str) -> Result[(), ParseUrlError] { ro parts = host.split(".").collect() if parts.len() != 4 { return Err(ObfuscatedIp(host)) } for p in parts { if p.byte_len() == 0 || p.byte_len() > 3 { return Err(ObfuscatedIp(host)) } for c in p.chars() { if !c.is_ascii_digit() { return Err(ObfuscatedIp(host)) } } if p.byte_len() > 1 && p.starts_with("0") { return Err(ObfuscatedIp(host)) } match p.to_int() { Ok(v) => if v > 255 { return Err(ObfuscatedIp(host)) } Err(_) => return Err(ObfuscatedIp(host)) } } Ok(()) } // reg-name: строгий набор [A-Za-z0-9.-] (unicode/punycode/sub-delims — вне scope). fn validate_reg_name(host str) -> Result[(), ParseUrlError] { for c in host.chars() { if !(c.is_ascii_alphanumeric() || c == '-' || c == '.') { return Err(InvalidHost("disallowed character in host name")) } } Ok(()) } // Прагматичный IPv6-валидатор (RFC 4291): hex-группы + ':' + опц. embedded IPv4, // ≤1 "::", ≤8 hextet-эквивалентов. Отвергает мусор, принимает валидные формы. fn is_valid_ipv6(s str) -> bool { if s.byte_len() == 0 { return false } for c in s.chars() { if !(c.is_ascii_hexdigit() || c == ':' || c == '.') { return false } } if !s.contains(":") { return false } if s.contains(":::") { return false } if s.match_indices("::").len() > 1 { return false } if s.starts_with(":") && !s.starts_with("::") { return false } if s.ends_with(":") && !s.ends_with("::") { return false } ro has_dbl = s.contains("::") mut hextets = 0 mut ok = true for g in s.split(":") { if g.byte_len() == 0 { continue } if g.contains(".") { if !is_dotted_quad(g) { ok = false } hextets += 2 } else { if g.byte_len() > 4 { ok = false } hextets += 1 } } if !ok { return false } if has_dbl { hextets <= 7 } else { hextets == 8 } } fn is_dotted_quad(s str) -> bool { ro parts = s.split(".").collect() if parts.len() != 4 { return false } for p in parts { if p.byte_len() == 0 || p.byte_len() > 3 { return false } for c in p.chars() { if !c.is_ascii_digit() { return false } } match p.to_int() { Ok(v) => if v > 255 { return false } Err(_) => return false } } true } // ────────────────────────────────────────────────────────────────────────── // Accessors // ────────────────────────────────────────────────────────────────────────── /// Origin-тройка `(scheme, host, port)` — для cross-origin auth-strip и pool-key /// (Ф.2). Порт по умолчанию — из scheme (http=80, https=443, ws=80, wss=443). #stable(since = "0.1") export fn Url @origin() -> (str, str, int) { ro host = match @host { Some(h) => h, None => "" } ro port = match @port { Some(p) => p, None => default_port(@scheme) } (@scheme, host, port) } fn default_port(scheme str) -> int { match scheme { "https" => 443 "http" => 80 "wss" => 443 "ws" => 80 "ftp" => 21 _ => 0 } } /// SSRF-guard hook: `true` для host, указывающего в private/loopback/link-local/ /// metadata диапазон (только для IP-литералов + "localhost"; reg-name резолвится /// через DNS в Ф.2 `ssrf_guard`). #stable(since = "0.1") export fn Url @is_private_target() -> bool { match @host { None => false Some(h) => { if h == "localhost" { return true } if h.contains(":") { return is_private_ipv6(h) } if is_dotted_quad(h) { return is_private_ipv4(h) } false } } } fn is_private_ipv4(h str) -> bool { ro parts = h.split(".").collect() if parts.len() != 4 { return false } ro o0 = octet(parts.get(0)) ro o1 = octet(parts.get(1)) if o0 == 127 { return true } // loopback 127.0.0.0/8 if o0 == 10 { return true } // RFC1918 10.0.0.0/8 if o0 == 192 && o1 == 168 { return true } // RFC1918 192.168.0.0/16 if o0 == 172 && o1 >= 16 && o1 <= 31 { return true } // RFC1918 172.16.0.0/12 if o0 == 169 && o1 == 254 { return true } // link-local/metadata 169.254.0.0/16 if o0 == 0 { return true } // 0.0.0.0/8 (this-host) false } fn octet(o Option[str]) -> int { match o { Some(s) => s.to_int() ?? -1 None => -1 } } fn is_private_ipv6(h str) -> bool { ro lower = h.to_ascii_lower() if lower == "::1" { return true } // loopback if lower == "::" { return true } // unspecified if lower.starts_with("fe80") { return true } // link-local fe80::/10 if lower.starts_with("fc") || lower.starts_with("fd") { return true } // ULA fc00::/7 false } // ────────────────────────────────────────────────────────────────────────── // Сериализация // ────────────────────────────────────────────────────────────────────────── /// Сериализовать обратно в строку. Round-trip с `Url.parse` (IPv6 re-брекетится). #stable(since = "0.1") export fn Url @to_str() -> str { consume buf = StringBuilder.new() buf.cap(64) buf.append(@scheme) buf.append(":") match @host { Some(h) => { buf.append("//") match @user { Some(u) => { buf.append(u) match @password { Some(p) => { buf.append(":"); buf.append(p); () } None => () } buf.append("@") () } None => () } if h.contains(":") { buf.append("[") buf.append(h) buf.append("]") () } else { buf.append(h) () } match @port { Some(p) => { buf.append(":"); buf.append(p.to_str()); () } None => () } } None => () } buf.append(@path) match @query { Some(q) => { buf.append("?"); buf.append(q); () } None => () } match @fragment { Some(f) => { buf.append("#"); buf.append(f); () } None => () } buf.into_str() } // ────────────────────────────────────────────────────────────────────────── // Percent-encoding (RFC 3986 §2.1) — query values // ────────────────────────────────────────────────────────────────────────── /// Percent-encode query-value: `A-Z a-z 0-9 -_.~` raw, пробел → `+`, остальное — /// `%XX` ПО КАЖДОМУ UTF-8-байту (multi-byte-корректно). /// /// # Examples /// ```nova /// assert(Url.encode_query("a b&c") == "a+b%26c") /// ``` #stable(since = "0.1") export fn Url.encode_query(s str) -> str { consume buf = StringBuilder.new() buf.cap(s.byte_len() * 3) for c in s.chars() { if is_unreserved(c) { buf.append(c) } else if c == ' ' { buf.append('+') } else { // Percent-encode КАЖДЫЙ байт UTF-8-представления char'а. for b in c.to_str().bytes() { ro n = b as int buf.append('%') buf.append(hex_upper(((n / 16) & 0xF) as u8)) buf.append(hex_upper((n & 0xF) as u8)) } } } buf.into_str() } /// Percent-decode query-value. `%XX` malformed / невалидный UTF-8 → `InvalidUrl`. /// /// GATE: финальная UTF-8-валидация — self-contained (`str.try_from`/canonical /// `str.from_bytes` не существует; ← 176 Ф.0.5) `[M-178-url-decode-canonical-from-bytes]`. #stable(since = "0.1") export fn Url.decode_query(s str) -> Result[str, HttpError] { match decode_query_inner(s) { Ok(t) => Ok(t) Err(e) => Err(HttpError.invalid_url(e)) } } fn decode_query_inner(s str) -> Result[str, ParseUrlError] { mut buf = WriteBuffer.new() buf.cap(s.byte_len()) ro bytes = s.bytes() ro n = bytes.len() mut i = 0 while i < n { ro b = bytes[i] if b == 0x25 { // '%' if i + 2 >= n { return Err(InvalidPercentEncoding(i)) } ro hi = hex_byte_val(bytes[i + 1], i + 1)? ro lo = hex_byte_val(bytes[i + 2], i + 2)? buf.write_byte((hi * 16 + lo) as u8) i += 3 } else if b == 0x2B { // '+' → space (form-encoding) buf.write_byte(0x20 as u8) i += 1 } else { buf.write_byte(b) // raw byte (в т.ч. multi-byte UTF-8) i += 1 } } // std-дверь []u8 @to_str() (D410): Utf8Error.byte_offset даёт ту же позицию // первого битого байта — ручной RFC3629-валидатор снесён (аудит владельца // 2026-07-25: дубль std). match buf.into_bytes().to_str() { Ok(s) => Ok(s) Err(e) => Err(InvalidPercentEncoding(e.byte_offset)) } } fn hex_byte_val(b u8, pos int) -> Result[u8, ParseUrlError] { ro n = b as int if n >= 48 && n <= 57 { Ok((n - 48) as u8) } else if n >= 97 && n <= 102 { Ok((n - 97 + 10) as u8) } else if n >= 65 && n <= 70 { Ok((n - 65 + 10) as u8) } else { Err(InvalidPercentEncoding(pos)) } } // ────────────────────────────────────────────────────────────────────────── // Байтовые/char хелперы // ────────────────────────────────────────────────────────────────────────── // Первое вхождение любого байта из `chars` (ASCII-делимитеры) → БАЙТОВОЕ смещение. fn find_first_of(s str, chars str) -> Option[int] { ro bytes = s.bytes() ro needles = chars.bytes() ro n = bytes.len() ro m = needles.len() mut i = 0 while i < n { ro b = bytes[i] mut j = 0 while j < m { if needles[j] == b { return Some(i) } j += 1 } i += 1 } None } // Типизированные `None`-хелперы: bare `None` в кортеже codegen'ит как // `Option[int]` (inference default) — эти обёртки форсируют элемент-тип. fn none_str() -> Option[str] => None fn none_int() -> Option[int] => None fn is_alpha(c char) -> bool { ro n = c as int (n >= 65 && n <= 90) || (n >= 97 && n <= 122) } fn is_scheme_char(c char) -> bool => c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.' fn is_unreserved(c char) -> bool => c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '~' fn hex_upper(n u8) -> char { ro v = n as int ro code = if v < 10 { ('0' as int) + v } else { ('A' as int) + v - 10 } match code.to_char() { Ok(c) => c Err(_) => '0' } }