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.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. 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;