castrum 0.9.3 → 0.9.6

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 (206) hide show
  1. package/Cargo.lock +2242 -0
  2. package/Cargo.toml +155 -0
  3. package/README.md +14 -6
  4. package/build.rs +5 -0
  5. package/castrum.darwin-arm64.node +0 -0
  6. package/castrum.darwin-x64.node +0 -0
  7. package/castrum.linux-arm64-gnu.node +0 -0
  8. package/castrum.linux-arm64-musl.node +0 -0
  9. package/castrum.linux-x64-gnu.node +0 -0
  10. package/castrum.linux-x64-musl.node +0 -0
  11. package/castrum.linux-x64-v3-gnu.node +0 -0
  12. package/castrum.win32-x64-msvc.node +0 -0
  13. package/dist/index.d.ts +1508 -1291
  14. package/dist/index.js +3391 -2069
  15. package/docs/API.md +2 -1
  16. package/docs/ARCHITECTURE.md +3 -2
  17. package/docs/CASE_STUDY.md +5 -3
  18. package/docs/ENVIRONMENT.md +25 -2
  19. package/docs/FFI_BUN_GUIDE.md +242 -14
  20. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  21. package/docs/REPO_MAP.md +17 -6
  22. package/docs/RND-CONCURRENCY.md +772 -0
  23. package/docs/bun-builtins-decision-matrix.md +4 -1
  24. package/index.ts +24 -25
  25. package/package.json +173 -156
  26. package/rust/crypto/aead.rs +378 -0
  27. package/rust/crypto/argon2.rs +265 -0
  28. package/rust/crypto/base64.rs +420 -0
  29. package/rust/crypto/bcrypt.rs +89 -0
  30. package/rust/crypto/cookie_sign.rs +169 -0
  31. package/rust/crypto/csrf.rs +106 -0
  32. package/rust/crypto/ed25519.rs +215 -0
  33. package/rust/crypto/hashing.rs +140 -0
  34. package/rust/crypto/hmac_sha256.rs +88 -0
  35. package/rust/crypto/jwt/api.rs +286 -0
  36. package/rust/crypto/jwt/mod.rs +22 -0
  37. package/rust/crypto/jwt/tests.rs +312 -0
  38. package/rust/crypto/jwt/token.rs +360 -0
  39. package/rust/crypto/mod.rs +27 -0
  40. package/rust/crypto/pbkdf2.rs +105 -0
  41. package/rust/crypto/random_token.rs +85 -0
  42. package/rust/crypto/session.rs +222 -0
  43. package/rust/ffi/crypto.rs +708 -0
  44. package/rust/ffi/hashing.rs +285 -0
  45. package/rust/ffi/http.rs +752 -0
  46. package/rust/ffi/ingress.rs +303 -0
  47. package/rust/ffi/json.rs +517 -0
  48. package/rust/ffi/jwt.rs +419 -0
  49. package/rust/ffi/metrics.rs +373 -0
  50. package/rust/ffi/mod.rs +69 -0
  51. package/rust/ffi/payload.rs +275 -0
  52. package/rust/ffi/probe.rs +60 -0
  53. package/rust/ffi/rate_limit.rs +77 -0
  54. package/rust/ffi/route.rs +85 -0
  55. package/rust/ffi/task.rs +172 -0
  56. package/rust/ffi/tests.rs +2333 -0
  57. package/rust/ffi/util.rs +152 -0
  58. package/rust/ffi/validators.rs +123 -0
  59. package/rust/http/accept.rs +606 -0
  60. package/rust/http/cookie_parser.rs +140 -0
  61. package/rust/http/etag.rs +289 -0
  62. package/rust/http/form.rs +136 -0
  63. package/rust/http/headers.rs +267 -0
  64. package/rust/http/http_date.rs +286 -0
  65. package/rust/http/http_parser.rs +252 -0
  66. package/rust/http/media_type.rs +355 -0
  67. package/rust/http/method.rs +200 -0
  68. package/rust/http/mime_lookup.rs +130 -0
  69. package/rust/http/mod.rs +32 -0
  70. package/rust/http/multipart.rs +685 -0
  71. package/rust/http/query_parser.rs +211 -0
  72. package/rust/http/url_codec.rs +296 -0
  73. package/rust/http/url_join.rs +324 -0
  74. package/rust/ingress/api.rs +355 -0
  75. package/rust/ingress/cors.rs +403 -0
  76. package/rust/ingress/ingress_constants.rs +209 -0
  77. package/rust/ingress/ip_trust.rs +312 -0
  78. package/rust/ingress/mod.rs +48 -0
  79. package/rust/ingress/native_route.rs +770 -0
  80. package/rust/ingress/options.rs +176 -0
  81. package/rust/ingress/output.rs +406 -0
  82. package/rust/ingress/packed.rs +191 -0
  83. package/rust/ingress/pipeline.rs +534 -0
  84. package/rust/ingress/proxy.rs +138 -0
  85. package/rust/ingress/rate_limit.rs +538 -0
  86. package/rust/ingress/terminal.rs +114 -0
  87. package/rust/ingress/tests.rs +567 -0
  88. package/rust/ingress/time.rs +70 -0
  89. package/rust/json/fast_schema/capture.rs +336 -0
  90. package/rust/json/fast_schema/compile.rs +556 -0
  91. package/rust/json/fast_schema/cursor.rs +368 -0
  92. package/rust/json/fast_schema/email.rs +282 -0
  93. package/rust/json/fast_schema/errors.rs +247 -0
  94. package/rust/json/fast_schema/mod.rs +57 -0
  95. package/rust/json/fast_schema/tests.rs +1143 -0
  96. package/rust/json/fast_schema/types.rs +140 -0
  97. package/rust/json/fast_schema/validate.rs +931 -0
  98. package/rust/json/json_ops.rs +121 -0
  99. package/rust/json/json_schema.rs +837 -0
  100. package/rust/json/json_ser.rs +883 -0
  101. package/rust/json/mod.rs +19 -0
  102. package/rust/json/napi_marshal.rs +318 -0
  103. package/rust/json/patch/api.rs +60 -0
  104. package/rust/json/patch/engine.rs +153 -0
  105. package/rust/json/patch/mod.rs +47 -0
  106. package/rust/json/patch/ops.rs +270 -0
  107. package/rust/json/patch/pointer.rs +49 -0
  108. package/rust/json/patch/tests.rs +499 -0
  109. package/rust/lib.rs +73 -0
  110. package/rust/metrics/api.rs +148 -0
  111. package/rust/metrics/mod.rs +21 -0
  112. package/rust/metrics/registry.rs +961 -0
  113. package/rust/panic_safety.rs +40 -0
  114. package/rust/payload/compress.rs +512 -0
  115. package/rust/payload/mod.rs +13 -0
  116. package/rust/payload/sse.rs +214 -0
  117. package/rust/payload/template.rs +253 -0
  118. package/rust/payload/websocket.rs +69 -0
  119. package/rust/payload/ws_frames.rs +354 -0
  120. package/rust/proptest_suite.rs +139 -0
  121. package/rust/selection.rs +95 -0
  122. package/rust/src/bin/native-bench.rs +495 -0
  123. package/rust/task/completion.rs +119 -0
  124. package/rust/task/mod.rs +65 -0
  125. package/rust/task/ops.rs +503 -0
  126. package/rust/task/runtime.rs +220 -0
  127. package/rust/task/tests.rs +231 -0
  128. package/rust/test_support.rs +71 -0
  129. package/rust/util/batch/api.rs +266 -0
  130. package/rust/util/batch/core.rs +473 -0
  131. package/rust/util/batch/mod.rs +23 -0
  132. package/rust/util/batch/tests.rs +445 -0
  133. package/rust/util/batch_core.rs +184 -0
  134. package/rust/util/bytes.rs +594 -0
  135. package/rust/util/mod.rs +34 -0
  136. package/rust/util/packed.rs +473 -0
  137. package/rust/util/text.rs +130 -0
  138. package/rust/util/threadpool.rs +128 -0
  139. package/rust/util/validation.rs +419 -0
  140. package/rust-toolchain.toml +17 -0
  141. package/scripts/postinstall.mjs +199 -0
  142. package/src/ingress/constants.ts +1 -1
  143. package/src/ingress/context.ts +2 -2
  144. package/src/ingress/decode/baked-result.ts +13 -13
  145. package/src/ingress/decode/fast-result.ts +17 -17
  146. package/src/ingress/errors.ts +7 -7
  147. package/src/ingress/headers/baked-templates.ts +1 -1
  148. package/src/ingress/headers/fast-templates.ts +3 -3
  149. package/src/ingress/health.ts +1 -2
  150. package/src/ingress/index.ts +1 -1
  151. package/src/ingress/native-route.ts +9 -5
  152. package/src/ingress/options.ts +1 -1
  153. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  154. package/src/ingress/packing/route-wire.ts +1 -5
  155. package/src/ingress/response/error-bodies.ts +5 -5
  156. package/src/ingress/response/terminal.ts +1 -1
  157. package/src/ingress/routes/echo.ts +4 -4
  158. package/src/ingress/routes/fallback.ts +2 -2
  159. package/src/ingress/routes/head.ts +2 -2
  160. package/src/ingress/routes/native.ts +2 -1
  161. package/src/ingress/routes/options.ts +1 -1
  162. package/src/ingress/routes/read.ts +2 -2
  163. package/src/ingress/server.ts +10 -6
  164. package/src/ingress/status.ts +7 -7
  165. package/src/integration/batch.ts +1 -1
  166. package/src/integration/index.ts +9 -9
  167. package/src/integration/pipeline.ts +6 -6
  168. package/src/integration/websocket.ts +2 -2
  169. package/src/loader/index.ts +1 -5
  170. package/src/loader/ops.ts +1 -1
  171. package/src/native/ffi/build/codecs.ts +130 -12
  172. package/src/native/ffi/build/compress.ts +22 -10
  173. package/src/native/ffi/build/instances.ts +46 -4
  174. package/src/native/ffi/build/metrics.ts +247 -0
  175. package/src/native/ffi/build/parse.ts +59 -23
  176. package/src/native/ffi/build/task.ts +79 -0
  177. package/src/native/ffi/build/util.ts +32 -1
  178. package/src/native/ffi/build.ts +4 -0
  179. package/src/native/ffi/selftest.ts +12 -3
  180. package/src/native/ffi/types.ts +151 -9
  181. package/src/native/ffi.ts +160 -15
  182. package/src/native/index.ts +1 -1
  183. package/src/native/loader.ts +55 -5
  184. package/src/native/types/instances.ts +34 -0
  185. package/src/native/types.ts +11 -0
  186. package/src/runtime/codec.ts +53 -9
  187. package/src/runtime/types.ts +7 -0
  188. package/src/rust-ffi/batch/build.ts +16 -9
  189. package/src/rust-ffi/index.ts +7 -6
  190. package/src/rust-ffi/options.ts +1 -3
  191. package/src/rust-ffi/proven.ts +3 -3
  192. package/src/rust-ffi/scalar/crypto.ts +1 -1
  193. package/src/rust-ffi/scalar/factories.ts +73 -0
  194. package/src/rust-ffi/scalar/hashing.ts +1 -1
  195. package/src/rust-ffi/scalar/http.ts +71 -27
  196. package/src/rust-ffi/scalar/index.ts +2 -0
  197. package/src/rust-ffi/scalar/interface.ts +20 -0
  198. package/src/rust-ffi/scalar/payload.ts +11 -3
  199. package/src/rust-ffi/scalar/validation.ts +121 -0
  200. package/src/rust-ffi/text.ts +25 -3
  201. package/src/selection.ts +1 -1
  202. package/src/shared/bytes.ts +24 -4
  203. package/src/shared/codec.ts +18 -3
  204. package/src/task/index.ts +24 -0
  205. package/src/task/op.ts +171 -0
  206. package/src/task/runtime.ts +568 -0
