castrum 0.9.7 → 0.9.8
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 +1 -1
- package/Cargo.toml +1 -1
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +501 -5
- package/dist/index.js +1648 -1325
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/index.ts +1 -0
- package/package.json +3 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +6 -0
- package/rust/ffi/crypto.rs +63 -32
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +51 -32
- package/rust/ffi/tests.rs +24 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/native_route.rs +21 -3
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +1 -1
- package/rust/task/tests.rs +30 -0
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +103 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +14 -2
- package/src/ingress/routes/common.ts +26 -10
- package/src/ingress/routes/echo.ts +50 -7
- package/src/ingress/routes/json-write.ts +21 -8
- package/src/ingress/routes/responder.ts +4 -4
- package/src/ingress/server-node.ts +33 -14
- package/src/ingress/server.ts +3 -4
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +10 -259
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +47 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +4 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/shared/buffer-pool.ts +44 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/packed/parsers.ts +64 -7
- package/src/task/runtime.ts +29 -28
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
# castrum hardening & maintainability sweep — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use
|
|
4
|
+
> `superpowers:subagent-driven-development` (recommended) or
|
|
5
|
+
> `superpowers:executing-plans` to implement this plan task-by-task. Steps use
|
|
6
|
+
> checkbox (`- [ ]`) syntax for tracking.
|
|
7
|
+
|
|
8
|
+
**Goal:** Fix the verified safety, security, and correctness defects in castrum,
|
|
9
|
+
finish/repair the in-flight performance WIP, and remove maintainability traps —
|
|
10
|
+
without changing the two ingress wire formats.
|
|
11
|
+
|
|
12
|
+
**Architecture:** Changes are grouped by risk: (P0) native handle/lifecycle
|
|
13
|
+
safety, (P1) WIP correctness + ingress security, (P2) crypto hardening +
|
|
14
|
+
maintainability. Each task is independently testable and leaves the run green.
|
|
15
|
+
No task may alter the success/error body shapes or `ratelimit-*` header names
|
|
16
|
+
(`docs/adr/0001-two-wire-formats.md`, AGENTS.md).
|
|
17
|
+
|
|
18
|
+
**Tech Stack:** TypeScript (Bun-first, Node fallback), Rust cdylib via napi-rs +
|
|
19
|
+
`bun:ffi`, Biome, `bun test`, `cargo test`.
|
|
20
|
+
|
|
21
|
+
**Spec:** This plan is its own spec — it was produced from a read-only audit of
|
|
22
|
+
the working tree at commit `adc5d8f` plus uncommitted WIP in
|
|
23
|
+
`bench/cost/ingress-cost.ts`, `src/ingress/handlers.ts`,
|
|
24
|
+
`src/ingress/packing/gather-raw-headers.ts`, `src/shared/buffer-pool.ts`.
|
|
25
|
+
Findings were verified line-by-line against source; anything unverified is
|
|
26
|
+
marked `[verify]`.
|
|
27
|
+
|
|
28
|
+
## Global Constraints
|
|
29
|
+
|
|
30
|
+
- Bun-first; never slow or complicate the Bun path for Node compatibility.
|
|
31
|
+
- One numeric layout source: `rust/ingress/output.rs` → `src/ingress/constants.ts`. Never hardcode.
|
|
32
|
+
- Do NOT unify the two ingress wire formats.
|
|
33
|
+
- Every fallible `extern "C"` export routes through `panic_guard`; needed-size
|
|
34
|
+
convention `0` = error, `w > out_cap` = exact required size.
|
|
35
|
+
- No `static mut`; cross-thread state via atomics/mutex; thread-local scratch only.
|
|
36
|
+
- New public exports need JSDoc (`bun run check:jsdoc`) and a `PROVEN_SELECTION` /
|
|
37
|
+
`selection.json` entry if they are `rust.*` ops.
|
|
38
|
+
- Docs must match code (RULES.md §7).
|
|
39
|
+
- Baseline at plan time: `bun run typecheck`, `bun run lint`, `check:clean`,
|
|
40
|
+
`check:jsdoc` (501/502) pass; 823 TS tests pass. Record `bun --version` and
|
|
41
|
+
`cargo test` count before starting.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Phase 0 — Baseline capture
|
|
46
|
+
|
|
47
|
+
### Task 0: Record the green baseline and bun version
|
|
48
|
+
|
|
49
|
+
**Files:** none (evidence only)
|
|
50
|
+
|
|
51
|
+
- [ ] **Step 1: Capture runtime + test baseline**
|
|
52
|
+
|
|
53
|
+
Run:
|
|
54
|
+
```bash
|
|
55
|
+
bun --version
|
|
56
|
+
cargo test 2>&1 | tail -20
|
|
57
|
+
```
|
|
58
|
+
Expected: all Rust tests pass. Save the pass count and bun version in the task log.
|
|
59
|
+
|
|
60
|
+
- [ ] **Step 2: Commit the plan (and any pre-existing WIP in a separate commit)**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git add docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md
|
|
64
|
+
git commit -m "docs(plan): hardening & maintainability sweep"
|
|
65
|
+
```
|
|
66
|
+
Leave the four WIP source files uncommitted here; Task 7–10 finish them.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Phase 1 — P0: native lifecycle & memory safety
|
|
71
|
+
|
|
72
|
+
### Task 1: Task-runtime doorbell use-after-free + restart-after-shutdown (CRITICAL)
|
|
73
|
+
|
|
74
|
+
**Files:**
|
|
75
|
+
- Modify: `rust/task/completion.rs` (add `clear_doorbell`)
|
|
76
|
+
- Modify: `rust/task/runtime.rs` (shutdown latch; refuse submit after shutdown)
|
|
77
|
+
- Modify: `rust/ffi/task.rs:160-166` (`castrum_task_shutdown` clears the doorbell first)
|
|
78
|
+
- Modify: `rust/task/mod.rs` (re-export)
|
|
79
|
+
- Modify: `src/task/runtime.ts:554-564` (zero the native doorbell before `close()`)
|
|
80
|
+
- Test: `rust/task/tests.rs`, `test/unit/task/runtime.test.ts`
|
|
81
|
+
|
|
82
|
+
**Interfaces:**
|
|
83
|
+
- Produces: `rust::task::completion::clear_doorbell()`; `runtime::submit` returns
|
|
84
|
+
`false` once `shutdown()` has run (until an explicit `init`).
|
|
85
|
+
|
|
86
|
+
**Why:** `push()` (`completion.rs:62-77`) calls the registered trampoline.
|
|
87
|
+
`shutdown()` closes the JS `JSCallback` but `DOORBELL` is never reset, and
|
|
88
|
+
`runtime::submit` auto-restarts the pool (`runtime.rs:199-203`), so a later
|
|
89
|
+
completion can call a freed trampoline.
|
|
90
|
+
|
|
91
|
+
- [ ] **Step 1: Add a failing JS lifecycle test**
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// test/unit/task/runtime.test.ts
|
|
95
|
+
test('shutdown is terminal: a later submit rejects, never restarts the pool', async () => {
|
|
96
|
+
const runtime = createTaskRuntime()
|
|
97
|
+
await runtime.gzipCompress(new Uint8Array([1, 2, 3]))
|
|
98
|
+
runtime.shutdown()
|
|
99
|
+
await expect(runtime.gzipCompress(new Uint8Array([1, 2, 3]))).rejects.toThrow()
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- [ ] **Step 2: Run it to confirm it currently hangs/restarts**
|
|
104
|
+
|
|
105
|
+
Run: `bun test test/unit/task/runtime.test.ts -t terminal`
|
|
106
|
+
Expected: FAIL (resolves instead of rejecting, or restarts the pool).
|
|
107
|
+
|
|
108
|
+
- [ ] **Step 3: Implement the Rust latch + doorbell clear**
|
|
109
|
+
|
|
110
|
+
In `rust/task/completion.rs` add:
|
|
111
|
+
```rust
|
|
112
|
+
/// Disable the doorbell trampoline (`0`). Called on shutdown so a late
|
|
113
|
+
/// completion can never call a freed JS callback.
|
|
114
|
+
pub fn clear_doorbell() {
|
|
115
|
+
DOORBELL.store(0, Ordering::Release);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
In `rust/task/runtime.rs` add:
|
|
119
|
+
```rust
|
|
120
|
+
use std::sync::atomic::AtomicBool;
|
|
121
|
+
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
|
|
122
|
+
|
|
123
|
+
pub fn shutdown() {
|
|
124
|
+
SHUTDOWN.store(true, Ordering::Release);
|
|
125
|
+
if let Some(p) = POOL.get() { p.shutdown(); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
pub fn submit<F>(job: F) -> bool where F: FnOnce() + Send + 'static {
|
|
129
|
+
if SHUTDOWN.load(Ordering::Acquire) { return false; }
|
|
130
|
+
let p = POOL.get_or_init(TaskPool::new);
|
|
131
|
+
if !p.running.load(Ordering::Acquire) { p.start(default_threads()); }
|
|
132
|
+
p.enqueue(Box::new(job));
|
|
133
|
+
true
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
Reset `SHUTDOWN` in `init()` (`init` is the explicit restart path).
|
|
137
|
+
In `rust/ffi/task.rs`:
|
|
138
|
+
```rust
|
|
139
|
+
#[no_mangle]
|
|
140
|
+
pub extern "C" fn castrum_task_shutdown() -> u32 {
|
|
141
|
+
task::completion::clear_doorbell(); // BEFORE stopping: no late ring
|
|
142
|
+
task::shutdown();
|
|
143
|
+
task::completion::clear();
|
|
144
|
+
0
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
Re-export `clear_doorbell` from `rust/task/mod.rs`.
|
|
148
|
+
|
|
149
|
+
- [ ] **Step 4: Update the JS runtime**
|
|
150
|
+
|
|
151
|
+
In `src/task/runtime.ts` `shutdown()`:
|
|
152
|
+
```ts
|
|
153
|
+
shutdown() {
|
|
154
|
+
for (const [id, deferred] of inflight) { ffi.taskCancel(id); deferred.reject(cancelledError()) }
|
|
155
|
+
inflight.clear()
|
|
156
|
+
ffi.taskSetDoorbell(0) // native side can no longer call the trampoline
|
|
157
|
+
ffi.taskShutdown()
|
|
158
|
+
release()
|
|
159
|
+
doorbell?.close()
|
|
160
|
+
doorbell = null
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
Make the `submit`/`submitInto`/`submitSlice` helpers treat a `false` return as
|
|
164
|
+
an error: reject with `new Error('task runtime: pool is shut down')`.
|
|
165
|
+
|
|
166
|
+
- [ ] **Step 5: Run tests**
|
|
167
|
+
|
|
168
|
+
Run: `cargo test task && bun test test/unit/task/runtime.test.ts`
|
|
169
|
+
Expected: PASS.
|
|
170
|
+
|
|
171
|
+
- [ ] **Step 6: Commit**
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
git add rust/task rust/ffi/task.rs src/task/runtime.ts test/unit/task/runtime.test.ts
|
|
175
|
+
git commit -m "fix(task): clear doorbell + latch shutdown to prevent callback use-after-free"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
### Task 2: `NativeRoute` double-free / use-after-free (HIGH)
|
|
181
|
+
|
|
182
|
+
**Files:**
|
|
183
|
+
- Modify: `src/ingress/native-route.ts:66-159`
|
|
184
|
+
- Test: `test/unit/ingress/native-route.test.ts`
|
|
185
|
+
|
|
186
|
+
**Why:** `ffiHandle` is `const`; `destroy()` frees unconditionally and is
|
|
187
|
+
documented "idempotent" but is not; `runFrame()` after destroy passes a freed
|
|
188
|
+
handle into Rust (`&*(handle …)`).
|
|
189
|
+
|
|
190
|
+
- [ ] **Step 1: Add failing tests**
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
test('destroy is idempotent and run-after-destroy throws', () => {
|
|
194
|
+
const route = createNativeRoute({ parseQuery: true })
|
|
195
|
+
route.destroy()
|
|
196
|
+
route.destroy() // must not double-free
|
|
197
|
+
expect(() => route.run('a=1', '', null)).toThrow()
|
|
198
|
+
})
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- [ ] **Step 2: Confirm failure**
|
|
202
|
+
|
|
203
|
+
Run: `bun test test/unit/ingress/native-route.test.ts`
|
|
204
|
+
Expected: FAIL (throws on second destroy / does not throw on run).
|
|
205
|
+
|
|
206
|
+
- [ ] **Step 3: Make the handle mutable and guarded**
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
let ffiHandle = bunFFI !== null ? compileFfi(bunFFI, descriptor) : 0
|
|
210
|
+
// Fall back to napi when FFI is absent OR compile failed (0 = invalid plan).
|
|
211
|
+
const napiRoute =
|
|
212
|
+
bunFFI === null || ffiHandle === 0 ? compileNapi(descriptor) : null
|
|
213
|
+
|
|
214
|
+
const ensureLive = (): void => {
|
|
215
|
+
if (ffiHandle === 0 && napiRoute === null) throw new Error('native route: destroyed')
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
In `runFrame` call `ensureLive()` first; in `destroy()`:
|
|
219
|
+
```ts
|
|
220
|
+
destroy() {
|
|
221
|
+
if (ffiHandle !== 0 && bunFFI !== null) { bunFFI.routeDestroy(ffiHandle); ffiHandle = 0 }
|
|
222
|
+
napiRoute?.destroy?.()
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
Update the JSDoc to describe the real contract (idempotent, run-after-destroy throws).
|
|
226
|
+
|
|
227
|
+
- [ ] **Step 4: Run tests**
|
|
228
|
+
|
|
229
|
+
Run: `bun test test/unit/ingress/native-route.test.ts`
|
|
230
|
+
Expected: PASS.
|
|
231
|
+
|
|
232
|
+
- [ ] **Step 5: Commit**
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
git add src/ingress/native-route.ts test/unit/ingress/native-route.test.ts
|
|
236
|
+
git commit -m "fix(native-route): idempotent destroy + guard run-after-destroy"
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
### Task 3: `MetricsRegistry` double-free (MEDIUM)
|
|
242
|
+
|
|
243
|
+
**Files:**
|
|
244
|
+
- Modify: `src/rust-ffi/scalar/factories.ts:381-383`
|
|
245
|
+
- Test: `test/unit/rust-ffi/metrics.test.ts` (add case)
|
|
246
|
+
|
|
247
|
+
- [ ] **Step 1: Add a failing idempotency test**
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
test('MetricsRegistry.destroy is idempotent', () => {
|
|
251
|
+
const m = createMetricsRegistry()
|
|
252
|
+
m.destroy()
|
|
253
|
+
expect(() => m.destroy()).not.toThrow()
|
|
254
|
+
})
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
- [ ] **Step 2: Implement the guard**
|
|
258
|
+
|
|
259
|
+
Make `handle` a `let`; in `destroy()`: `if (handle !== 0) { ffi.metricsDestroy(handle); handle = 0 }`.
|
|
260
|
+
Give `record`/`render`/`snapshot` a `if (handle === 0) throw new Error('metrics: destroyed')` guard.
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 3: Run + commit**
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
bun test test/unit/rust-ffi
|
|
266
|
+
git add src/rust-ffi/scalar/factories.ts test/unit/rust-ffi
|
|
267
|
+
git commit -m "fix(metrics): idempotent destroy + use-after-destroy guard"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
### Task 4: Missing `panic_guard` on fallible exports (MEDIUM)
|
|
273
|
+
|
|
274
|
+
**Files:**
|
|
275
|
+
- Modify: `rust/ffi/crypto.rs` (`castrum_password_verify`, `castrum_password_verify_bcrypt`)
|
|
276
|
+
- Modify: `rust/ffi/http.rs` (`*_parse_packed` ×4)
|
|
277
|
+
- Modify: `rust/ffi/hashing.rs` (`castrum_json_sum_ids`, `castrum_hex_validate_batch`, `castrum_regex_escape`)
|
|
278
|
+
- Modify: `rust/ffi/payload.rs` (`castrum_ws_accept_key`, `castrum_ws_frame_encode`, `castrum_ws_frame_decode_packed`)
|
|
279
|
+
- Modify: `rust/ffi/task.rs` (`castrum_task_submit*`)
|
|
280
|
+
- Test: existing per-module tests (`cargo test`)
|
|
281
|
+
|
|
282
|
+
**Why:** a panic through `extern "C"` aborts the process. Wrap each body in
|
|
283
|
+
`panic_guard(|| core, <safe-fallback>)` where the fallback is `0` for byte
|
|
284
|
+
counts, `1` for the `password_verify` u32 protocol (verify: confirm 0 is the
|
|
285
|
+
"fail" sentinel), and `std::ptr::null()` for cstring returns.
|
|
286
|
+
|
|
287
|
+
- [ ] **Step 1: Wrap each export** (read the exact return type first; never guess the fallback).
|
|
288
|
+
- [ ] **Step 2: `cargo test` and `cargo clippy`** — expected PASS/clean.
|
|
289
|
+
- [ ] **Step 3: `bun run build:all`** (rebuild baseline + v3; stale `.node` segfaults).
|
|
290
|
+
- [ ] **Step 4: `bun test test/unit/native`** then commit.
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
git commit -am "fix(ffi): panic_guard the remaining fallible extern \"C\" exports"
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
### Task 5: `cstring` NUL truncation on rate-limit + accept-negotiation keys (MEDIUM)
|
|
299
|
+
|
|
300
|
+
**Files:**
|
|
301
|
+
- Modify: `src/native/ffi/build/instances.ts:82-88,196-206`
|
|
302
|
+
- Test: `test/unit/native/cstring-nul.test.ts`
|
|
303
|
+
|
|
304
|
+
**Why:** `rateLimiterCheck(inner, key, …)` and
|
|
305
|
+
`acceptNegotiatorNegotiateServer` pass user-reachable strings to a `cstring` arg
|
|
306
|
+
with no `hasNul` guard (`hasNul` is already imported in this file). A NUL
|
|
307
|
+
truncates the key → distinct keys share one bucket.
|
|
308
|
+
|
|
309
|
+
- [ ] **Step 1:** Check whether a `_bytes` Rust sibling exists for each symbol. If
|
|
310
|
+
yes, route to it. If no, guard with `hasNul` and throw a clear error (fail
|
|
311
|
+
closed) rather than silently truncating.
|
|
312
|
+
- [ ] **Step 2:** Add tests asserting a NUL-containing key does not silently
|
|
313
|
+
collide with the NUL-free key.
|
|
314
|
+
- [ ] **Step 3:** `bun test test/unit/native` + commit.
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
git commit -am "fix(ffi): reject/route NUL-containing rate-limit and accept keys"
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
### Task 6: Native route fails open on `validateBody` without `requireJsonBody` (MEDIUM)
|
|
323
|
+
|
|
324
|
+
**Files:**
|
|
325
|
+
- Modify: `rust/ingress/native_route.rs:224-254`
|
|
326
|
+
- Modify: `src/ingress/native-route.ts` and/or `src/ingress/routes/native.ts`
|
|
327
|
+
- Test: `rust/ingress/native_route.rs` tests + `test/unit/ingress/native-route.test.ts`
|
|
328
|
+
|
|
329
|
+
**Why:** an absent/oversized body with `validate_body && !require_json_body`
|
|
330
|
+
leaves `error_code == 0`, so the JS handler (which only checks `!== 0`) reports
|
|
331
|
+
success and the schema is never applied.
|
|
332
|
+
|
|
333
|
+
- [ ] **Step 1: Add a failing Rust test** where `validate_body=true`,
|
|
334
|
+
`require_json_body=false`, empty frame body → expect `ERR_BODY_NOT_JSON`.
|
|
335
|
+
- [ ] **Step 2: Fail closed in `run()`**
|
|
336
|
+
|
|
337
|
+
```rust
|
|
338
|
+
if self.require_json_body || self.validate_body {
|
|
339
|
+
if !has_body || body.len() > self.max_body_bytes {
|
|
340
|
+
body_valid_json = false;
|
|
341
|
+
if self.require_json_body || self.validate_body {
|
|
342
|
+
error_code = ERR_BODY_NOT_JSON;
|
|
343
|
+
}
|
|
344
|
+
} else { /* unchanged */ }
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
- [ ] **Step 3: Make the TS handler also check `ROUTE_FLAG.OK`** rather than only
|
|
348
|
+
`errorCode !== 0` (defense in depth), or document that `errorCode` is now the
|
|
349
|
+
single source.
|
|
350
|
+
- [ ] **Step 4:** `cargo test` + `bun test test/unit/ingress` + commit.
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
git commit -am "fix(native-route): fail closed when validateBody has no body"
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Phase 2 — P1: finish the WIP correctly + ingress security
|
|
359
|
+
|
|
360
|
+
### Task 7: Unbounded per-origin header cache under wildcard CORS (MEDIUM)
|
|
361
|
+
|
|
362
|
+
**Files:**
|
|
363
|
+
- Modify: `src/ingress/handlers.ts:330-339` (comment is wrong) and
|
|
364
|
+
`src/ingress/response/baked-response.ts:100-119`
|
|
365
|
+
- Test: `test/unit/ingress/baked-response.test.ts`
|
|
366
|
+
|
|
367
|
+
**Why:** `originHeaderCache` is an unbounded `Map` keyed by attacker-supplied
|
|
368
|
+
`Origin`; wildcard CORS allows every origin → remote memory-exhaustion DoS.
|
|
369
|
+
|
|
370
|
+
- [ ] **Step 1: Add a test** asserting the cache does not grow past a fixed cap
|
|
371
|
+
when many distinct origins pass through.
|
|
372
|
+
- [ ] **Step 2:** Bound the map (e.g. `ORIGIN_HEADER_CACHE_MAX = 64`, evict
|
|
373
|
+
oldest / clear when full) or skip caching unless CORS is an exact allowlist.
|
|
374
|
+
Fix the comment at `handlers.ts:330-338`.
|
|
375
|
+
- [ ] **Step 3:** `bun test test/unit/ingress` + commit.
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
git commit -am "fix(ingress): bound the per-origin header cache (wildcard CORS DoS)"
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
### Task 8: WIP prewarm warms the wrong variant (MEDIUM)
|
|
384
|
+
|
|
385
|
+
**Files:**
|
|
386
|
+
- Modify: `src/ingress/handlers.ts:395-421`
|
|
387
|
+
- Test: `test/unit/ingress/baked-response.test.ts` (add a prewarm-hit assertion)
|
|
388
|
+
|
|
389
|
+
**Why:** the loop calls `responseHeaders(HV_CORS_SIMPLE, …)` (variant `&31 = 24`)
|
|
390
|
+
while the native success variant is `HV_JSON | HV_CORS_SIMPLE` (`&31 = 31`); the
|
|
391
|
+
cached array is unreachable. The comment also claims it fills the memoized
|
|
392
|
+
`Headers` WeakMap, but `memoizedHeaders` is never called.
|
|
393
|
+
|
|
394
|
+
- [ ] **Step 1:** Prewarm with the real success variant (and the terminal variant
|
|
395
|
+
if applicable). Call `memoizedHeaders(...)` in the prewarm, or delete the
|
|
396
|
+
claim. Verify against `rust/ingress/output.rs` when the success variant is set.
|
|
397
|
+
- [ ] **Step 2:** Add a regression test that a prewarmed `(variant, origin)` is a
|
|
398
|
+
cache hit for the success path.
|
|
399
|
+
- [ ] **Step 3:** `bun run bench:http:smoke` (wire-format guard) + commit.
|
|
400
|
+
|
|
401
|
+
```bash
|
|
402
|
+
git commit -am "fix(ingress): prewarm the actual success header variant"
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
### Task 9: WIP `prefill` multiplies memory across router routes (HIGH-impact)
|
|
408
|
+
|
|
409
|
+
**Files:**
|
|
410
|
+
- Modify: `src/ingress/handlers.ts:110-120,296-307`
|
|
411
|
+
- Modify: `src/ingress/router.ts:199` (one handler per route)
|
|
412
|
+
- Test: `test/unit/shared/buffer-pool.test.ts`, `test/unit/ingress/router.test.ts`
|
|
413
|
+
|
|
414
|
+
**Why:** `prefill: 16` eagerly allocates `outputBufferSize × 16` per handler;
|
|
415
|
+
`createIngressRouter` creates one handler per route and `outputBufferSize` may be
|
|
416
|
+
up to 64 MiB → up to 1 GiB per route.
|
|
417
|
+
|
|
418
|
+
- [ ] **Step 1:** Bound prefill by total bytes, not just count:
|
|
419
|
+
`prefill = min(PREWARM_POOL_BUFFERS, floor(MAX_PREWARM_BYTES / initialSize))`
|
|
420
|
+
with `MAX_PREWARM_BYTES` small (e.g. 4 MiB). Document the trade in
|
|
421
|
+
`docs/INGRESS.md`.
|
|
422
|
+
- [ ] **Step 2:** Unit-test `BufferPool` `prefill`: free count, `createdCount`,
|
|
423
|
+
and that `prefill` never exceeds `maxBuffers - 1`.
|
|
424
|
+
- [ ] **Step 3:** `bun run bench:http:smoke` + commit.
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
git commit -am "fix(ingress): bound pool prefill by bytes (router memory)"
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
### Task 10: Bench WIP correctness (instance UAF, timing skew)
|
|
433
|
+
|
|
434
|
+
**Files:**
|
|
435
|
+
- Modify: `bench/cost/ingress-cost.ts`, `ingress-cost-post.ts`, `ingress-js-breakdown.ts`, `ingress-refresh-breakdown.ts`, `ingress-native-decompose.ts`, `router-minimal-breakdown.ts`
|
|
436
|
+
- Modify: `src/ingress/packing/gather-raw-headers.ts:173-183` (filter `*`)
|
|
437
|
+
|
|
438
|
+
- [ ] **Step 1:** Retain every discarded `new NativeIngress(OPTIONS)` instance in
|
|
439
|
+
a module-level `const` so its `innerPtr()` target stays alive.
|
|
440
|
+
- [ ] **Step 2:** In `ingress-cost.ts`, pre-generate the request id outside the
|
|
441
|
+
`nativeCmp` timed closure; merge the two `../../src/ingress/shared` imports.
|
|
442
|
+
- [ ] **Step 3:** Make `prewarmOriginBlocks` skip `*` (match the handler filter).
|
|
443
|
+
- [ ] **Step 4:** `bun run bench:ingress-cost` must run clean; commit.
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
git commit -am "fix(bench): retain native ingress instances; de-skew nativeCmp timing"
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## Phase 3 — P2: crypto hardening
|
|
452
|
+
|
|
453
|
+
### Task 11: JWT time-claim robustness + require-`exp`
|
|
454
|
+
|
|
455
|
+
**Files:** `rust/crypto/jwt/token.rs:238-265`, `rust/crypto/jwt/api.rs`, tests.
|
|
456
|
+
|
|
457
|
+
- [ ] Reject (not skip) non-integer / out-of-range `exp`, `nbf`, `iat` — a
|
|
458
|
+
float `exp` currently verifies forever. Add a Rust test with `"exp": 1.75e9`.
|
|
459
|
+
- [ ] Add an option to require `exp` (default documented); add a TS test.
|
|
460
|
+
- [ ] Replace the hand-rolled `ct_eq` (`token.rs:116-127`) with
|
|
461
|
+
`aws_lc_rs::hmac::verify` or `subtle::ConstantTimeEq`.
|
|
462
|
+
- [ ] `cargo test && bun test` + commit.
|
|
463
|
+
|
|
464
|
+
### Task 12: AEAD batch nonce length validation
|
|
465
|
+
|
|
466
|
+
**Files:** `rust/crypto/aead.rs:29-37,192-230`, `src/rust-ffi/batch/types.ts`.
|
|
467
|
+
|
|
468
|
+
- [ ] Validate `nonce.len() == 12` in `batch_nonce`/entry points and return a
|
|
469
|
+
JS error instead of `copy_from_slice` panicking (`rust.aead.encryptBatch`).
|
|
470
|
+
- [ ] Document the "fresh base nonce per batch call" contract on the public type.
|
|
471
|
+
- [ ] Add a Rust test with a 16-byte nonce → error, not abort. Commit.
|
|
472
|
+
|
|
473
|
+
### Task 13: Crypto config validation
|
|
474
|
+
|
|
475
|
+
**Files:** `rust/crypto/{hmac_sha256,cookie_sign,csrf,jwt/api,session}.rs`,
|
|
476
|
+
`rust/crypto/bcrypt.rs:17-27`, `rust/crypto/pbkdf2.rs:22-40`,
|
|
477
|
+
`rust/crypto/argon2.rs:33-43`.
|
|
478
|
+
|
|
479
|
+
- [ ] Reject empty HMAC/session/JWT keys (session already does; extend to the rest).
|
|
480
|
+
- [ ] bcrypt `cost == 0` → error (not clamp to 4); pbkdf2 `rounds == 0` → error.
|
|
481
|
+
- [ ] Cap argon2 `m_cost`/`t_cost`/`out_len` at sane maxima.
|
|
482
|
+
- [ ] `cargo test` + commit (split into per-area commits if easier).
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## Phase 4 — P2: ingress security defaults / parser hardening
|
|
487
|
+
|
|
488
|
+
### Task 14: Echo route reflected Content-Type XSS
|
|
489
|
+
|
|
490
|
+
**Files:** `src/ingress/routes/echo.ts:53-88`.
|
|
491
|
+
|
|
492
|
+
- [ ] Do not reflect `Content-Type` verbatim; default to `application/octet-stream`
|
|
493
|
+
unless it is a safe allowlist, or always force `X-Content-Type-Options: nosniff`.
|
|
494
|
+
- [ ] Test: `POST /echo` with `Content-Type: text/html` must not return `text/html`.
|
|
495
|
+
- [ ] Commit.
|
|
496
|
+
|
|
497
|
+
### Task 15: Rate limiting with no `getIp` shares one global bucket
|
|
498
|
+
|
|
499
|
+
**Files:** `src/ingress/routes/common.ts:42-49`, `src/ingress/server.ts`,
|
|
500
|
+
`src/ingress/options.ts`.
|
|
501
|
+
|
|
502
|
+
- [ ] When rate limiting is enabled and no `getIp` is configured, log a one-time
|
|
503
|
+
warning and/or document the single-bucket behavior. Prefer a fail-loud default.
|
|
504
|
+
- [ ] Test that the warning fires (or that the chosen behavior is explicit).
|
|
505
|
+
- [ ] Commit.
|
|
506
|
+
|
|
507
|
+
### Task 16: Duplicate `x-forwarded-for` handling
|
|
508
|
+
|
|
509
|
+
**Files:** `rust/http/headers.rs:117-126`, `rust/ingress/proxy.rs`.
|
|
510
|
+
|
|
511
|
+
- [ ] Combine duplicate XFF lines in arrival order instead of keeping only the
|
|
512
|
+
last; pin the behavior and re-run proxy tests. `[verify]` against proxy semantics.
|
|
513
|
+
- [ ] Commit.
|
|
514
|
+
|
|
515
|
+
### Task 17: `cors: {}` resolves to wildcard allow-all
|
|
516
|
+
|
|
517
|
+
**Files:** `rust/ingress/cors.rs:54-80`, `src/ingress/headers/cors.ts`.
|
|
518
|
+
|
|
519
|
+
- [ ] Decide and document: an empty `cors` object should not silently allow all
|
|
520
|
+
origins. Prefer default-deny unless `allowOrigin` is set.
|
|
521
|
+
- [ ] Update tests + `docs/INGRESS.md`. Commit.
|
|
522
|
+
- [ ] **Do not change this without maintainer sign-off** — it is a behavioral default.
|
|
523
|
+
|
|
524
|
+
### Task 18: Node WS handshake protocol injection
|
|
525
|
+
|
|
526
|
+
**Files:** `src/ingress/server-node.ts:276-279`.
|
|
527
|
+
|
|
528
|
+
- [ ] Validate the negotiated subprotocol token (`^[A-Za-z0-9._-]+$`) or build the
|
|
529
|
+
101 response via `Headers` instead of string interpolation.
|
|
530
|
+
- [ ] Test with a CRLF-bearing protocol. Commit.
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## Phase 5 — P2: maintainability
|
|
535
|
+
|
|
536
|
+
### Task 19: FFI/general doc drift
|
|
537
|
+
|
|
538
|
+
**Files:** `docs/CASE_STUDY.md:61`, `docs/FFI_BUN_GUIDE.md:495`,
|
|
539
|
+
`docs/REPO_MAP.md:40,44,134,297`, `docs/ENVIRONMENT.md:13`,
|
|
540
|
+
`.agents/skills/castrum-rust-ffi/SKILL.md:109`,
|
|
541
|
+
`.agents/skills/castrum-codebase-map/SKILL.md:34`,
|
|
542
|
+
`.agents/skills/castrum-ingress-pipeline/SKILL.md:26,51-54,74`,
|
|
543
|
+
`scripts/check-clean.ts:19-25`.
|
|
544
|
+
|
|
545
|
+
- [ ] Make the counts match `check:clean` (121 total / 109 direct) and fix the
|
|
546
|
+
`nm -D | grep -c castrum_` example. Update the test counts and the zero-copy text.
|
|
547
|
+
- [ ] Extend `check:clean` to scan `.agents/skills/**` for the FFI count and to
|
|
548
|
+
catch "C-ABI symbols" as well as "castrum".
|
|
549
|
+
- [ ] Run `bun run check:clean`. Commit.
|
|
550
|
+
|
|
551
|
+
### Task 20: Dead exports, missing JSDoc, accidental duplication
|
|
552
|
+
|
|
553
|
+
**Files:** `src/ingress/packing/gather-raw-headers.ts:17-21`,
|
|
554
|
+
`src/ingress/server.ts:346`, `src/loader/ops.ts:29`, `src/ingress/shared.ts:39`,
|
|
555
|
+
`src/native/ffi/build/parse.ts:37`, `src/ingress/server-node.ts:76-77/159-163`,
|
|
556
|
+
`src/shared/packed/parsers.ts:56-80`.
|
|
557
|
+
|
|
558
|
+
- [ ] Remove/narrow confirmed-unused exports (grep first).
|
|
559
|
+
- [ ] Add the missing JSDoc on `buildParse`.
|
|
560
|
+
- [ ] De-duplicate `hasBody`, the retry-after helper, and the four lazy-addon
|
|
561
|
+
parser wrappers.
|
|
562
|
+
- [ ] `bun run check:clean && bun run check:jsdoc && bun test test/unit` + commit.
|
|
563
|
+
|
|
564
|
+
### Task 21: Extract the duplicated FFI-fallback block in `handlers.ts`
|
|
565
|
+
|
|
566
|
+
**Files:** `src/ingress/handlers.ts:506-540`.
|
|
567
|
+
|
|
568
|
+
- [ ] Extract one `handleFfiOrNapi(...)` helper used by both branches; keep the
|
|
569
|
+
failure counter + `ffiDisabled` behavior identical. `bun run bench:http:smoke` + commit.
|
|
570
|
+
|
|
571
|
+
### Task 22: Document the class exceptions to RULES.md §4
|
|
572
|
+
|
|
573
|
+
**Files:** `RULES.md`, `src/ingress/routes/responder.ts:48,66,115` (typo).
|
|
574
|
+
|
|
575
|
+
- [ ] State that `AdaptiveEstimate`, `BakedIngressResult`, `FastIngressResult` are
|
|
576
|
+
sanctioned result/utility classes; fix `IGNGEX_SECURITY_HEADERS` → `IGNEX_SECURITY_HEADERS`.
|
|
577
|
+
- [ ] Commit.
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
## Phase 6 — Final verification
|
|
582
|
+
|
|
583
|
+
### Task 23: Full gate + benchmark before/after
|
|
584
|
+
|
|
585
|
+
- [ ] `cargo test`
|
|
586
|
+
- [ ] `bun run build:all`
|
|
587
|
+
- [ ] `bun run typecheck && bun run typecheck:test`
|
|
588
|
+
- [ ] `bun run lint`
|
|
589
|
+
- [ ] `bun test test/unit test/property test/compat`
|
|
590
|
+
- [ ] `bun run test:node`
|
|
591
|
+
- [ ] `bun run check:clean && bun run check:jsdoc && bun run check:version`
|
|
592
|
+
- [ ] `bun run bench:http:smoke`
|
|
593
|
+
- [ ] `bun run bench:startup` (loader untouched, but cheap)
|
|
594
|
+
- [ ] Compare `bun run bench:ingress-cost` / `bench:ffi` before vs after; record
|
|
595
|
+
`bun --version` at start and end.
|
|
596
|
+
- [ ] Update `CHANGELOG.md`; commit.
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
## Self-Review
|
|
601
|
+
|
|
602
|
+
**Coverage vs the audit:** FFI lifecycle (1–3), unguarded exports (4), NUL
|
|
603
|
+
truncation (5), native-route fail-open (6), WIP correctness (7–10), crypto
|
|
604
|
+
(11–13), ingress security (14–18), docs/maintainability (19–22), verification
|
|
605
|
+
(23). Findings not yet scheduled and deferred deliberately: argon2 batch-salt
|
|
606
|
+
helper (API design), AEAD AAD support (API design), request-id predictability
|
|
607
|
+
(documented, not a secret), `isValidResponseStatus(101)` (defense-in-depth),
|
|
608
|
+
existing `MAX_OUTPUT_BUFFER_SIZE` export (covered by 20), oversized Rust test
|
|
609
|
+
modules (cosmetic). None are P0/P1.
|
|
610
|
+
|
|
611
|
+
**Open decisions requiring maintainer input before implementation:** Task 17
|
|
612
|
+
(`cors: {}` default), Task 15 (fail-loud on missing `getIp`), Task 11 (require
|
|
613
|
+
`exp` default). Do not silently change public defaults in those tasks.
|
|
614
|
+
|
|
615
|
+
**Placeholder scan:** P2/Phase 3–5 tasks specify files and exact behavior but not
|
|
616
|
+
full code blocks; each still names the symbol, the current bug, and the
|
|
617
|
+
verification command. Expand to full code before executing those tasks.
|
package/index.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// test/unit/features/import-contract.test.ts).
|
|
13
13
|
|
|
14
14
|
export * from './src/ingress'
|
|
15
|
+
export { getIngressBinding, type IngressBinding } from './src/native/ingress-binding'
|
|
15
16
|
// Framework-agnostic integration helpers: run the ingress pipeline as a
|
|
16
17
|
// request stage (createPipeline), RFC 6455 upgrade (createWebSocketUpgrade),
|
|
17
18
|
// and SSE framing (sseResponse). See docs/INGRESS.md.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castrum",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
|
|
6
6
|
"type": "module",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"build:all": "bun run build && bun run build:v3",
|
|
67
67
|
"build:js": "bun run build:js:bundle && bun run build:js:types",
|
|
68
68
|
"build:js:bundle": "bun build ./index.ts --target=node --format=esm --outdir=dist",
|
|
69
|
-
"build:js:types": "dts-bundle-generator -o dist/index.d.ts index.ts",
|
|
69
|
+
"build:js:types": "node scripts/ensure-dts-typescript.mjs && dts-bundle-generator -o dist/index.d.ts index.ts",
|
|
70
70
|
"bench": "bun run build && bun bench.ts",
|
|
71
71
|
"check": "bun bench.ts",
|
|
72
72
|
"test": "bun test test/unit test/property test/compat",
|
|
@@ -146,6 +146,7 @@
|
|
|
146
146
|
"fast-json-patch": "^3.1.1",
|
|
147
147
|
"mime-types": "^3.0.2",
|
|
148
148
|
"typescript": "7.0.2",
|
|
149
|
+
"typescript-dts": "npm:typescript@5.9.3",
|
|
149
150
|
"validator": "^13.15.35"
|
|
150
151
|
},
|
|
151
152
|
"overrides": {
|