castrum 0.9.4 → 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 (58) hide show
  1. package/Cargo.lock +25 -42
  2. package/Cargo.toml +21 -4
  3. package/README.md +8 -6
  4. package/castrum.darwin-arm64.node +0 -0
  5. package/castrum.darwin-x64.node +0 -0
  6. package/castrum.linux-arm64-gnu.node +0 -0
  7. package/castrum.linux-arm64-musl.node +0 -0
  8. package/castrum.linux-x64-gnu.node +0 -0
  9. package/castrum.linux-x64-musl.node +0 -0
  10. package/castrum.linux-x64-v3-gnu.node +0 -0
  11. package/castrum.win32-x64-msvc.node +0 -0
  12. package/dist/index.d.ts +164 -0
  13. package/dist/index.js +4432 -3641
  14. package/docs/API.md +1 -0
  15. package/docs/ARCHITECTURE.md +1 -1
  16. package/docs/CASE_STUDY.md +4 -2
  17. package/docs/ENVIRONMENT.md +9 -2
  18. package/docs/FFI_BUN_GUIDE.md +154 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  20. package/docs/REPO_MAP.md +16 -5
  21. package/docs/RND-CONCURRENCY.md +772 -0
  22. package/docs/bun-builtins-decision-matrix.md +4 -1
  23. package/index.ts +5 -0
  24. package/package.json +172 -163
  25. package/rust/crypto/pbkdf2.rs +9 -0
  26. package/rust/ffi/mod.rs +1 -0
  27. package/rust/ffi/task.rs +172 -0
  28. package/rust/http/form.rs +6 -2
  29. package/rust/http/query_parser.rs +53 -22
  30. package/rust/ingress/native_route.rs +131 -140
  31. package/rust/ingress/pipeline.rs +3 -3
  32. package/rust/ingress/tests.rs +5 -4
  33. package/rust/json/json_ser.rs +13 -11
  34. package/rust/lib.rs +1 -0
  35. package/rust/task/completion.rs +119 -0
  36. package/rust/task/mod.rs +65 -0
  37. package/rust/task/ops.rs +503 -0
  38. package/rust/task/runtime.rs +220 -0
  39. package/rust/task/tests.rs +231 -0
  40. package/rust/util/bytes.rs +238 -51
  41. package/src/ingress/health.ts +1 -2
  42. package/src/native/ffi/build/codecs.ts +29 -7
  43. package/src/native/ffi/build/compress.ts +8 -0
  44. package/src/native/ffi/build/instances.ts +7 -2
  45. package/src/native/ffi/build/metrics.ts +23 -0
  46. package/src/native/ffi/build/task.ts +79 -0
  47. package/src/native/ffi/build/util.ts +18 -0
  48. package/src/native/ffi/build.ts +2 -0
  49. package/src/native/ffi/selftest.ts +3 -1
  50. package/src/native/ffi/types.ts +40 -0
  51. package/src/native/ffi.ts +73 -14
  52. package/src/native/loader.ts +53 -3
  53. package/src/rust-ffi/scalar/http.ts +54 -16
  54. package/src/rust-ffi/text.ts +25 -3
  55. package/src/shared/bytes.ts +19 -0
  56. package/src/task/index.ts +24 -0
  57. package/src/task/op.ts +171 -0
  58. package/src/task/runtime.ts +568 -0
