castrum 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// rust/panic_safety.rs — cross-module "never panic on malformed input" tests.
|
|
2
|
+
//
|
|
3
|
+
// These span several parsers (headers, query, cookie, JSON serialization), so
|
|
4
|
+
// they have no single owning module — kept as a dedicated test-only module
|
|
5
|
+
// (declared `#[cfg(test)]` in lib.rs). Every reachable parser must return
|
|
6
|
+
// Ok/Err on arbitrary bytes, never panic (a panic in the napi path becomes a
|
|
7
|
+
// JS 500; in the C-ABI path it is contained by `panic_guard`).
|
|
8
|
+
|
|
9
|
+
use crate::http::headers::HeaderRefs;
|
|
10
|
+
use crate::test_support::Rng;
|
|
11
|
+
|
|
12
|
+
#[test]
|
|
13
|
+
fn parsers_do_not_panic_on_malformed_input() {
|
|
14
|
+
let mut rng = Rng(0xdeadbeef);
|
|
15
|
+
|
|
16
|
+
for _ in 0..2000 {
|
|
17
|
+
let len = (rng.next() % 64) as usize;
|
|
18
|
+
let data = rng.bytes(len);
|
|
19
|
+
|
|
20
|
+
// Every reachable parser must return Ok/Err, never panic.
|
|
21
|
+
let _ = crate::http::query_parser::query_parse_packed_vec(&data);
|
|
22
|
+
let _ = crate::http::cookie_parser::cookie_parse_packed_vec(&data);
|
|
23
|
+
let _ = HeaderRefs::parse(&data, (rng.next() & 1) == 1, 100);
|
|
24
|
+
let _ = crate::json::json_ser::cookie_json_into_slice(&data, &mut [0u8; 64], 100);
|
|
25
|
+
let _ = crate::json::json_ser::json_escaped_len(&data);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[test]
|
|
30
|
+
fn header_parser_do_not_panic_on_adversarial_packed_headers() {
|
|
31
|
+
let mut rng = Rng(0xc0ffee);
|
|
32
|
+
|
|
33
|
+
for _ in 0..2000 {
|
|
34
|
+
// Length fields are arbitrary bytes -> must not cause OOB reads/panics.
|
|
35
|
+
let len = (rng.next() % 48) as usize;
|
|
36
|
+
let data = rng.bytes(len);
|
|
37
|
+
let _ = HeaderRefs::parse(&data, true, 200);
|
|
38
|
+
let _ = HeaderRefs::parse(&data, false, 200);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
// rust/payload/compress.rs — gzip + brotli compression.
|
|
2
|
+
//
|
|
3
|
+
// Backend-framework feature: response/body compression. gzip uses the
|
|
4
|
+
// memory-safe zlib-rs backend of flate2 (pure Rust, no C); brotli uses the pure
|
|
5
|
+
// Rust `brotli` crate. Both are heavy per-byte CPU work — ideal Rust offload.
|
|
6
|
+
//
|
|
7
|
+
// Pure-Rust core (no napi types) stays unit-testable; only the entry points
|
|
8
|
+
// use napi types.
|
|
9
|
+
|
|
10
|
+
use napi::bindgen_prelude::*;
|
|
11
|
+
use napi_derive::napi;
|
|
12
|
+
use std::io::{Read, Write};
|
|
13
|
+
|
|
14
|
+
// ── Pure-Rust core ─────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/// gzip-compress `data` at the given level (0–9; default 6).
|
|
17
|
+
pub fn gzip_compress_bytes(data: &[u8], level: u32) -> std::io::Result<Vec<u8>> {
|
|
18
|
+
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(level));
|
|
19
|
+
enc.write_all(data)?;
|
|
20
|
+
enc.finish()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Default cap on decompressed output (64 MiB) — a decompression-bomb guard.
|
|
24
|
+
/// A few hundred compressed bytes can otherwise expand to gigabytes (e.g.
|
|
25
|
+
/// zero-run deflate), OOM-aborting the process. Callers that legitimately
|
|
26
|
+
/// need more can pass `max_decompressed` to the napi entries.
|
|
27
|
+
pub const DEFAULT_MAX_DECOMPRESSED: usize = 64 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/// gzip-decompress `data`, erroring if the output would exceed `max` bytes.
|
|
30
|
+
pub fn gzip_decompress_bytes(data: &[u8], max: usize) -> std::io::Result<Vec<u8>> {
|
|
31
|
+
// Pre-sized output Vec: `read_to_end` on a `Vec::new()` grows the buffer
|
|
32
|
+
// from 32 bytes in doubling steps (each with a realloc + copy) — ~30%
|
|
33
|
+
// overhead on real payloads. The pre-size is bounded by `max` (the
|
|
34
|
+
// decompression-bomb cap) and by `data.len() * 8` (gzip rarely exceeds 8x
|
|
35
|
+
// on small inputs), so it never over-allocates by much.
|
|
36
|
+
let mut out = Vec::with_capacity(data.len().saturating_mul(8).min(max.max(64)));
|
|
37
|
+
let dec = flate2::read::GzDecoder::new(data);
|
|
38
|
+
dec.take(max as u64 + 1).read_to_end(&mut out)?;
|
|
39
|
+
if out.len() > max {
|
|
40
|
+
return Err(std::io::Error::new(
|
|
41
|
+
std::io::ErrorKind::InvalidData,
|
|
42
|
+
format!("gzip output exceeds the {max}-byte decompression cap"),
|
|
43
|
+
));
|
|
44
|
+
}
|
|
45
|
+
Ok(out)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// brotli-compress `data` at the given quality (0–11; default 5).
|
|
49
|
+
pub fn brotli_compress_bytes(data: &[u8], quality: u32) -> std::io::Result<Vec<u8>> {
|
|
50
|
+
let mut enc = brotli::CompressorWriter::new(Vec::new(), 4096, quality, 22);
|
|
51
|
+
enc.write_all(data)?;
|
|
52
|
+
// into_inner finalizes the stream (BROTLI_OPERATION_FINISH).
|
|
53
|
+
Ok(enc.into_inner())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// brotli-decompress `data`, erroring if the output would exceed `max` bytes.
|
|
57
|
+
pub fn brotli_decompress_bytes(data: &[u8], max: usize) -> std::io::Result<Vec<u8>> {
|
|
58
|
+
// Same pre-sized output trick as gzip (brotli's internal 4 KiB buffer is
|
|
59
|
+
// kept — larger buffers measured slower on small inputs). Brotli
|
|
60
|
+
// decompression benefits most from avoiding the output Vec's doubling
|
|
61
|
+
// reallocs.
|
|
62
|
+
let mut out = Vec::with_capacity(data.len().saturating_mul(16).min(max.max(64)));
|
|
63
|
+
let dec = brotli::Decompressor::new(data, 4096);
|
|
64
|
+
dec.take(max as u64 + 1).read_to_end(&mut out)?;
|
|
65
|
+
if out.len() > max {
|
|
66
|
+
return Err(std::io::Error::new(
|
|
67
|
+
std::io::ErrorKind::InvalidData,
|
|
68
|
+
format!("brotli output exceeds the {max}-byte decompression cap"),
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
Ok(out)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Streaming `*_into` cores (write into a caller buffer, no Vec) ──
|
|
75
|
+
// The C-ABI (`bun:ffi`) path uses these so gzip/brotli write directly into the
|
|
76
|
+
// caller's JS buffer — no intermediate `Vec` + memcpy. A too-small buffer is
|
|
77
|
+
// reported as `TooSmall { needed }` (the EXACT total, computed in a single
|
|
78
|
+
// pass) so the JS wrapper allocates once and retries, never re-running the
|
|
79
|
+
// whole (de)compression.
|
|
80
|
+
|
|
81
|
+
/// Error for the streaming `*_into` cores.
|
|
82
|
+
#[derive(Debug)]
|
|
83
|
+
pub enum StreamError {
|
|
84
|
+
/// Underlying I/O failure (invalid stream, etc.).
|
|
85
|
+
Io(std::io::Error),
|
|
86
|
+
/// The caller's output buffer was too small; `needed` is the exact total
|
|
87
|
+
/// size the stream would produce (the stream was fully consumed once to
|
|
88
|
+
/// count it).
|
|
89
|
+
TooSmall { needed: usize },
|
|
90
|
+
/// The stream's output would exceed the `max` decompression cap.
|
|
91
|
+
ExceedsMax(usize),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
impl From<std::io::Error> for StreamError {
|
|
95
|
+
fn from(e: std::io::Error) -> Self {
|
|
96
|
+
StreamError::Io(e)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Write sink that copies into a caller-provided slice up to its capacity and
|
|
101
|
+
/// silently COUNTS any overflow — so a one-pass compress can report the exact
|
|
102
|
+
/// required size without an intermediate `Vec`.
|
|
103
|
+
struct CountingWriter<'a> {
|
|
104
|
+
out: &'a mut [u8],
|
|
105
|
+
pos: usize,
|
|
106
|
+
overflow: usize,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
impl Write for CountingWriter<'_> {
|
|
110
|
+
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
111
|
+
let cap = self.out.len().saturating_sub(self.pos);
|
|
112
|
+
let take = cap.min(buf.len());
|
|
113
|
+
if take > 0 {
|
|
114
|
+
self.out[self.pos..self.pos + take].copy_from_slice(&buf[..take]);
|
|
115
|
+
self.pos += take;
|
|
116
|
+
}
|
|
117
|
+
self.overflow += buf.len() - take;
|
|
118
|
+
Ok(buf.len())
|
|
119
|
+
}
|
|
120
|
+
fn flush(&mut self) -> std::io::Result<()> {
|
|
121
|
+
Ok(())
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// gzip-compress `data` directly into `out` (one pass, no intermediate `Vec`).
|
|
126
|
+
/// Returns bytes written, or `StreamError::TooSmall { needed }` when `out` is
|
|
127
|
+
/// too small (the exact required size, computed without a second pass).
|
|
128
|
+
pub fn gzip_compress_into(
|
|
129
|
+
data: &[u8],
|
|
130
|
+
level: u32,
|
|
131
|
+
out: &mut [u8],
|
|
132
|
+
) -> std::result::Result<usize, StreamError> {
|
|
133
|
+
let mut w = CountingWriter {
|
|
134
|
+
out,
|
|
135
|
+
pos: 0,
|
|
136
|
+
overflow: 0,
|
|
137
|
+
};
|
|
138
|
+
let mut enc = flate2::write::GzEncoder::new(&mut w, flate2::Compression::new(level));
|
|
139
|
+
enc.write_all(data)?;
|
|
140
|
+
enc.finish()?;
|
|
141
|
+
if w.overflow > 0 {
|
|
142
|
+
return Err(StreamError::TooSmall {
|
|
143
|
+
needed: w.pos + w.overflow,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
Ok(w.pos)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// brotli-compress `data` directly into `out` (one pass, no intermediate `Vec`).
|
|
150
|
+
/// Returns bytes written, or `StreamError::TooSmall { needed }` when `out` is
|
|
151
|
+
/// too small (the exact required size, computed without a second pass).
|
|
152
|
+
pub fn brotli_compress_into(
|
|
153
|
+
data: &[u8],
|
|
154
|
+
quality: u32,
|
|
155
|
+
out: &mut [u8],
|
|
156
|
+
) -> std::result::Result<usize, StreamError> {
|
|
157
|
+
let mut w = CountingWriter {
|
|
158
|
+
out,
|
|
159
|
+
pos: 0,
|
|
160
|
+
overflow: 0,
|
|
161
|
+
};
|
|
162
|
+
let mut enc = brotli::CompressorWriter::new(&mut w, 4096, quality, 22);
|
|
163
|
+
enc.write_all(data)?;
|
|
164
|
+
let w2 = enc.into_inner();
|
|
165
|
+
if w2.overflow > 0 {
|
|
166
|
+
return Err(StreamError::TooSmall {
|
|
167
|
+
needed: w2.pos + w2.overflow,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
Ok(w2.pos)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// Shared decompress-into loop: read from a `Read` stream into `out` up to its
|
|
174
|
+
/// capacity, bounded by the `max` cap. When `out` is exactly filled, the
|
|
175
|
+
/// stream is consumed once more (into a scratch) to detect overflow and report
|
|
176
|
+
/// the exact `needed` size.
|
|
177
|
+
fn decompress_into<R: Read>(
|
|
178
|
+
mut dec: R,
|
|
179
|
+
max: usize,
|
|
180
|
+
out: &mut [u8],
|
|
181
|
+
) -> std::result::Result<usize, StreamError> {
|
|
182
|
+
let mut written = 0usize;
|
|
183
|
+
while written < out.len() {
|
|
184
|
+
let n = dec.read(&mut out[written..])?;
|
|
185
|
+
if n == 0 {
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
written += n;
|
|
189
|
+
}
|
|
190
|
+
if written > max {
|
|
191
|
+
return Err(StreamError::ExceedsMax(max));
|
|
192
|
+
}
|
|
193
|
+
if written < out.len() {
|
|
194
|
+
// Stream ended inside the buffer — fits, done.
|
|
195
|
+
return Ok(written);
|
|
196
|
+
}
|
|
197
|
+
// `out` was exactly filled — check whether the stream has more to emit.
|
|
198
|
+
let mut total = written;
|
|
199
|
+
let mut scratch = [0u8; 4096];
|
|
200
|
+
loop {
|
|
201
|
+
let n = dec.read(&mut scratch)?;
|
|
202
|
+
if n == 0 {
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
total += n;
|
|
206
|
+
if total > max {
|
|
207
|
+
return Err(StreamError::ExceedsMax(max));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if total > written {
|
|
211
|
+
return Err(StreamError::TooSmall { needed: total });
|
|
212
|
+
}
|
|
213
|
+
Ok(written)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// gzip-decompress `data` directly into `out` (one pass, no intermediate `Vec`),
|
|
217
|
+
/// bounded by the `max` decompression cap.
|
|
218
|
+
pub fn gzip_decompress_into(
|
|
219
|
+
data: &[u8],
|
|
220
|
+
max: usize,
|
|
221
|
+
out: &mut [u8],
|
|
222
|
+
) -> std::result::Result<usize, StreamError> {
|
|
223
|
+
decompress_into(flate2::read::GzDecoder::new(data), max, out)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// brotli-decompress `data` directly into `out` (one pass, no intermediate
|
|
227
|
+
/// `Vec`), bounded by the `max` decompression cap.
|
|
228
|
+
pub fn brotli_decompress_into(
|
|
229
|
+
data: &[u8],
|
|
230
|
+
max: usize,
|
|
231
|
+
out: &mut [u8],
|
|
232
|
+
) -> std::result::Result<usize, StreamError> {
|
|
233
|
+
decompress_into(brotli::Decompressor::new(data, 4096), max, out)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── NAPI entry points ──────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
/// gzip-compress bytes → gzip stream.
|
|
239
|
+
#[napi]
|
|
240
|
+
pub fn gzip_compress(data: Uint8Array, level: Option<u32>) -> Result<Buffer> {
|
|
241
|
+
let level = level.unwrap_or(6).min(9);
|
|
242
|
+
gzip_compress_bytes(data.as_ref(), level)
|
|
243
|
+
.map(Buffer::from)
|
|
244
|
+
.map_err(|e| Error::from_reason(format!("gzip compress failed: {e}")))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// gzip-decompress a gzip stream → original bytes. `max_decompressed` caps the
|
|
248
|
+
/// output (default 64 MiB) as a decompression-bomb guard.
|
|
249
|
+
#[napi]
|
|
250
|
+
pub fn gzip_decompress(data: Uint8Array, max_decompressed: Option<u32>) -> Result<Buffer> {
|
|
251
|
+
let max = max_decompressed
|
|
252
|
+
.map(|m| m as usize)
|
|
253
|
+
.unwrap_or(DEFAULT_MAX_DECOMPRESSED);
|
|
254
|
+
gzip_decompress_bytes(data.as_ref(), max)
|
|
255
|
+
.map(Buffer::from)
|
|
256
|
+
.map_err(|e| Error::from_reason(format!("gzip decompress failed: {e}")))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// brotli-compress bytes → brotli stream.
|
|
260
|
+
#[napi]
|
|
261
|
+
pub fn brotli_compress(data: Uint8Array, quality: Option<u32>) -> Result<Buffer> {
|
|
262
|
+
let quality = quality.unwrap_or(5).min(11);
|
|
263
|
+
brotli_compress_bytes(data.as_ref(), quality)
|
|
264
|
+
.map(Buffer::from)
|
|
265
|
+
.map_err(|e| Error::from_reason(format!("brotli compress failed: {e}")))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// brotli-decompress a brotli stream → original bytes. `max_decompressed` caps
|
|
269
|
+
/// the output (default 64 MiB) as a decompression-bomb guard.
|
|
270
|
+
#[napi]
|
|
271
|
+
pub fn brotli_decompress(data: Uint8Array, max_decompressed: Option<u32>) -> Result<Buffer> {
|
|
272
|
+
let max = max_decompressed
|
|
273
|
+
.map(|m| m as usize)
|
|
274
|
+
.unwrap_or(DEFAULT_MAX_DECOMPRESSED);
|
|
275
|
+
brotli_decompress_bytes(data.as_ref(), max)
|
|
276
|
+
.map(Buffer::from)
|
|
277
|
+
.map_err(|e| Error::from_reason(format!("brotli decompress failed: {e}")))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// gzip batch: packed `[u32 count]{[u32 len][data]}` in → packed
|
|
281
|
+
/// `[u32 count]{[u32 len][gzip]}` out (same level for all items).
|
|
282
|
+
#[napi]
|
|
283
|
+
pub fn gzip_compress_batch_packed(data: Uint8Array, level: Option<u32>) -> Result<Buffer> {
|
|
284
|
+
let level = level.unwrap_or(6).min(9);
|
|
285
|
+
crate::util::run_packed_batch(data.as_ref(), move |chunk| {
|
|
286
|
+
gzip_compress_bytes(chunk, level).unwrap_or_default()
|
|
287
|
+
})
|
|
288
|
+
.map(Buffer::from)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// gzip-decompress batch: packed gzip streams in → packed originals out. Each
|
|
292
|
+
/// item's output is capped by `max_decompressed` (default 64 MiB); an item
|
|
293
|
+
/// that exceeds it becomes empty (the batch's fail convention).
|
|
294
|
+
#[napi]
|
|
295
|
+
pub fn gzip_decompress_batch_packed(
|
|
296
|
+
data: Uint8Array,
|
|
297
|
+
max_decompressed: Option<u32>,
|
|
298
|
+
) -> Result<Buffer> {
|
|
299
|
+
let max = max_decompressed
|
|
300
|
+
.map(|m| m as usize)
|
|
301
|
+
.unwrap_or(DEFAULT_MAX_DECOMPRESSED);
|
|
302
|
+
crate::util::run_packed_batch(data.as_ref(), move |chunk| {
|
|
303
|
+
gzip_decompress_bytes(chunk, max).unwrap_or_default()
|
|
304
|
+
})
|
|
305
|
+
.map(Buffer::from)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// brotli batch: packed data in → packed brotli streams out.
|
|
309
|
+
#[napi]
|
|
310
|
+
pub fn brotli_compress_batch_packed(data: Uint8Array, quality: Option<u32>) -> Result<Buffer> {
|
|
311
|
+
let quality = quality.unwrap_or(5).min(11);
|
|
312
|
+
crate::util::run_packed_batch(data.as_ref(), move |chunk| {
|
|
313
|
+
brotli_compress_bytes(chunk, quality).unwrap_or_default()
|
|
314
|
+
})
|
|
315
|
+
.map(Buffer::from)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// brotli-decompress batch: packed brotli streams in → packed originals out.
|
|
319
|
+
/// Each item's output is capped by `max_decompressed` (default 64 MiB); an
|
|
320
|
+
/// item that exceeds it becomes empty (the batch's fail convention).
|
|
321
|
+
#[napi]
|
|
322
|
+
pub fn brotli_decompress_batch_packed(
|
|
323
|
+
data: Uint8Array,
|
|
324
|
+
max_decompressed: Option<u32>,
|
|
325
|
+
) -> Result<Buffer> {
|
|
326
|
+
let max = max_decompressed
|
|
327
|
+
.map(|m| m as usize)
|
|
328
|
+
.unwrap_or(DEFAULT_MAX_DECOMPRESSED);
|
|
329
|
+
crate::util::run_packed_batch(data.as_ref(), move |chunk| {
|
|
330
|
+
brotli_decompress_bytes(chunk, max).unwrap_or_default()
|
|
331
|
+
})
|
|
332
|
+
.map(Buffer::from)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#[cfg(test)]
|
|
336
|
+
mod tests {
|
|
337
|
+
use super::*;
|
|
338
|
+
|
|
339
|
+
const PAYLOAD: &[u8] = b"the quick brown fox jumps over the lazy dog. the quick brown fox jumps over the lazy dog.";
|
|
340
|
+
|
|
341
|
+
#[test]
|
|
342
|
+
fn gzip_roundtrip() {
|
|
343
|
+
let compressed = gzip_compress_bytes(PAYLOAD, 6).unwrap();
|
|
344
|
+
assert!(compressed.len() < PAYLOAD.len());
|
|
345
|
+
assert_eq!(
|
|
346
|
+
gzip_decompress_bytes(&compressed, DEFAULT_MAX_DECOMPRESSED).unwrap(),
|
|
347
|
+
PAYLOAD
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#[test]
|
|
352
|
+
fn brotli_roundtrip() {
|
|
353
|
+
let compressed = brotli_compress_bytes(PAYLOAD, 5).unwrap();
|
|
354
|
+
assert!(compressed.len() < PAYLOAD.len());
|
|
355
|
+
assert_eq!(
|
|
356
|
+
brotli_decompress_bytes(&compressed, DEFAULT_MAX_DECOMPRESSED).unwrap(),
|
|
357
|
+
PAYLOAD
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#[test]
|
|
362
|
+
fn gzip_handles_empty_and_binary() {
|
|
363
|
+
let empty = gzip_compress_bytes(b"", 6).unwrap();
|
|
364
|
+
assert_eq!(
|
|
365
|
+
gzip_decompress_bytes(&empty, DEFAULT_MAX_DECOMPRESSED).unwrap(),
|
|
366
|
+
b""
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
let binary: Vec<u8> = (0u8..=255).cycle().take(10_000).collect();
|
|
370
|
+
let compressed = gzip_compress_bytes(&binary, 6).unwrap();
|
|
371
|
+
assert_eq!(
|
|
372
|
+
gzip_decompress_bytes(&compressed, DEFAULT_MAX_DECOMPRESSED).unwrap(),
|
|
373
|
+
binary
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#[test]
|
|
378
|
+
fn brotli_handles_binary() {
|
|
379
|
+
let binary: Vec<u8> = (0u8..=255).cycle().take(10_000).collect();
|
|
380
|
+
let compressed = brotli_compress_bytes(&binary, 5).unwrap();
|
|
381
|
+
assert_eq!(
|
|
382
|
+
brotli_decompress_bytes(&compressed, DEFAULT_MAX_DECOMPRESSED).unwrap(),
|
|
383
|
+
binary
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#[test]
|
|
388
|
+
fn gzip_rejects_garbage() {
|
|
389
|
+
assert!(gzip_decompress_bytes(b"not-a-gzip-stream", DEFAULT_MAX_DECOMPRESSED).is_err());
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#[test]
|
|
393
|
+
fn decompression_bomb_is_capped() {
|
|
394
|
+
// A tiny gzip/brotli stream that inflates to a megabyte of zeros must
|
|
395
|
+
// ERROR when the cap is smaller than the output — not OOM the process.
|
|
396
|
+
let bomb = vec![0u8; 1_000_000];
|
|
397
|
+
let gz = gzip_compress_bytes(&bomb, 1).unwrap();
|
|
398
|
+
assert!(
|
|
399
|
+
gz.len() < bomb.len() / 100,
|
|
400
|
+
"zero-run gzip should compress hard"
|
|
401
|
+
);
|
|
402
|
+
assert!(gzip_decompress_bytes(&gz, 1024).is_err());
|
|
403
|
+
|
|
404
|
+
let br = brotli_compress_bytes(&bomb, 0).unwrap();
|
|
405
|
+
assert!(
|
|
406
|
+
br.len() < bomb.len() / 100,
|
|
407
|
+
"zero-run brotli should compress hard"
|
|
408
|
+
);
|
|
409
|
+
assert!(brotli_decompress_bytes(&br, 1024).is_err());
|
|
410
|
+
|
|
411
|
+
// The same streams roundtrip fine within a generous cap.
|
|
412
|
+
assert_eq!(
|
|
413
|
+
gzip_decompress_bytes(&gz, DEFAULT_MAX_DECOMPRESSED).unwrap(),
|
|
414
|
+
bomb
|
|
415
|
+
);
|
|
416
|
+
assert_eq!(
|
|
417
|
+
brotli_decompress_bytes(&br, DEFAULT_MAX_DECOMPRESSED).unwrap(),
|
|
418
|
+
bomb
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#[test]
|
|
423
|
+
fn streaming_roundtrip_matches_bytes() {
|
|
424
|
+
// Streaming compress into an exact-size buffer == the Vec-based core.
|
|
425
|
+
let expected = gzip_compress_bytes(PAYLOAD, 6).unwrap();
|
|
426
|
+
let mut buf = vec![0u8; expected.len()];
|
|
427
|
+
let w = gzip_compress_into(PAYLOAD, 6, &mut buf).expect("exact buffer");
|
|
428
|
+
assert_eq!(w, expected.len());
|
|
429
|
+
assert_eq!(&buf[..w], expected.as_slice());
|
|
430
|
+
|
|
431
|
+
let mut out = vec![0u8; PAYLOAD.len() + 64];
|
|
432
|
+
let d = gzip_decompress_into(&expected, DEFAULT_MAX_DECOMPRESSED, &mut out)
|
|
433
|
+
.expect("large buffer");
|
|
434
|
+
assert_eq!(&out[..d], PAYLOAD);
|
|
435
|
+
|
|
436
|
+
let bexpected = brotli_compress_bytes(PAYLOAD, 5).unwrap();
|
|
437
|
+
let mut bbuf = vec![0u8; bexpected.len()];
|
|
438
|
+
let bw = brotli_compress_into(PAYLOAD, 5, &mut bbuf).expect("exact buffer");
|
|
439
|
+
assert_eq!(bw, bexpected.len());
|
|
440
|
+
assert_eq!(&bbuf[..bw], bexpected.as_slice());
|
|
441
|
+
|
|
442
|
+
let mut bout = vec![0u8; PAYLOAD.len() + 64];
|
|
443
|
+
let bd = brotli_decompress_into(&bexpected, DEFAULT_MAX_DECOMPRESSED, &mut bout)
|
|
444
|
+
.expect("large buffer");
|
|
445
|
+
assert_eq!(&bout[..bd], PAYLOAD);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
#[test]
|
|
449
|
+
fn streaming_too_small_reports_needed() {
|
|
450
|
+
let expected = gzip_compress_bytes(PAYLOAD, 6).unwrap();
|
|
451
|
+
let mut tiny = [0u8; 4];
|
|
452
|
+
match gzip_compress_into(PAYLOAD, 6, &mut tiny) {
|
|
453
|
+
Err(StreamError::TooSmall { needed }) => assert_eq!(needed, expected.len()),
|
|
454
|
+
other => panic!("expected TooSmall, got {other:?}"),
|
|
455
|
+
}
|
|
456
|
+
let mut exact = vec![0u8; expected.len()];
|
|
457
|
+
assert_eq!(
|
|
458
|
+
gzip_compress_into(PAYLOAD, 6, &mut exact).unwrap(),
|
|
459
|
+
expected.len()
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
let mut dout = [0u8; 8];
|
|
463
|
+
match gzip_decompress_into(&expected, DEFAULT_MAX_DECOMPRESSED, &mut dout) {
|
|
464
|
+
Err(StreamError::TooSmall { needed }) => assert_eq!(needed, PAYLOAD.len()),
|
|
465
|
+
other => panic!("expected TooSmall, got {other:?}"),
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let bexpected = brotli_compress_bytes(PAYLOAD, 5).unwrap();
|
|
469
|
+
let mut btiny = [0u8; 4];
|
|
470
|
+
match brotli_compress_into(PAYLOAD, 5, &mut btiny) {
|
|
471
|
+
Err(StreamError::TooSmall { needed }) => assert_eq!(needed, bexpected.len()),
|
|
472
|
+
other => panic!("expected TooSmall, got {other:?}"),
|
|
473
|
+
}
|
|
474
|
+
let mut bdout = [0u8; 8];
|
|
475
|
+
match brotli_decompress_into(&bexpected, DEFAULT_MAX_DECOMPRESSED, &mut bdout) {
|
|
476
|
+
Err(StreamError::TooSmall { needed }) => assert_eq!(needed, PAYLOAD.len()),
|
|
477
|
+
other => panic!("expected TooSmall, got {other:?}"),
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#[test]
|
|
482
|
+
fn streaming_decompress_caps_bomb() {
|
|
483
|
+
let bomb = vec![0u8; 1_000_000];
|
|
484
|
+
let gz = gzip_compress_bytes(&bomb, 1).unwrap();
|
|
485
|
+
let mut out = vec![0u8; 2048];
|
|
486
|
+
match gzip_decompress_into(&gz, 1024, &mut out) {
|
|
487
|
+
Err(StreamError::ExceedsMax(_)) => {}
|
|
488
|
+
other => panic!("expected ExceedsMax, got {other:?}"),
|
|
489
|
+
}
|
|
490
|
+
let br = brotli_compress_bytes(&bomb, 0).unwrap();
|
|
491
|
+
let mut bout = vec![0u8; 2048];
|
|
492
|
+
match brotli_decompress_into(&br, 1024, &mut bout) {
|
|
493
|
+
Err(StreamError::ExceedsMax(_)) => {}
|
|
494
|
+
other => panic!("expected ExceedsMax, got {other:?}"),
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#[test]
|
|
499
|
+
fn streaming_handles_empty_and_binary() {
|
|
500
|
+
let empty = gzip_compress_bytes(b"", 6).unwrap();
|
|
501
|
+
let mut out = vec![0u8; 4];
|
|
502
|
+
let d = gzip_decompress_into(&empty, DEFAULT_MAX_DECOMPRESSED, &mut out)
|
|
503
|
+
.expect("empty gzip decompresses to 0 bytes");
|
|
504
|
+
assert_eq!(d, 0);
|
|
505
|
+
|
|
506
|
+
let binary: Vec<u8> = (0u8..=255).cycle().take(10_000).collect();
|
|
507
|
+
let compressed = gzip_compress_bytes(&binary, 6).unwrap();
|
|
508
|
+
let mut bout = vec![0u8; binary.len() + 64];
|
|
509
|
+
let w = gzip_decompress_into(&compressed, DEFAULT_MAX_DECOMPRESSED, &mut bout).unwrap();
|
|
510
|
+
assert_eq!(&bout[..w], binary.as_slice());
|
|
511
|
+
}
|
|
512
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//! Output & streaming (compress, SSE, WebSocket, templating).
|
|
2
|
+
//
|
|
3
|
+
// - compress.rs gzip (zlib-rs) + brotli compress/decompress + batch
|
|
4
|
+
// - sse.rs SSE event framing + batch
|
|
5
|
+
// - ws_frames.rs RFC 6455 frame codec + batch
|
|
6
|
+
// - websocket.rs WebSocket accept-key
|
|
7
|
+
// - template.rs minijinja template rendering (TemplateRenderer) + batch
|
|
8
|
+
|
|
9
|
+
pub mod compress;
|
|
10
|
+
pub mod sse;
|
|
11
|
+
pub mod template;
|
|
12
|
+
pub mod websocket;
|
|
13
|
+
pub mod ws_frames;
|