@@ -0,0 +1,503 @@
1
+ // rust/task/ops.rs — the task op dispatch table + cancellation.
2
+ //
3
+ // v1 ships ONE op (`gzip.decompress`) because it is the clearest possible
4
+ // story: castrum deliberately keeps gzip decompression native (Bun's
5
+ // `Bun.gunzipSync` has no decompression-bomb cap) and it is the op that
6
+ // measured a 28.5 ms event-loop stall — so offloading it is a real win, not a
7
+ // benchmark contrivance. New ops are one arm each; the packed arg convention
8
+ // is `[u32 op-specific header][payload]`.
9
+
10
+ use parking_lot::Mutex;
11
+ use std::collections::HashSet;
12
+
13
+ use crate::crypto::argon2::verify_password;
14
+ use crate::payload::compress::{
15
+ brotli_decompress_bytes, brotli_decompress_into, gzip_compress_bytes, gzip_decompress_bytes,
16
+ gzip_decompress_into, StreamError, DEFAULT_MAX_DECOMPRESSED,
17
+ };
18
+
19
+ use super::completion::{self, Completion};
20
+
21
+ /// `gzip.decompress` — args `[u32 maxDecompressed LE][gzip bytes]`
22
+ /// (0 = {@link DEFAULT_MAX_DECOMPRESSED}).
23
+ pub const OP_GZIP_DECOMPRESS: u32 = 1;
24
+
25
+ /// `pbkdf2.sha256` — args `[u32 rounds][u32 dkLen][u32 saltLen][salt][password]`.
26
+ /// The ideal offload shape: 10-200 ms of pure CPU, 1-64 bytes of output.
27
+ pub const OP_PBKDF2_SHA256: u32 = 2;
28
+
29
+ /// `gzip.decompress` writing directly into a caller-provided buffer
30
+ /// (zero-copy output): args `[u32 maxDecompressed LE][gzip bytes]`. The
31
+ /// completion body is the 8-byte LE written length, not the data.
32
+ pub const OP_GZIP_DECOMPRESS_INTO: u32 = 3;
33
+
34
+ /// `argon2id.verify` — args `[u32 pwLen][u32 phcLen][password][phc]`; the body
35
+ /// is ONE byte (`1` = match). The other textbook offload shape: the PHC string
36
+ /// pins the cost parameters, so this is tens to hundreds of ms of CPU for a
37
+ /// one-bit answer.
38
+ pub const OP_ARGON2_VERIFY: u32 = 4;
39
+
40
+ /// `brotli.decompress` — args `[u32 maxDecompressed LE][brotli bytes]`.
41
+ pub const OP_BROTLI_DECOMPRESS: u32 = 5;
42
+
43
+ /// `brotli.decompress` into a caller buffer (zero-copy), same args. Brotli has
44
+ /// no ISIZE trailer, so the caller guesses a capacity and retries once on
45
+ /// {@link STATUS_TOO_SMALL} — the needed-size convention covers it.
46
+ pub const OP_BROTLI_DECOMPRESS_INTO: u32 = 6;
47
+
48
+ /// `gzip.compress` — args `[u32 level][gzip bytes]`. Worth offloading even on
49
+ /// Bun: `Bun.gzipSync` measured a 7.6 ms loop stall on 24 MiB. Deliberately has
50
+ /// NO zero-copy (`_into`) form: a compressed result is small relative to its
51
+ /// input, so pre-sizing a destination from the worst-case bound would allocate
52
+ /// far more than the copy it saves. The copy path is the right shape here.
53
+ pub const OP_GZIP_COMPRESS: u32 = 7;
54
+
55
+ /// Test-only op whose body panics — pins the `catch_unwind` containment.
56
+ #[cfg(test)]
57
+ pub const OP_TEST_PANIC: u32 = 0xFFFF;
58
+
59
+ pub const STATUS_OK: u32 = 0;
60
+ pub const STATUS_ERROR: u32 = 1;
61
+ pub const STATUS_CANCELLED: u32 = 2;
62
+ /// The caller's output buffer was too small; the body carries the exact
63
+ /// required size as 8 bytes LE so the caller can retry once.
64
+ pub const STATUS_TOO_SMALL: u32 = 3;
65
+ /// The op has no zero-copy (`_into`) form; the caller falls back to the copy
66
+ /// path. Keeps an older/stale addon from breaking the caller.
67
+ pub const STATUS_UNSUPPORTED: u32 = 4;
68
+
69
+ static CANCELLED: Mutex<Option<HashSet<u64>>> = Mutex::new(None);
70
+
71
+ /// Mark a task id cancelled. Returns `true` the first time, `false` if the id
72
+ /// was already marked (or unknown — the task may have finished).
73
+ pub fn mark_cancelled(id: u64) -> bool {
74
+ CANCELLED.lock().get_or_insert_with(HashSet::new).insert(id)
75
+ }
76
+
77
+ fn is_cancelled(id: u64) -> bool {
78
+ CANCELLED.lock().as_ref().is_some_and(|s| s.contains(&id))
79
+ }
80
+
81
+ fn clear_cancelled(id: u64) {
82
+ if let Some(s) = CANCELLED.lock().as_mut() {
83
+ s.remove(&id);
84
+ }
85
+ }
86
+
87
+ /// Run one task and publish its completion. Never unwinds outward.
88
+ pub fn execute_guarded(op: u32, args: &[u8], id: u64) {
89
+ let n = header_len(op).min(args.len());
90
+ let (hdr, data) = args.split_at(n);
91
+ let panicked =
92
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| execute(op, hdr, data, id)))
93
+ .is_err();
94
+ if panicked {
95
+ clear_cancelled(id);
96
+ completion::push(Completion {
97
+ id,
98
+ status: STATUS_ERROR,
99
+ body: b"task panicked (contained)".to_vec(),
100
+ });
101
+ }
102
+ }
103
+
104
+ /// Ops whose args are ONE packed blob read out of the caller's slice. The
105
+ /// payload is read in place (zero-copy INPUT), so a large argument never gets
106
+ /// copied on the JS thread — which is what made offloading a big compression a
107
+ /// net loss (see docs/RND-CONCURRENCY.md §7b).
108
+ ///
109
+ /// # Safety
110
+ /// `data`/`data_len` must describe a readable region that stays alive and
111
+ /// unmodified until the completion for `id` is drained.
112
+ pub unsafe fn execute_slice_guarded(
113
+ op: u32,
114
+ hdr: &[u8],
115
+ data: *const u8,
116
+ data_len: usize,
117
+ id: u64,
118
+ ) {
119
+ let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
120
+ let data: &[u8] = if data.is_null() {
121
+ &[]
122
+ } else {
123
+ std::slice::from_raw_parts(data, data_len)
124
+ };
125
+ execute(op, hdr, data, id);
126
+ }))
127
+ .is_err();
128
+ if panicked {
129
+ clear_cancelled(id);
130
+ completion::push(Completion {
131
+ id,
132
+ status: STATUS_ERROR,
133
+ body: b"task panicked (contained)".to_vec(),
134
+ });
135
+ }
136
+ }
137
+
138
+ /// Bytes of op-specific header that precede the payload in a packed args blob.
139
+ /// The `_INTO` (zero-copy output) and copy forms of an op share a header.
140
+ const fn header_len(op: u32) -> usize {
141
+ match op {
142
+ OP_GZIP_DECOMPRESS
143
+ | OP_GZIP_DECOMPRESS_INTO
144
+ | OP_BROTLI_DECOMPRESS
145
+ | OP_BROTLI_DECOMPRESS_INTO
146
+ | OP_GZIP_COMPRESS => 4,
147
+ OP_ARGON2_VERIFY => 8,
148
+ OP_PBKDF2_SHA256 => 12,
149
+ _ => 0,
150
+ }
151
+ }
152
+
153
+ /// Map a streaming-codec outcome onto a task result, sharing the needed-size
154
+ /// convention: a too-small destination yields the EXACT required size as an
155
+ /// 8-byte LE body so the caller can allocate once and retry.
156
+ fn stream_result(
157
+ r: std::result::Result<usize, StreamError>,
158
+ what: &str,
159
+ ) -> std::result::Result<usize, (u32, Vec<u8>)> {
160
+ match r {
161
+ Ok(written) => Ok(written),
162
+ Err(StreamError::TooSmall { needed }) => {
163
+ Err((STATUS_TOO_SMALL, (needed as u64).to_le_bytes().to_vec()))
164
+ }
165
+ Err(StreamError::ExceedsMax(cap)) => Err((
166
+ STATUS_ERROR,
167
+ format!("{what}: exceeded the {cap}-byte cap").into_bytes(),
168
+ )),
169
+ Err(StreamError::Io(e)) => Err((STATUS_ERROR, format!("{what}: {e}").into_bytes())),
170
+ }
171
+ }
172
+
173
+ /// Read one little-endian u32 out of an op header, or `None` when short.
174
+ fn hdr_u32(hdr: &[u8], off: usize) -> Option<u32> {
175
+ let b = hdr.get(off..off + 4)?;
176
+ Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
177
+ }
178
+
179
+ /// The cap header shared by the decompress ops: `0` means the native default.
180
+ fn cap_of(hdr: &[u8]) -> Option<usize> {
181
+ let raw = hdr_u32(hdr, 0)?;
182
+ Some(if raw == 0 {
183
+ DEFAULT_MAX_DECOMPRESSED
184
+ } else {
185
+ raw as usize
186
+ })
187
+ }
188
+
189
+ /// Run an op that writes into `out` (zero-copy output). `Ok(written)`, or
190
+ /// `Err((status, body))` — a too-small buffer yields `STATUS_TOO_SMALL` with
191
+ /// the exact required size as the 8-byte body.
192
+ fn run_into(
193
+ op: u32,
194
+ hdr: &[u8],
195
+ data: &[u8],
196
+ out: &mut [u8],
197
+ ) -> std::result::Result<usize, (u32, Vec<u8>)> {
198
+ match op {
199
+ OP_GZIP_DECOMPRESS_INTO => match cap_of(hdr) {
200
+ Some(max) => stream_result(gzip_decompress_into(data, max, out), "gzip decompress"),
201
+ None => Err((STATUS_ERROR, b"gzip.decompress: header too short".to_vec())),
202
+ },
203
+ OP_BROTLI_DECOMPRESS_INTO => match cap_of(hdr) {
204
+ Some(max) => stream_result(brotli_decompress_into(data, max, out), "brotli decompress"),
205
+ None => Err((
206
+ STATUS_ERROR,
207
+ b"brotli.decompress: header too short".to_vec(),
208
+ )),
209
+ },
210
+ _ => Err((STATUS_UNSUPPORTED, Vec::new())),
211
+ }
212
+ }
213
+
214
+ /// Execute a zero-copy op into the caller's buffer and publish its completion.
215
+ /// The completion body is the 8-byte LE written length (not the data), so the
216
+ /// JS side hands back a view of the buffer it already owns — no copy at all.
217
+ ///
218
+ /// # Safety
219
+ /// `out` must be valid for writes of `out_cap` bytes and must stay alive (and
220
+ /// untouched by JS) until the completion for `id` is drained.
221
+ pub unsafe fn execute_into_guarded(op: u32, args: &[u8], id: u64, out: *mut u8, out_cap: usize) {
222
+ let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
223
+ if is_cancelled(id) {
224
+ return Err((STATUS_CANCELLED, Vec::new()));
225
+ }
226
+ if out.is_null() || out_cap == 0 {
227
+ return Err((STATUS_ERROR, b"task: null output buffer".to_vec()));
228
+ }
229
+ let slice = std::slice::from_raw_parts_mut(out, out_cap);
230
+ let n = header_len(op).min(args.len());
231
+ let (hdr, data) = args.split_at(n);
232
+ run_into(op, hdr, data, slice)
233
+ }));
234
+ clear_cancelled(id);
235
+ match outcome {
236
+ Ok(Ok(written)) => completion::push(Completion {
237
+ id,
238
+ status: STATUS_OK,
239
+ body: (written as u64).to_le_bytes().to_vec(),
240
+ }),
241
+ Ok(Err((status, body))) => completion::push(Completion { id, status, body }),
242
+ Err(_) => completion::push(Completion {
243
+ id,
244
+ status: STATUS_ERROR,
245
+ body: b"task panicked (contained)".to_vec(),
246
+ }),
247
+ }
248
+ }
249
+
250
+ fn execute(op: u32, hdr: &[u8], data: &[u8], id: u64) {
251
+ if is_cancelled(id) {
252
+ clear_cancelled(id);
253
+ completion::push(Completion {
254
+ id,
255
+ status: STATUS_CANCELLED,
256
+ body: Vec::new(),
257
+ });
258
+ return;
259
+ }
260
+ let (status, body) = match run_op(op, hdr, data) {
261
+ Ok(b) => (STATUS_OK, b),
262
+ Err(e) => (STATUS_ERROR, e.into_bytes()),
263
+ };
264
+ clear_cancelled(id);
265
+ completion::push(Completion { id, status, body });
266
+ }
267
+
268
+ fn run_op(op: u32, hdr: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
269
+ match op {
270
+ OP_GZIP_DECOMPRESS => match cap_of(hdr) {
271
+ Some(max) => gzip_decompress_bytes(data, max).map_err(|e| e.to_string()),
272
+ None => Err("gzip.decompress: header too short".to_string()),
273
+ },
274
+ OP_BROTLI_DECOMPRESS => match cap_of(hdr) {
275
+ Some(max) => brotli_decompress_bytes(data, max).map_err(|e| e.to_string()),
276
+ None => Err("brotli.decompress: header too short".to_string()),
277
+ },
278
+ OP_GZIP_COMPRESS => match hdr_u32(hdr, 0) {
279
+ Some(level) => gzip_compress_bytes(data, level.min(9)).map_err(|e| e.to_string()),
280
+ None => Err("gzip.compress: header too short".to_string()),
281
+ },
282
+ OP_ARGON2_VERIFY => {
283
+ let (Some(pw_len), Some(phc_len)) = (hdr_u32(hdr, 0), hdr_u32(hdr, 4)) else {
284
+ return Err("argon2id.verify: header too short".to_string());
285
+ };
286
+ let (pw_len, phc_len) = (pw_len as usize, phc_len as usize);
287
+ if data.len() < pw_len + phc_len {
288
+ return Err("argon2id.verify: truncated password/phc".to_string());
289
+ }
290
+ let (password, phc) = data.split_at(pw_len);
291
+ // A malformed PHC string is a NON-MATCH, never an error — the
292
+ // synchronous `rust.passwordVerify` behaves the same way.
293
+ Ok(vec![u8::from(verify_password(password, &phc[..phc_len]))])
294
+ }
295
+ OP_PBKDF2_SHA256 => {
296
+ let (Some(rounds), Some(dk_len), Some(salt_len)) =
297
+ (hdr_u32(hdr, 0), hdr_u32(hdr, 4), hdr_u32(hdr, 8))
298
+ else {
299
+ return Err("pbkdf2.sha256: header too short".to_string());
300
+ };
301
+ let salt_len = salt_len as usize;
302
+ if data.len() < salt_len {
303
+ return Err("pbkdf2.sha256: truncated salt".to_string());
304
+ }
305
+ let (salt, password) = data.split_at(salt_len);
306
+ Ok(crate::crypto::pbkdf2::pbkdf2_sha256_into(
307
+ password, salt, rounds, dk_len,
308
+ ))
309
+ }
310
+ #[cfg(test)]
311
+ OP_TEST_PANIC => panic!("intentional test panic"),
312
+ _ => Err(format!("unknown task op {op}")),
313
+ }
314
+ }
315
+
316
+ #[cfg(test)]
317
+ mod tests {
318
+ use super::*;
319
+
320
+ /// Mirror the packed-args split so tests exercise the same boundary the
321
+ /// C-ABI `submit` does (header first, payload after).
322
+ fn run_packed(op: u32, args: &[u8]) -> Result<Vec<u8>, String> {
323
+ let n = header_len(op).min(args.len());
324
+ run_op(op, &args[..n], &args[n..])
325
+ }
326
+
327
+ fn run_into_packed(
328
+ op: u32,
329
+ args: &[u8],
330
+ out: &mut [u8],
331
+ ) -> std::result::Result<usize, (u32, Vec<u8>)> {
332
+ let n = header_len(op).min(args.len());
333
+ run_into(op, &args[..n], &args[n..], out)
334
+ }
335
+
336
+ #[test]
337
+ fn run_op_round_trips_gzip() {
338
+ let payload = b"task op payload".repeat(32);
339
+ let compressed = crate::payload::compress::gzip_compress_bytes(&payload, 6).unwrap();
340
+ let mut args = Vec::with_capacity(4 + compressed.len());
341
+ args.extend_from_slice(&0u32.to_le_bytes());
342
+ args.extend_from_slice(&compressed);
343
+ assert_eq!(run_packed(OP_GZIP_DECOMPRESS, &args).unwrap(), payload);
344
+ }
345
+
346
+ #[test]
347
+ fn unknown_op_is_an_error() {
348
+ assert!(run_packed(42, &[]).is_err());
349
+ }
350
+
351
+ #[test]
352
+ fn pbkdf2_op_matches_the_known_vector() {
353
+ let mut args = Vec::new();
354
+ args.extend_from_slice(&1u32.to_le_bytes()); // rounds
355
+ args.extend_from_slice(&32u32.to_le_bytes()); // dk_len
356
+ args.extend_from_slice(&4u32.to_le_bytes()); // salt len
357
+ args.extend_from_slice(b"salt");
358
+ args.extend_from_slice(b"password");
359
+ let out = run_packed(OP_PBKDF2_SHA256, &args).unwrap();
360
+ let hex: String = out.iter().map(|b| format!("{b:02x}")).collect();
361
+ assert_eq!(
362
+ hex,
363
+ "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"
364
+ );
365
+ }
366
+
367
+ #[test]
368
+ fn gzip_into_reports_the_exact_needed_size_and_then_succeeds() {
369
+ let payload = b"zero-copy output".repeat(64);
370
+ let compressed = crate::payload::compress::gzip_compress_bytes(&payload, 6).unwrap();
371
+ let mut args = Vec::new();
372
+ args.extend_from_slice(&0u32.to_le_bytes());
373
+ args.extend_from_slice(&compressed);
374
+
375
+ // Too small → TOO_SMALL with the exact required size.
376
+ let mut tiny = [0u8; 8];
377
+ match run_into_packed(OP_GZIP_DECOMPRESS_INTO, &args, &mut tiny) {
378
+ Err((STATUS_TOO_SMALL, body)) => {
379
+ let needed = u64::from_le_bytes(body.try_into().unwrap());
380
+ assert_eq!(needed, payload.len() as u64);
381
+ }
382
+ Ok(w) => panic!("expected TooSmall, got Ok({w})"),
383
+ Err((s, _)) => panic!("expected TooSmall, got status {s}"),
384
+ }
385
+
386
+ // Exact size → writes the payload, no copy on the JS side.
387
+ let mut exact = vec![0u8; payload.len()];
388
+ assert_eq!(
389
+ run_into_packed(OP_GZIP_DECOMPRESS_INTO, &args, &mut exact).unwrap(),
390
+ payload.len()
391
+ );
392
+ assert_eq!(exact, payload);
393
+ }
394
+
395
+ #[test]
396
+ fn an_op_without_an_into_form_is_unsupported() {
397
+ let mut out = [0u8; 16];
398
+ assert!(matches!(
399
+ run_into_packed(OP_PBKDF2_SHA256, &[], &mut out),
400
+ Err((STATUS_UNSUPPORTED, _))
401
+ ));
402
+ }
403
+
404
+ #[test]
405
+ fn gzip_compress_round_trips_through_the_task_op() {
406
+ let payload = b"compress me".repeat(128);
407
+ let mut args = Vec::new();
408
+ args.extend_from_slice(&6u32.to_le_bytes());
409
+ args.extend_from_slice(&payload);
410
+
411
+ let compressed = run_packed(OP_GZIP_COMPRESS, &args).unwrap();
412
+ assert!(compressed.len() < payload.len());
413
+ assert_eq!(
414
+ crate::payload::compress::gzip_decompress_bytes(&compressed, payload.len() + 1)
415
+ .unwrap(),
416
+ payload
417
+ );
418
+ }
419
+
420
+ #[test]
421
+ fn brotli_into_reports_the_exact_needed_size_and_then_succeeds() {
422
+ let payload = b"brotli zero-copy output".repeat(64);
423
+ let compressed = crate::payload::compress::brotli_compress_bytes(&payload, 5).unwrap();
424
+ let mut args = Vec::new();
425
+ args.extend_from_slice(&0u32.to_le_bytes());
426
+ args.extend_from_slice(&compressed);
427
+
428
+ // Too small (a capacity GUESS, since brotli has no ISIZE trailer) →
429
+ // TOO_SMALL with the exact required size.
430
+ let mut tiny = [0u8; 16];
431
+ match run_into_packed(OP_BROTLI_DECOMPRESS_INTO, &args, &mut tiny) {
432
+ Err((STATUS_TOO_SMALL, body)) => {
433
+ assert_eq!(
434
+ u64::from_le_bytes(body.try_into().unwrap()),
435
+ payload.len() as u64
436
+ );
437
+ }
438
+ Ok(w) => panic!("expected TooSmall, got Ok({w})"),
439
+ Err((s, _)) => panic!("expected TooSmall, got status {s}"),
440
+ }
441
+
442
+ let mut exact = vec![0u8; payload.len()];
443
+ assert_eq!(
444
+ run_into_packed(OP_BROTLI_DECOMPRESS_INTO, &args, &mut exact).unwrap(),
445
+ payload.len()
446
+ );
447
+ assert_eq!(exact, payload);
448
+ }
449
+
450
+ #[test]
451
+ fn argon2_verify_answers_in_one_byte() {
452
+ let phc = crate::crypto::argon2::hash_password(b"hunter2", b"saltysalt", 4096, 1, 1, 32)
453
+ .expect("hash");
454
+
455
+ let mut args = Vec::new();
456
+ let pw = b"hunter2";
457
+ let phc_bytes = phc.as_bytes();
458
+ args.extend_from_slice(&(pw.len() as u32).to_le_bytes());
459
+ args.extend_from_slice(&(phc_bytes.len() as u32).to_le_bytes());
460
+ args.extend_from_slice(pw);
461
+ args.extend_from_slice(phc_bytes);
462
+
463
+ assert_eq!(run_packed(OP_ARGON2_VERIFY, &args).unwrap(), vec![1]);
464
+
465
+ // Wrong password → 0, and a malformed PHC string is a NON-MATCH, never
466
+ // an error (parity with the synchronous `rust.passwordVerify`).
467
+ let mut wrong = Vec::new();
468
+ let bad = b"nope";
469
+ wrong.extend_from_slice(&(bad.len() as u32).to_le_bytes());
470
+ wrong.extend_from_slice(&(phc_bytes.len() as u32).to_le_bytes());
471
+ wrong.extend_from_slice(bad);
472
+ wrong.extend_from_slice(phc_bytes);
473
+ assert_eq!(run_packed(OP_ARGON2_VERIFY, &wrong).unwrap(), vec![0]);
474
+
475
+ let mut malformed = Vec::new();
476
+ let junk = b"not-a-phc";
477
+ malformed.extend_from_slice(&(bad.len() as u32).to_le_bytes());
478
+ malformed.extend_from_slice(&(junk.len() as u32).to_le_bytes());
479
+ malformed.extend_from_slice(bad);
480
+ malformed.extend_from_slice(junk);
481
+ assert_eq!(run_packed(OP_ARGON2_VERIFY, &malformed).unwrap(), vec![0]);
482
+ }
483
+
484
+ #[test]
485
+ fn a_truncated_argon2_arg_header_is_an_error() {
486
+ let mut args = Vec::new();
487
+ args.extend_from_slice(&64u32.to_le_bytes());
488
+ args.extend_from_slice(&64u32.to_le_bytes());
489
+ args.extend_from_slice(b"short");
490
+ assert!(run_packed(OP_ARGON2_VERIFY, &args).is_err());
491
+ }
492
+
493
+ #[test]
494
+ fn gzip_cap_is_enforced() {
495
+ // 1 MiB of zeros compresses tiny; a 16-byte cap must reject it.
496
+ let payload = vec![0u8; 1 << 20];
497
+ let compressed = crate::payload::compress::gzip_compress_bytes(&payload, 6).unwrap();
498
+ let mut args = Vec::new();
499
+ args.extend_from_slice(&16u32.to_le_bytes());
500
+ args.extend_from_slice(&compressed);
501
+ assert!(run_packed(OP_GZIP_DECOMPRESS, &args).is_err());
502
+ }
503
+ }