@@ -0,0 +1,119 @@
1
+ // rust/task/completion.rs — the completion ring + batched doorbell.
2
+ //
3
+ // THE performance-critical piece. A pooled native thread never returns to JS
4
+ // directly; it pushes a `Completion` here and (at most once per batch) calls a
5
+ // single thread-safe trampoline so the JS thread can drain everything in ONE
6
+ // `castrum_task_drain` call. That amortizes the ~666 ns/crossing threadsafe
7
+ // callback over a whole batch instead of paying it per task.
8
+ //
9
+ // PACKED DRAIN LAYOUT (`out`):
10
+ // [u32 count LE]
11
+ // repeat count: [u64 id LE][u32 status][u32 len][len bytes]
12
+ //
13
+ // Needed-size convention (repo-wide): when `out` is too small the EXACT
14
+ // required byte count is returned and nothing is consumed, so JS allocates
15
+ // once and retries once. `0` means "nothing pending".
16
+ //
17
+ // NOTE (2026-09-11): a pointer-based zero-copy variant was prototyped and
18
+ // REVERTED — Bun 1.4.2's `toArrayBuffer` deallocator hook segfaults (see
19
+ // docs/RND-CONCURRENCY.md §7a). Zero-copy will instead pre-allocate the
20
+ // destination in JS and have the pool thread write into it directly.
21
+
22
+ use parking_lot::Mutex;
23
+ use std::collections::VecDeque;
24
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
25
+ use std::sync::OnceLock;
26
+
27
+ /// One finished task. `status`: 0 = ok, 1 = error, 2 = cancelled. `body` is
28
+ /// the result bytes, or the UTF-8 error message when `status != 0`.
29
+ pub struct Completion {
30
+ pub id: u64,
31
+ pub status: u32,
32
+ pub body: Vec<u8>,
33
+ }
34
+
35
+ /// Completions waiting to be drained. Lazily initialised WITH capacity so a
36
+ /// burst (hundreds of tasks finishing together) does not pay repeated VecDeque
37
+ /// growth reallocations on the worker threads.
38
+ static RING: OnceLock<Mutex<VecDeque<Completion>>> = OnceLock::new();
39
+
40
+ /// One burst's worth of entries before the first realloc.
41
+ const RING_CAPACITY: usize = 256;
42
+
43
+ fn ring() -> &'static Mutex<VecDeque<Completion>> {
44
+ RING.get_or_init(|| Mutex::new(VecDeque::with_capacity(RING_CAPACITY)))
45
+ }
46
+ /// `true` while a doorbell call is outstanding, so N completions in a burst
47
+ /// wake JS once, not N times.
48
+ static ARMED: AtomicBool = AtomicBool::new(false);
49
+ /// Address of the thread-safe trampoline (`JSCallback({threadsafe:true}).ptr`).
50
+ static DOORBELL: AtomicUsize = AtomicUsize::new(0);
51
+
52
+ /// Register the doorbell trampoline. `0` disables it (poll-only mode).
53
+ pub fn set_doorbell(cb: usize) {
54
+ DOORBELL.store(cb, Ordering::Release);
55
+ }
56
+
57
+ /// Push a completion and ring the doorbell at most once per armed window.
58
+ ///
59
+ /// The ring lock is released BEFORE the trampoline is called: the JS callback
60
+ /// re-enters `drain`, which locks the ring again — holding it here would
61
+ /// deadlock the process.
62
+ pub fn push(c: Completion) {
63
+ let should_ring = {
64
+ let mut q = ring().lock();
65
+ q.push_back(c);
66
+ !ARMED.swap(true, Ordering::AcqRel)
67
+ };
68
+ if should_ring {
69
+ let p = DOORBELL.load(Ordering::Acquire);
70
+ if p != 0 {
71
+ // SAFETY: `p` is a live `JSCallback` thread-safe trampoline whose
72
+ // JS owner is held for the process lifetime by the task runtime.
73
+ let f: extern "C" fn() = unsafe { std::mem::transmute(p) };
74
+ f();
75
+ }
76
+ }
77
+ }
78
+
79
+ /// Drain every pending completion into `out`. Returns bytes written, `0` when
80
+ /// nothing is pending, or the exact required size when `out` is too small.
81
+ pub fn drain(out: &mut [u8]) -> usize {
82
+ // Disarm FIRST so a completion pushed while we drain re-arms and rings the
83
+ // bell instead of being stranded (see the module note).
84
+ ARMED.store(false, Ordering::Release);
85
+ let mut q = ring().lock();
86
+ if q.is_empty() {
87
+ return 0;
88
+ }
89
+ let mut needed = 4usize;
90
+ for c in q.iter() {
91
+ needed += 16 + c.body.len();
92
+ }
93
+ if out.len() < needed {
94
+ return needed;
95
+ }
96
+ out[0..4].copy_from_slice(&(q.len() as u32).to_le_bytes());
97
+ let mut off = 4usize;
98
+ for c in q.iter() {
99
+ out[off..off + 8].copy_from_slice(&c.id.to_le_bytes());
100
+ out[off + 8..off + 12].copy_from_slice(&c.status.to_le_bytes());
101
+ out[off + 12..off + 16].copy_from_slice(&(c.body.len() as u32).to_le_bytes());
102
+ out[off + 16..off + 16 + c.body.len()].copy_from_slice(&c.body);
103
+ off += 16 + c.body.len();
104
+ }
105
+ q.clear();
106
+ needed
107
+ }
108
+
109
+ /// Completions waiting to be drained.
110
+ pub fn pending() -> u32 {
111
+ ring().lock().len() as u32
112
+ }
113
+
114
+ /// Drop every pending completion (shutdown / test isolation). Bodies already
115
+ /// handed to JS by `drain_ptrs` are owned by JS and are NOT freed here.
116
+ pub fn clear() {
117
+ ring().lock().clear();
118
+ ARMED.store(false, Ordering::Release);
119
+ }
@@ -0,0 +1,65 @@
1
+ // rust/task — off-thread task runtime ("castrum Tasks").
2
+ //
3
+ // A Bun-first answer to synchronous FFI: any CPU-bound native op that would
4
+ // otherwise freeze the JS thread is submitted as a task, runs on a dedicated
5
+ // pool thread (NOT the rayon batch pool), and resolves a JS promise through a
6
+ // BATCHED completion doorbell. See docs/RND-CONCURRENCY.md for the design and
7
+ // the measured Bun concurrency costs.
8
+ //
9
+ // runtime.rs pool lifecycle (once-per-process) + admission
10
+ // completion.rs result ring + batched thread-safe doorbell (the fast path)
11
+ // ops.rs op dispatch table + cancellation + panic containment
12
+ // tests.rs pool/ring/cancel/panic/doorbell unit tests
13
+ //
14
+ // Safety contract (identical to the rest of the C-ABI surface): every task body
15
+ // runs under `catch_unwind`; no `Mutex` is held across the doorbell call; the
16
+ // pool owns every input buffer after `submit` returns.
17
+
18
+ pub mod completion;
19
+ pub mod ops;
20
+ pub mod runtime;
21
+
22
+ #[cfg(test)]
23
+ mod tests;
24
+
25
+ pub use completion::{drain, pending, set_doorbell};
26
+ pub use ops::{mark_cancelled, STATUS_CANCELLED, STATUS_ERROR, STATUS_OK};
27
+ pub use runtime::{init, shutdown, threads};
28
+
29
+ /// Submit one task op with packed `args`, completing under `id`.
30
+ ///
31
+ /// The pool auto-starts (default worker count) on first use, so callers do not
32
+ /// have to sequence `init` before `submit`. `args` is moved onto the worker.
33
+ pub fn submit_op(op: u32, args: Vec<u8>, id: u64) -> bool {
34
+ runtime::submit(move || ops::execute_guarded(op, &args, id))
35
+ }
36
+
37
+ /// Submit one zero-copy task op: the pool thread writes its result DIRECTLY
38
+ /// into the caller's `out` buffer, and the completion body is the 8-byte LE
39
+ /// written length instead of the data. This removes the last full-size copy and
40
+ /// its allocation from the JS thread (see docs/RND-CONCURRENCY.md §7a).
41
+ ///
42
+ /// # Safety
43
+ /// `out` must point to a buffer of at least `out_cap` bytes that stays alive
44
+ /// and is not touched by JS until the completion for `id` is drained.
45
+ pub fn submit_op_into(op: u32, args: Vec<u8>, id: u64, out: usize, out_cap: usize) -> bool {
46
+ runtime::submit(move || unsafe {
47
+ ops::execute_into_guarded(op, &args, id, out as *mut u8, out_cap)
48
+ })
49
+ }
50
+
51
+ /// Submit one task whose PAYLOAD is read IN PLACE from the caller's buffer
52
+ /// (zero-copy INPUT): only the `hdr` header bytes are copied, so a large
53
+ /// argument never crosses the boundary. Without this, offloading a large
54
+ /// op pays a full-size copy on the JS thread — measured to make
55
+ /// `gzipCompress` of 24 MiB SLOWER than the synchronous built-in it replaces
56
+ /// (18.6 ms / 11.7 ms stall vs 5.6 ms), which is the opposite of the point.
57
+ ///
58
+ /// # Safety
59
+ /// `data`/`data_len` must describe a readable region that stays alive and
60
+ /// unmodified until the completion for `id` is drained.
61
+ pub fn submit_slice_op(op: u32, hdr: Vec<u8>, data: usize, data_len: usize, id: u64) -> bool {
62
+ runtime::submit(move || unsafe {
63
+ ops::execute_slice_guarded(op, &hdr, data as *const u8, data_len, id)
64
+ })
65
+ }
@@ -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
+ }