diff --git a/CHANGELOG.md b/CHANGELOG.md index 60fc992..96b42c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,60 @@ All notable changes to Raven are documented in this file. +## [2.19.10] - 2026-07-02 + +### Fixed + +- `std/json.parse` now rejects raw invalid UTF-8 bytes inside JSON string literals instead of accepting malformed JSON text as a `JsonValue.Str`. Valid raw UTF-8 and escaped Unicode still decode normally. (#845) + +## [2.19.9] - 2026-07-02 + +### Fixed + +- The `std/json` guide and spec now match the parser's surrogate policy: valid high/low surrogate pairs decode to the astral code point, while unpaired high or low surrogates are parse errors rather than documented as U+FFFD replacement. (#844) + +## [2.19.8] - 2026-07-02 + +### Fixed + +- The standard-library module charter now reflects the shipped `std/sync` concurrency primitives and bundled `std/tls` transport support instead of describing concurrency and TLS as deferred or package-only. (#843) + +## [2.19.7] - 2026-07-02 + +### Fixed + +- HTTP responses now validate field names and sanitize field values again immediately before serialization, so direct mutation of `Response.headers` cannot bypass the checked `Response.header` builder and write malformed header names or raw control bytes to the wire. (#842) + +## [2.19.6] - 2026-07-02 + +### Added + +- Added the missing `std/tls` standard-library guide page and navigation entries, covering verified TLS connections, custom trust, client certificates, STARTTLS upgrades, stream methods, and checked configuration builders. (#841) + +## [2.19.5] - 2026-07-02 + +### Fixed + +- `std/tls.TlsConfig` now exposes checked `add_ca_file_checked` and `client_cert_checked` builders that return `Result` when CA, certificate, or key files cannot be read or loaded. The chaining builders remain available for compatibility. (#840) + +## [2.19.4] - 2026-07-02 + +### Fixed + +- `Rng.weighted_choice` no longer saturates huge positive cumulative weights at `Int::MAX`, which made later positive items unreachable. Selection now sums positive weights as `Float` and keeps all positive entries reachable even when the exact integer total would overflow. (#839) + +## [2.19.3] - 2026-07-02 + +### Fixed + +- `std/fmt.format_float` now clamps excessive fractional precision before its scaling factor or scaled value becomes infinite, so finite inputs such as `format_float(1.0, 400)` return a bounded string instead of hanging in digit extraction. (#837) + +## [2.19.2] - 2026-07-02 + +### Fixed + +- `std/encoding.base32_decode` rejects padding-only and impossible RFC 4648 base32 padding shapes instead of silently decoding them as empty output. (#838) + ## [2.19.1] - 2026-06-27 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index e323744..93e3552 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -558,7 +558,7 @@ dependencies = [ [[package]] name = "raven" -version = "2.19.1" +version = "2.19.10" dependencies = [ "cc", "cranelift-codegen", @@ -575,7 +575,7 @@ dependencies = [ [[package]] name = "raven-runtime" -version = "2.19.1" +version = "2.19.10" dependencies = [ "chrono", "corosensei", diff --git a/Cargo.toml b/Cargo.toml index cf53836..6d7d79f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = [".", "raven-runtime"] [workspace.package] -version = "2.19.1" +version = "2.19.10" edition = "2021" authors = ["martian56"] license = "MIT" diff --git a/docs/v2/guide/standard-library.md b/docs/v2/guide/standard-library.md index 36cb076..aaea13b 100644 --- a/docs/v2/guide/standard-library.md +++ b/docs/v2/guide/standard-library.md @@ -127,6 +127,7 @@ helpers. |--------|----------------| | [std/net](stdlib/net.md) | TCP sockets | | [std/http](stdlib/http.md) | an HTTP client and server | +| [std/tls](stdlib/tls.md) | verified TLS streams and STARTTLS upgrades | | [std/json](stdlib/json.md) | JSON parse and stringify | ### Concurrency, errors, FFI, testing diff --git a/docs/v2/guide/stdlib/fmt.md b/docs/v2/guide/stdlib/fmt.md index 9316aeb..1f69bf4 100644 --- a/docs/v2/guide/stdlib/fmt.md +++ b/docs/v2/guide/stdlib/fmt.md @@ -207,6 +207,8 @@ fun main() { `x` rendered with exactly `decimals` digits after the decimal point, rounded half up. A `decimals` of `0` or less produces no fractional part and no point. +Very large precision requests are clamped before the internal scale would +overflow; the result stays bounded instead of hanging on finite input. ```rust import std/fmt { format_float } diff --git a/docs/v2/guide/stdlib/json.md b/docs/v2/guide/stdlib/json.md index 3571d47..4dd23de 100644 --- a/docs/v2/guide/stdlib/json.md +++ b/docs/v2/guide/stdlib/json.md @@ -95,7 +95,7 @@ offset. String escapes decode as in standard JSON: the two-character escapes `\" \\ \/ \b \f \n \r \t`, and a `\uXXXX` escape for a code point (UTF-8 encoded into the result). A high surrogate followed by a low surrogate -decodes to the astral code point; a lone surrogate decodes to U+FFFD. +decodes to the astral code point; an unpaired surrogate is a parse error. ## Navigating a parsed value diff --git a/docs/v2/guide/stdlib/random.md b/docs/v2/guide/stdlib/random.md index 6fe0c5c..0cc05a5 100644 --- a/docs/v2/guide/stdlib/random.md +++ b/docs/v2/guide/stdlib/random.md @@ -198,6 +198,10 @@ Choose an element of `xs` with probability proportional to the matching entry of `weights`. Returns `None` when the lists are empty or the total weight is not positive. +Non-positive weights are ignored. Very large positive weights are handled +without saturating the cumulative range, so later positive items remain +reachable even when the exact integer total would overflow. + ```rust import std/random diff --git a/docs/v2/guide/stdlib/tls.md b/docs/v2/guide/stdlib/tls.md new file mode 100644 index 0000000..23c2126 --- /dev/null +++ b/docs/v2/guide/stdlib/tls.md @@ -0,0 +1,123 @@ +# std/tls + +Client-side TLS streams over the raven-runtime TLS backend. Use `std/tls` when +you need a raw encrypted byte stream for a protocol such as Redis, Postgres, +MySQL, or SMTP after STARTTLS negotiation. For ordinary HTTPS requests, use +[std/http](http.md); its client already handles TLS. + +## Importing + +```rust +import std/tls { connect, connect_with, upgrade, upgrade_with, config } +``` + +`TlsStream` and `TlsConfig` methods come in with their types. + +## Connecting + +### `connect(addr: String, server_name: String) -> Result` + +Open a TCP connection to `addr` (`"host:port"`), start TLS immediately, send +`server_name` as SNI, and verify the peer against the bundled Mozilla root +store. + +```rust +import std/tls { connect } + +fun main() { + match connect("example.com:443", "example.com") { + Ok(stream) -> { + let _ = stream.write("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n") + print(stream.read_all()?) + stream.close() + }, + Err(e) -> print(e.message()), + } +} +``` + +### `connect_with(addr, server_name, cfg) -> Result` + +Connect with a custom `TlsConfig`, for private CAs, client certificates, or a +development-only skip of certificate verification. + +## Configuring Trust + +### `config() -> TlsConfig` + +Create a TLS client configuration. It starts with the bundled public roots. + +### `add_ca_file(path: String) -> TlsConfig` + +Trust PEM certificate(s) from `path` in addition to the bundled roots and return +the config for chaining. + +### `add_ca_file_checked(path: String) -> Result` + +The checked form of `add_ca_file`. Missing, unreadable, or malformed CA files +return an `Err` immediately. + +### `client_cert(cert_path: String, key_path: String) -> TlsConfig` + +Configure a client certificate chain and private key for mutual TLS and return +the config for chaining. + +### `client_cert_checked(cert_path: String, key_path: String) -> Result` + +The checked form of `client_cert`. Missing, unreadable, or malformed certificate +or key files return an `Err` immediately. + +### `insecure_skip_verify() -> TlsConfig` + +Disable certificate verification. The connection is encrypted but the peer is +not authenticated, so this is only for local development. + +```rust +import std/tls { config, connect_with } + +fun main() { + let cfg = config().add_ca_file_checked("dev-ca.pem")? + match connect_with("db.internal:5432", "db.internal", cfg) { + Ok(stream) -> { + // speak the protocol + stream.close() + }, + Err(e) -> print(e.message()), + } + cfg.free() +} +``` + +## STARTTLS + +### `upgrade(stream: TcpStream, server_name: String) -> Result` + +Turn an already-connected `std/net` TCP stream into a TLS stream on the same +socket. This is for protocols that negotiate in plaintext and then switch to +TLS. On success the original TCP stream is consumed. + +### `upgrade_with(stream, server_name, cfg) -> Result` + +The configured form of `upgrade`. + +## Stream Methods + +### `read(max: Int) -> Result` + +Read up to `max` decrypted bytes. A clean close returns `Ok("")`. + +### `read_all() -> Result` + +Read decrypted bytes until the peer closes the connection. + +### `write(data: String) -> Result` + +Encrypt and send all bytes in `data`, returning the count written. + +### `set_read_timeout_ms(ms: Int)`, `set_write_timeout_ms(ms: Int)` + +Set per-stream read or write timeouts. `0` disables the timeout. + +### `close()` + +Send TLS close-notify and release the runtime-side connection. diff --git a/docs/v2/specs/std-json.md b/docs/v2/specs/std-json.md index 518b27e..e4a6d4c 100644 --- a/docs/v2/specs/std-json.md +++ b/docs/v2/specs/std-json.md @@ -79,9 +79,9 @@ bytes in the result. A high surrogate (`\uD800` to `\uDBFF`) immediately followed by a low surrogate (`\uDC00` to `\uDFFF`) decodes to the astral code point and is UTF-8 encoded. A high surrogate not followed by a low surrogate, or a lone -low surrogate, decodes to U+FFFD (the replacement character). Note that the -Raven lexer rejects surrogate `\u` escapes inside a source string literal, -so a surrogate input reaches `parse` only from data read at runtime. +low surrogate, is a parse error. Note that the Raven lexer rejects surrogate +`\u` escapes inside a source string literal, so a surrogate input reaches +`parse` only from data read at runtime. ### Numbers diff --git a/docs/v2/specs/std-tls.md b/docs/v2/specs/std-tls.md index be25477..119cc74 100644 --- a/docs/v2/specs/std-tls.md +++ b/docs/v2/specs/std-tls.md @@ -40,8 +40,12 @@ checks that the certificate matches `server_name`, which is also sent as SNI. - `add_ca_file(path)` trusts the PEM certificate(s) in `path` in addition to the bundled roots, for a server signed by a private CA. +- `add_ca_file_checked(path)` is the fallible form, returning + `Result` when the CA file cannot be read or loaded. - `client_cert(cert_path, key_path)` presents a client certificate chain and private key (PEM) for mutual TLS. +- `client_cert_checked(cert_path, key_path)` is the fallible form, returning + `Result` when either file cannot be read or loaded. - `insecure_skip_verify()` accepts any certificate. The session is still encrypted, but the peer is not authenticated, so this is for local development only. @@ -70,7 +74,8 @@ prefix (the operation name) joined to the runtime error text. As in `std/net`, no error structs cross the FFI: the runtime keeps a thread-local last-error string that a fallible op clears on success and sets on failure, and `raven_tls_last_error()` returns it. An id of 0 from a connect, a negative count -from a write, or a non-empty last error becomes an `Err`. +from a write, a checked config builder returning 0, or a non-empty last error +becomes an `Err`. ## Bytes diff --git a/docs/v2/specs/stdlib-modules.md b/docs/v2/specs/stdlib-modules.md index 5a9d5c9..bb284b1 100644 --- a/docs/v2/specs/stdlib-modules.md +++ b/docs/v2/specs/stdlib-modules.md @@ -61,16 +61,18 @@ Every module implicitly imports the prelude. | `std/hash` | non-crypto hashing (FNV, xxHash) backing the `Hash` trait, checksums | Rust hash, Go hash | | `std/net` | TCP, UDP, addresses, DNS | Go net, Rust net | | `std/http` | HTTP client plus a minimal server | Go net/http | +| `std/tls` | verified TLS streams, custom trust, STARTTLS upgrade | Rustls, Go crypto/tls | | `std/regex` | regular expressions | Go regexp, Python re, Java regex | | `std/ffi` | `CStr`, `CInt`, `CPtr`, conversions | C interop | +| `std/sync` | goroutine channels, sleep, yield, mutex, wait groups | Go sync/channel | -## Deferred to v2.x +## Future extensions -Concurrency is its own milestone; v2.0 is single-threaded. +Raven v2 now ships goroutines, channels, and the `std/sync` primitives that +support them. The remaining concurrency surface can grow in follow-up releases +without changing the shipped channel API. -- `std/sync` (Mutex, RwLock, Once) - `std/thread` (spawn, join) -- `std/channel` (message passing) - `std/atomic` (atomic primitives) A lazy/async runtime, if added, is also a v2.x discussion. @@ -79,7 +81,7 @@ A lazy/async runtime, if added, is also a v2.x discussion. Delivered through rvpm rather than the standard library, because they are security-sensitive, fast-moving, opinionated, or niche: -- Cryptography and TLS signing (sha2, hmac, ed25519, and so on) +- Cryptography primitives and signing (sha2, hmac, ed25519, and so on) - Compression (gzip, zstd, brotli) - Serialization beyond JSON (YAML, TOML, msgpack, protobuf) - Database drivers @@ -105,5 +107,5 @@ Implementation proceeds in that dependency order, then the modules above, then t 1. Compiler foundations: methods on built-ins, capturing closures, core trait prelude, lazy iterators. 2. Convert the already-shipped `std/io` and `std/string` to the method-first API. 3. Core modules: collections, math, cmp, fmt, error, test. -4. System and data modules: time, fs, path, env, process, random, json, encoding, hash, net, http, regex. -5. v2.x: concurrency. +4. System, data, and network modules: time, fs, path, env, process, random, json, encoding, hash, net, http, tls, regex. +5. Concurrency primitives: goroutines plus `std/sync`. diff --git a/examples/v2/http_server_header_validation.rv b/examples/v2/http_server_header_validation.rv index bd40fe1..859ff2b 100644 --- a/examples/v2/http_server_header_validation.rv +++ b/examples/v2/http_server_header_validation.rv @@ -10,10 +10,13 @@ import std/time { sleep_millis } import std/io { println } fun handler(req: Request) -> Response { - return Response.text("body") - .header("Bad Name", "dropped") - .header("X-Ctrl", "a".concat(__str_from_byte(1)).concat("b")) - .header("X-Good", "fine") + let r = Response.text("body").header("Bad Name", "dropped").header( + "X-Ctrl", + "a".concat(__str_from_byte(1)).concat("b"), + ).header("X-Good", "fine") + r.headers.set("Direct Bad", "dropped") + r.headers.set("X-Direct-Ctrl", "c".concat(__str_from_byte(1)).concat("d")) + return r } fun headers_block(s: String) -> String { @@ -30,10 +33,10 @@ fun connect_retry(addr: String) -> Result { match connect(addr) { Ok(s) -> { return Ok(s) - } + }, Err(_) -> { sleep_millis(20) - } + }, } attempt += 1 } @@ -48,12 +51,12 @@ fun send(addr: String, request: String) -> String { match s.read_all() { Ok(r) -> { result = r - } - Err(_) -> {} + }, + Err(_) -> {}, } s.close() - } - Err(_) -> {} + }, + Err(_) -> {}, } return result } @@ -69,8 +72,10 @@ fun main() { let resp = send(addr, "GET / HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n") let block = headers_block(resp) println("bad-name dropped: ${block.index_of("Bad Name") < 0}") + println("direct bad-name dropped: ${block.index_of("Direct Bad") < 0}") println("good present: ${block.index_of("X-Good: fine") >= 0}") println("control stripped: ${block.index_of("X-Ctrl: ab") >= 0}") + println("direct control stripped: ${block.index_of("X-Direct-Ctrl: cd") >= 0}") app.shutdown() } diff --git a/examples/v2/random_weighted_choice.rv b/examples/v2/random_weighted_choice.rv index 3369e1d..44cd3b1 100644 --- a/examples/v2/random_weighted_choice.rv +++ b/examples/v2/random_weighted_choice.rv @@ -23,10 +23,10 @@ fun main() { if v == "c" { drew_c = drew_c + 1 } - } + }, None -> { none = none + 1 - } + }, } i = i + 1 } @@ -34,4 +34,26 @@ fun main() { println("never None: ${none == 0}") println("negative-weight item never drawn: ${!drew_b}") println("positive item past it reachable: ${drew_c > 0}") + + let huge_xs = ["a", "b"] + let huge_weights = [9223372036854775807, 9223372036854775807] + let huge_a = 0 + let huge_b = 0 + let seed = 0 + while seed < 40 { + let huge_rng = Rng.new(seed) + match huge_rng.weighted_choice(huge_xs, huge_weights) { + Some(v) -> { + if v == "a" { + huge_a = huge_a + 1 + } + if v == "b" { + huge_b = huge_b + 1 + } + }, + None -> {}, + } + seed = seed + 1 + } + println("huge equal weights both reachable: ${huge_a > 0 && huge_b > 0}") } diff --git a/examples/v2/random_weighted_choice.rv.out b/examples/v2/random_weighted_choice.rv.out index 67a7703..133f654 100644 --- a/examples/v2/random_weighted_choice.rv.out +++ b/examples/v2/random_weighted_choice.rv.out @@ -1,3 +1,4 @@ never None: true negative-weight item never drawn: true positive item past it reachable: true +huge equal weights both reachable: true diff --git a/examples/v2/stdlib_fmt.rv b/examples/v2/stdlib_fmt.rv index 34f52a0..419e944 100644 --- a/examples/v2/stdlib_fmt.rv +++ b/examples/v2/stdlib_fmt.rv @@ -5,18 +5,23 @@ // -2.50 // 255 // 10 +// true +// true import std/fmt { format_float, from_radix, from_hex } fun main() { - print(format_float(3.14159, 2)) // 3.14 - print(format_float(10.0, 2)) // 10.00 - print(format_float(-2.5, 2)) // -2.50 + print(format_float(3.14159, 2)) // 3.14 + print(format_float(10.0, 2)) // 10.00 + print(format_float(-2.5, 2)) // -2.50 match from_hex("ff") { - Some(n) -> print(n), // 255 + Some(n) -> print(n), // 255 None -> print(-1), } match from_radix("1010", 2) { - Some(n) -> print(n), // 10 + Some(n) -> print(n), // 10 None -> print(-1), } + let huge = format_float(1.0, 400) + print(huge.length() > 0) + print(huge.length() < 400) } diff --git a/examples/v2/stdlib_fmt.rv.out b/examples/v2/stdlib_fmt.rv.out index d155c17..980e2c2 100644 --- a/examples/v2/stdlib_fmt.rv.out +++ b/examples/v2/stdlib_fmt.rv.out @@ -3,3 +3,5 @@ -2.50 255 10 +true +true diff --git a/examples/v2/tls_config_errors.rv b/examples/v2/tls_config_errors.rv new file mode 100644 index 0000000..b8131de --- /dev/null +++ b/examples/v2/tls_config_errors.rv @@ -0,0 +1,18 @@ +import std/tls { config } +import std/io { println } + +fun main() { + let c = config() + match c.add_ca_file_checked("target/definitely_missing_tls_ca_for_test.pem") { + Ok(_) -> println("ca error exposed: false"), + Err(_) -> println("ca error exposed: true"), + } + match c.client_cert_checked( + "target/definitely_missing_tls_client_cert_for_test.pem", + "target/definitely_missing_tls_client_key_for_test.pem", + ) { + Ok(_) -> println("client cert error exposed: false"), + Err(_) -> println("client cert error exposed: true"), + } + c.free() +} diff --git a/examples/v2/tls_config_errors.rv.out b/examples/v2/tls_config_errors.rv.out new file mode 100644 index 0000000..839afef --- /dev/null +++ b/examples/v2/tls_config_errors.rv.out @@ -0,0 +1,2 @@ +ca error exposed: true +client cert error exposed: true diff --git a/examples/v2/use_json.rv b/examples/v2/use_json.rv index 49494f5..cfd50e1 100644 --- a/examples/v2/use_json.rv +++ b/examples/v2/use_json.rv @@ -15,4 +15,9 @@ fun main() { Ok(v) -> println("ok"), Err(e) -> println("parse failed"), } + let bad_utf8 = "\"".concat(__str_from_byte(255)).concat("\"") + match parse(bad_utf8) { + Ok(v) -> println("ok"), + Err(e) -> println("invalid utf8 rejected"), + } } diff --git a/mkdocs.yml b/mkdocs.yml index bb748e3..a259ce8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Networking: - std/net: v2/guide/stdlib/net.md - std/http: v2/guide/stdlib/http.md + - std/tls: v2/guide/stdlib/tls.md - std/json: v2/guide/stdlib/json.md - Concurrency, errors, FFI, testing: - std/sync: v2/guide/stdlib/sync.md diff --git a/stdlib/std/fmt.rv b/stdlib/std/fmt.rv index 9b3d520..9c1626c 100644 --- a/stdlib/std/fmt.rv +++ b/stdlib/std/fmt.rv @@ -253,9 +253,10 @@ fun float_digit_char(d: Float) -> String { return "9" } -// Format `x` with exactly `decimals` digits after the decimal point, rounded -// half up. `format_float(3.14159, 2)` is "3.14"; `format_float(-2.5, 0)` is -// "-3". +// Format `x` with exactly `decimals` digits after the decimal point when the +// requested scale stays finite, rounded half up. `format_float(3.14159, 2)` is +// "3.14"; `format_float(-2.5, 0)` is "-3". Excessive precision is clamped +// before the scale or scaled value would become infinite. fun format_float(x: Float, decimals: Int) -> String { // Non-finite values have no decimal form: NaN compares false everywhere // (so the scaling loop would never run and pad to "0.00") and infinity @@ -272,11 +273,22 @@ fun format_float(x: Float, decimals: Int) -> String { } let neg = x < 0.0 let v = fabs(x) + let places = decimals + if places < 0 { + places = 0 + } let scale = 1.0 let i = 0 - while i < decimals { - scale = scale * 10.0 - i = i + 1 + while i < places { + let next = scale * 10.0 + if next * 0.0 != 0.0 { + places = i + } else if (v * next) * 0.0 != 0.0 { + places = i + } else { + scale = next + i = i + 1 + } } let scaled = floor(v * scale + 0.5) let digits = "" @@ -293,15 +305,15 @@ fun format_float(x: Float, decimals: Int) -> String { count = count + 1 } } - while count <= decimals { + while count <= places { digits = __str_concat("0", digits) count = count + 1 } let result = "" - if decimals <= 0 { + if places <= 0 { result = digits } else { - let cut = count - decimals + let cut = count - places let int_part = digits.substring(0, cut) let frac_part = digits.substring(cut, count) result = __str_concat(__str_concat(int_part, "."), frac_part) diff --git a/stdlib/std/http.rv b/stdlib/std/http.rv index bbd541d..990b2c3 100644 --- a/stdlib/std/http.rv +++ b/stdlib/std/http.rv @@ -47,7 +47,12 @@ fun http_error(ctx: String) -> Error { // failure. The headers argument is `Key: Value` lines joined by `\n`, or // "" for none. A non-2xx HTTP status is still Ok: the runtime reports it // as a normal response. -fun request(method: String, url: String, body: String, headers: String) -> Result { +fun request( + method: String, + url: String, + body: String, + headers: String, +) -> Result { let id = raven_http_request(method, url, body, headers) if id == 0 { return Err(http_error(method)) @@ -210,7 +215,7 @@ impl Response { // A response with an explicit status and body and no headers yet. fun with_body(status: Int, body: String) -> Response { let h: Map = Map.new() - return Response { status: status, headers: h, body: body } + return Response { status, headers: h, body } } // 200 with `value` serialized to JSON via its `ToJson` impl (a `@derive( @@ -409,7 +414,7 @@ impl Server { // Register `handler` for `method` requests whose path matches `pattern`. // Routes are tried in registration order; the first match wins. fun route(self, method: Method, pattern: String, handler: fun(Request) -> Response) { - self.routes.push(Route { method: method, pattern: pattern, handler: handler }) + self.routes.push(Route { method, pattern, handler }) } // Register a handler for GET `pattern`. @@ -443,7 +448,11 @@ impl Server { // single path segment, so it cannot escape `dir` with a slash. A missing // file is a 404 (see `Response.file`). fun static(self, prefix: String, dir: String) { - self.route(Method.Get, prefix.concat("/:file"), fun(req: Request) -> Response = serve_from(dir, req)) + self.route( + Method.Get, + prefix.concat("/:file"), + fun(req: Request) -> Response = serve_from(dir, req), + ) } // Bind `addr` ("host:port") and serve, handling each connection on its own @@ -472,16 +481,16 @@ impl Server { inflight.done() }) } - } - Err(_) -> {} + }, + Err(_) -> {}, } } inflight.wait() // Release the listening socket so the port is free after a // graceful shutdown returns, rather than staying bound. socket.close() - } - Err(e) -> print("http: cannot bind ".concat(addr).concat(": ").concat(e.message())) + }, + Err(e) -> print("http: cannot bind ".concat(addr).concat(": ").concat(e.message())), } } } @@ -531,14 +540,14 @@ fun serve_connection(server: Server, stream: TcpStream) { if !keep { open = false } - } + }, // A clean close with no request pending: an idle keep-alive // client hung up. Close silently. None -> { open = false - } + }, } - } + }, Err(_) -> { // A malformed request is answered with 400 whether or not earlier // requests on this keep-alive connection succeeded; only a clean @@ -548,7 +557,7 @@ fun serve_connection(server: Server, stream: TcpStream) { print("- - 400") } open = false - } + }, } } stream.close() @@ -625,8 +634,8 @@ fun dispatch(routes: List, req: Request) -> Response { req.params = params let handler = route.handler return handler(req) - } - None -> {} + }, + None -> {}, } } i += 1 @@ -740,10 +749,10 @@ fun decode_chunked(stream: TcpStream, initial: String) -> Result { size = n - } + }, None -> { return Err(error_kind("http", "invalid chunk size")) - } + }, } if size < 0 { return Err(error_kind("http", "negative chunk size")) @@ -823,13 +832,13 @@ fun read_request(stream: TcpStream, initial: String) -> Result 65536 { return Err(error_kind("http", "request headers too large")) } - } + }, // A read failure while waiting for the next request line (a read // timeout on an idle keep-alive connection, or a reset) is not a // malformed request, so close quietly rather than answering 400. Err(_) -> { return Ok(None) - } + }, } } let head_text = buf.substring(0, split) @@ -951,12 +960,12 @@ fun read_request(stream: TcpStream, initial: String) -> Result { want = n - } + }, // A non-numeric Content-Length is a malformed request; answer // 400 rather than silently treating the body as empty. None -> { return Err(error_kind("http", "invalid Content-Length")) - } + }, } } // Reject a negative or oversized declared body before reading it, so a @@ -992,18 +1001,22 @@ fun read_request(stream: TcpStream, initial: String) -> Result = Map.new() - return Ok(Some(ReadResult { - request: Request { - method: method, - path: path, - version: version, - headers: headers, - params: params, - query: query, - body: actual_body, - }, - rest: rest, - })) + return Ok( + Some( + ReadResult { + request: Request { + method, + path, + version, + headers, + params, + query, + body: actual_body, + }, + rest, + }, + ), + ) } // Decode `a=1&b=2` query-string pairs into `out`, percent-decoding each key and @@ -1073,8 +1086,7 @@ fun is_tchar(b: Int) -> Bool { return true } // ! # $ % & ' * + - . ^ _ ` | ~ - return b == 33 || b == 35 || b == 36 || b == 37 || b == 38 || b == 39 || b == 42 || b == 43 - || b == 45 || b == 46 || b == 94 || b == 95 || b == 96 || b == 124 || b == 126 + return b == 33 || b == 35 || b == 36 || b == 37 || b == 38 || b == 39 || b == 42 || b == 43 || b == 45 || b == 46 || b == 94 || b == 95 || b == 96 || b == 124 || b == 126 } // Non-empty and every byte an ASCII decimal digit. Used to validate a numeric @@ -1187,7 +1199,10 @@ fun write_response(stream: TcpStream, resp: Response, keep_alive: Bool, is_head: let i = 0 while i < keys.len() { let k = keys[i] - block = block.concat(k).concat(": ").concat(resp.headers.get_or(k, "")).concat("\r\n") + if is_valid_field_name(k) { + let v = sanitize_field_value(resp.headers.get_or(k, "")) + block = block.concat(k).concat(": ").concat(v).concat("\r\n") + } i += 1 } // A response to a HEAD request, or one whose status forbids a body, carries diff --git a/stdlib/std/json.rv b/stdlib/std/json.rv index cb7a669..75b361b 100644 --- a/stdlib/std/json.rv +++ b/stdlib/std/json.rv @@ -182,7 +182,8 @@ fun parse_string(c: Cursor) -> Result { } if cp >= 55296 && cp <= 56319 { // High surrogate: decode an astral code point when a - // low surrogate follows, else emit U+FFFD. + // low surrogate follows. Unpaired surrogates are invalid + // JSON strings for this parser. if c.peek() == 92 { c.advance() if c.peek() == 117 { @@ -214,7 +215,21 @@ fun parse_string(c: Cursor) -> Result { if b < 32 { return Err(json_error("unescaped control character in string")) } - out = __str_concat(out, __str_from_byte(b)) + if b < 128 { + out = __str_concat(out, __str_from_byte(b)) + } else { + let start = c.pos - 1 + let len = utf8_seq_len(c.text, start, c.text.length()) + if len == 0 { + return Err(json_error("invalid UTF-8 in string")) + } + let j = 0 + while j < len { + out = __str_concat(out, __str_from_byte(__str_byte_at(c.text, start + j))) + j = j + 1 + } + c.pos = c.pos + (len - 1) + } } } return Ok(out) @@ -461,7 +476,7 @@ fun offset_str(n: Int) -> String { // Parse `text` as a single JSON value. Trailing content other than // whitespace after the top-level value is rejected. fun parse(text: String) -> Result { - let c = Cursor { text: text, pos: 0, depth: 0 } + let c = Cursor { text, pos: 0, depth: 0 } let v = parse_value(c)? c.skip_ws() if !c.at_end() { @@ -535,14 +550,54 @@ fun utf8_seq_len(s: String, i: Int, n: Int) -> Int { } return 0 } - if b >= 224 && b <= 239 { + if b == 224 { + if i + 2 < n && utf8_in_range(__str_byte_at(s, i + 1), 160, 191) && is_utf8_cont( + __str_byte_at(s, i + 2), + ) { + return 3 + } + return 0 + } + if b >= 225 && b <= 236 { + if i + 2 < n && is_utf8_cont(__str_byte_at(s, i + 1)) && is_utf8_cont(__str_byte_at(s, i + 2)) { + return 3 + } + return 0 + } + if b == 237 { + if i + 2 < n && utf8_in_range(__str_byte_at(s, i + 1), 128, 159) && is_utf8_cont( + __str_byte_at(s, i + 2), + ) { + return 3 + } + return 0 + } + if b >= 238 && b <= 239 { if i + 2 < n && is_utf8_cont(__str_byte_at(s, i + 1)) && is_utf8_cont(__str_byte_at(s, i + 2)) { return 3 } return 0 } - if b >= 240 && b <= 244 { - if i + 3 < n && is_utf8_cont(__str_byte_at(s, i + 1)) && is_utf8_cont(__str_byte_at(s, i + 2)) && is_utf8_cont(__str_byte_at(s, i + 3)) { + if b == 240 { + if i + 3 < n && utf8_in_range(__str_byte_at(s, i + 1), 144, 191) && is_utf8_cont( + __str_byte_at(s, i + 2), + ) && is_utf8_cont(__str_byte_at(s, i + 3)) { + return 4 + } + return 0 + } + if b >= 241 && b <= 243 { + if i + 3 < n && is_utf8_cont(__str_byte_at(s, i + 1)) && is_utf8_cont(__str_byte_at(s, i + 2)) && is_utf8_cont( + __str_byte_at(s, i + 3), + ) { + return 4 + } + return 0 + } + if b == 244 { + if i + 3 < n && utf8_in_range(__str_byte_at(s, i + 1), 128, 143) && is_utf8_cont( + __str_byte_at(s, i + 2), + ) && is_utf8_cont(__str_byte_at(s, i + 3)) { return 4 } return 0 @@ -550,6 +605,10 @@ fun utf8_seq_len(s: String, i: Int, n: Int) -> Int { return 0 } +fun utf8_in_range(b: Int, lo: Int, hi: Int) -> Bool { + return b >= lo && b <= hi +} + fun is_utf8_cont(b: Int) -> Bool { return b >= 128 && b <= 191 } @@ -787,7 +846,7 @@ fun value_keys(value: JsonValue) -> List { _ -> { let empty: List = [] empty - } + }, } } diff --git a/stdlib/std/random.rv b/stdlib/std/random.rv index 3b0c10a..ad7691f 100644 --- a/stdlib/std/random.rv +++ b/stdlib/std/random.rv @@ -150,7 +150,7 @@ impl Rng { // Choose an element of `xs` with probability proportional to the matching // entry of `weights`. Returns `None` when the lists are empty or the total - // weight is not positive. + // positive weight is zero. fun weighted_choice(self, xs: List, weights: List) -> Option { // Only items that have a matching weight can be drawn, so the total // must ignore any extra weights past the shorter list. Counting them @@ -160,45 +160,39 @@ impl Rng { if weights.len() < count { count = weights.len() } - // A non-positive weight contributes nothing and is never drawn: counting - // a negative weight would shrink the total and the cumulative sum, leaving - // a later positive item unreachable. Only positive weights are summed, and - // the sum saturates so a set of large weights does not wrap past i64 into - // a non-positive total and lose the whole draw. - let max = 9223372036854775807 - let total = 0 + // A non-positive weight contributes nothing and is never drawn. Sum as + // Float so very large positive Int weights do not saturate the cumulative + // range and make later positive items unreachable. + let total = 0.0 let i = 0 while i < count { let w = weights.get(i) if w > 0 { - if total > max - w { - total = max - } else { - total = total + w - } + total = total + raven_int_to_float(w) } i = i + 1 } - if total <= 0 { + if total <= 0.0 { return None } - let r = self.gen_range(0, total) - let acc = 0 + let r = self.next_float() * total + let acc = 0.0 + let fallback = -1 let j = 0 while j < count { let w = weights.get(j) if w > 0 { - if acc > max - w { - acc = max - } else { - acc = acc + w - } + fallback = j + acc = acc + raven_int_to_float(w) if r < acc { return Some(xs.get(j)) } } j = j + 1 } + if fallback >= 0 { + return Some(xs.get(fallback)) + } return None } } diff --git a/stdlib/std/tls.rv b/stdlib/std/tls.rv index 9a3aaf4..afd349b 100644 --- a/stdlib/std/tls.rv +++ b/stdlib/std/tls.rv @@ -116,19 +116,47 @@ fun config() -> TlsConfig { impl TlsConfig { // Trust the PEM certificate(s) in `path` in addition to the bundled roots, - // for a server signed by a private CA. Returns self so calls can chain. + // for a server signed by a private CA. Returns self so calls can chain; + // use add_ca_file_checked when the load failure must be handled. fun add_ca_file(self, path: String) -> TlsConfig { - let _ = raven_tls_config_add_ca_file(self.id, path) + let _ = self.add_ca_file_checked(path) return self } + // Checked variant of add_ca_file. Missing, unreadable, or malformed files + // return an Error instead of being hidden until a later connect. + fun add_ca_file_checked(self, path: String) -> Result { + let ok = raven_tls_config_add_ca_file(self.id, path) + if ok == 0 { + return Err(tls_error("add_ca_file")) + } + if raven_tls_last_error().length() > 0 { + return Err(tls_error("add_ca_file")) + } + return Ok(self) + } + // Present a client certificate for mutual TLS. `cert_path` is the PEM - // certificate chain, `key_path` the PEM private key. Returns self. + // certificate chain, `key_path` the PEM private key. Returns self; use + // client_cert_checked when the load failure must be handled. fun client_cert(self, cert_path: String, key_path: String) -> TlsConfig { - let _ = raven_tls_config_client_cert(self.id, cert_path, key_path) + let _ = self.client_cert_checked(cert_path, key_path) return self } + // Checked variant of client_cert. Missing, unreadable, or malformed files + // return an Error instead of being hidden until a later connect. + fun client_cert_checked(self, cert_path: String, key_path: String) -> Result { + let ok = raven_tls_config_client_cert(self.id, cert_path, key_path) + if ok == 0 { + return Err(tls_error("client_cert")) + } + if raven_tls_last_error().length() > 0 { + return Err(tls_error("client_cert")) + } + return Ok(self) + } + // Disable certificate verification. For local development only: this makes // the connection trivially interceptable. Returns self. fun insecure_skip_verify(self) -> TlsConfig { diff --git a/tests/codegen_smoke.rs b/tests/codegen_smoke.rs index 9199625..82ead2a 100644 --- a/tests/codegen_smoke.rs +++ b/tests/codegen_smoke.rs @@ -1397,10 +1397,13 @@ fn http_server_validates_response_headers() { }; // Regression for #742: the response builder drops a header with an invalid // field name and strips control bytes from a value, so a handler cannot frame - // a malformed response or inject extra headers. + // a malformed response or inject extra headers. The final serialization path + // repeats that validation for direct `Response.headers` mutations too. let expected = "bad-name dropped: true\n\ + direct bad-name dropped: true\n\ good present: true\n\ - control stripped: true\n"; + control stripped: true\n\ + direct control stripped: true\n"; compile_link_run_and_check("http_server_header_validation.rv", expected, &runtime); } @@ -1794,11 +1797,13 @@ fn json_program_compiles_and_runs() { // rendering the whole-number Float 1 as `1`. Object key order follows // the hash-bucket layout. A string with a `\n` escape and a `A` unicode escape // decodes (the escape becomes the literal A) and re-serializes with the - // newline re-escaped. A malformed object takes the Err path. Prints the - // compact object, the re-escaped string, then `parse failed`. + // newline re-escaped. A malformed object and raw invalid UTF-8 in a string + // take the Err path. Prints the compact object, the re-escaped string, then + // the two parse-failure markers. let expected = "{\"a\":1,\"b\":[true,null,\"hi\"]}\n\ \"x\\ny A\"\n\ - parse failed\n"; + parse failed\n\ + invalid utf8 rejected\n"; compile_link_run_and_check("use_json.rv", expected, &runtime); }