castrum 0.9.3 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// rust/payload/ws_frames.rs — RFC 6455 WebSocket frame encode/decode.
|
|
2
|
+
//
|
|
3
|
+
// Backend-framework feature: byte-level WebSocket framing (opcode, FIN, length
|
|
4
|
+
// encoding, client masking). Bun already provides a full WebSocket server; this
|
|
5
|
+
// is the low-level codec used when you need raw frame control or a benchmark
|
|
6
|
+
// reference for the byte machinery. The default mask key is the RFC 6455 §5.7
|
|
7
|
+
// example (`0x37 0xfa 0x21 0x3d`) so encode is deterministic and testable; real
|
|
8
|
+
// clients should use a random key (pass a different one if desired).
|
|
9
|
+
//
|
|
10
|
+
// Pure-Rust core (no napi types) stays unit-testable; only the entry points
|
|
11
|
+
// use napi types.
|
|
12
|
+
|
|
13
|
+
use napi::bindgen_prelude::*;
|
|
14
|
+
use napi_derive::napi;
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MASK: [u8; 4] = [0x37, 0xfa, 0x21, 0x3d];
|
|
17
|
+
|
|
18
|
+
/// Word-at-a-time masked copy (RFC 6455 §5.3 masking): XOR `src` with the
|
|
19
|
+
/// 4-byte `mask`, writing the result into `dst` (same length). Processes 8
|
|
20
|
+
/// bytes per u64 XOR (guaranteed vectorizable), with 4-byte and byte tails.
|
|
21
|
+
/// Exactly equivalent to the per-byte `b ^ mask[i & 3]` loop.
|
|
22
|
+
#[inline]
|
|
23
|
+
fn masked_copy(dst: &mut [u8], src: &[u8], mask: [u8; 4]) {
|
|
24
|
+
debug_assert_eq!(dst.len(), src.len());
|
|
25
|
+
// Replicate the 4-byte mask across the low/high 32 bits so one u64 word
|
|
26
|
+
// can be processed per iteration (2x the u32 throughput).
|
|
27
|
+
let mask64 = {
|
|
28
|
+
let m = u32::from_le_bytes(mask) as u64;
|
|
29
|
+
m | (m << 32)
|
|
30
|
+
};
|
|
31
|
+
let mut i = 0usize;
|
|
32
|
+
while i + 8 <= src.len() {
|
|
33
|
+
let mut w = [0u8; 8];
|
|
34
|
+
w.copy_from_slice(&src[i..i + 8]);
|
|
35
|
+
let x = u64::from_le_bytes(w) ^ mask64;
|
|
36
|
+
dst[i..i + 8].copy_from_slice(&x.to_le_bytes());
|
|
37
|
+
i += 8;
|
|
38
|
+
}
|
|
39
|
+
while i + 4 <= src.len() {
|
|
40
|
+
let w = u32::from_le_bytes([src[i], src[i + 1], src[i + 2], src[i + 3]]);
|
|
41
|
+
dst[i..i + 4].copy_from_slice(&(w ^ (mask64 as u32)).to_le_bytes());
|
|
42
|
+
i += 4;
|
|
43
|
+
}
|
|
44
|
+
// Tail: `i` is a multiple of 4 here, so `i & 3 == 0` and the mask phase
|
|
45
|
+
// matches the reference byte loop exactly.
|
|
46
|
+
while i < src.len() {
|
|
47
|
+
dst[i] = src[i] ^ mask[i & 3];
|
|
48
|
+
i += 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Pure-Rust core ─────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/// Encode a single WebSocket frame per RFC 6455 §5.2.
|
|
55
|
+
pub fn encode_frame(opcode: u8, payload: &[u8], mask: bool, fin: bool) -> Vec<u8> {
|
|
56
|
+
let header_len =
|
|
57
|
+
2 + if payload.len() > 125 {
|
|
58
|
+
if payload.len() > 65_535 {
|
|
59
|
+
8
|
|
60
|
+
} else {
|
|
61
|
+
2
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
0
|
|
65
|
+
} + if mask { 4 } else { 0 };
|
|
66
|
+
let mut out = vec![0u8; header_len + payload.len()];
|
|
67
|
+
encode_frame_into(opcode, payload, mask, fin, &mut out).expect("exact-size frame buffer");
|
|
68
|
+
out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Zero-alloc core: encode a WebSocket frame directly into `out`. Returns the
|
|
72
|
+
/// bytes written, or `None` when `out` is too small. Byte-identical to
|
|
73
|
+
/// [`encode_frame`]; used by the C-ABI (`bun:ffi`) path to avoid the header
|
|
74
|
+
/// Vec + copy.
|
|
75
|
+
pub fn encode_frame_into(
|
|
76
|
+
opcode: u8,
|
|
77
|
+
payload: &[u8],
|
|
78
|
+
mask: bool,
|
|
79
|
+
fin: bool,
|
|
80
|
+
out: &mut [u8],
|
|
81
|
+
) -> Option<usize> {
|
|
82
|
+
let header_len =
|
|
83
|
+
2 + if payload.len() > 125 {
|
|
84
|
+
if payload.len() > 65_535 {
|
|
85
|
+
8
|
|
86
|
+
} else {
|
|
87
|
+
2
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
0
|
|
91
|
+
} + if mask { 4 } else { 0 };
|
|
92
|
+
let need = header_len + payload.len();
|
|
93
|
+
if out.len() < need {
|
|
94
|
+
return None;
|
|
95
|
+
}
|
|
96
|
+
let mut pos = 0usize;
|
|
97
|
+
out[pos] = (if fin { 0x80 } else { 0 }) | (opcode & 0x0f);
|
|
98
|
+
pos += 1;
|
|
99
|
+
|
|
100
|
+
let len = payload.len();
|
|
101
|
+
let mask_bit = if mask { 0x80 } else { 0 };
|
|
102
|
+
if len <= 125 {
|
|
103
|
+
out[pos] = mask_bit | len as u8;
|
|
104
|
+
pos += 1;
|
|
105
|
+
} else if len <= 65_535 {
|
|
106
|
+
out[pos] = mask_bit | 126;
|
|
107
|
+
pos += 1;
|
|
108
|
+
out[pos..pos + 2].copy_from_slice(&(len as u16).to_be_bytes());
|
|
109
|
+
pos += 2;
|
|
110
|
+
} else {
|
|
111
|
+
out[pos] = mask_bit | 127;
|
|
112
|
+
pos += 1;
|
|
113
|
+
out[pos..pos + 8].copy_from_slice(&(len as u64).to_be_bytes());
|
|
114
|
+
pos += 8;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if mask {
|
|
118
|
+
out[pos..pos + 4].copy_from_slice(&DEFAULT_MASK);
|
|
119
|
+
pos += 4;
|
|
120
|
+
masked_copy(&mut out[pos..pos + payload.len()], payload, DEFAULT_MASK);
|
|
121
|
+
pos += payload.len();
|
|
122
|
+
} else {
|
|
123
|
+
out[pos..pos + payload.len()].copy_from_slice(payload);
|
|
124
|
+
pos += payload.len();
|
|
125
|
+
}
|
|
126
|
+
Some(pos)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// A decoded WebSocket frame.
|
|
130
|
+
pub struct Frame {
|
|
131
|
+
pub fin: bool,
|
|
132
|
+
pub opcode: u8,
|
|
133
|
+
pub payload: Vec<u8>,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Decode a single WebSocket frame (RFC 6455 §5.2). Returns `None` on any
|
|
137
|
+
/// malformed / truncated input. Server→client frames are unmasked here.
|
|
138
|
+
pub fn decode_frame(data: &[u8]) -> Option<Frame> {
|
|
139
|
+
if data.len() < 2 {
|
|
140
|
+
return None;
|
|
141
|
+
}
|
|
142
|
+
let fin = data[0] & 0x80 != 0;
|
|
143
|
+
let opcode = data[0] & 0x0f;
|
|
144
|
+
let masked = data[1] & 0x80 != 0;
|
|
145
|
+
let mut len = (data[1] & 0x7f) as u64;
|
|
146
|
+
let mut pos = 2usize;
|
|
147
|
+
|
|
148
|
+
if len == 126 {
|
|
149
|
+
if data.len() < pos + 2 {
|
|
150
|
+
return None;
|
|
151
|
+
}
|
|
152
|
+
len = u16::from_be_bytes([data[pos], data[pos + 1]]) as u64;
|
|
153
|
+
pos += 2;
|
|
154
|
+
} else if len == 127 {
|
|
155
|
+
if data.len() < pos + 8 {
|
|
156
|
+
return None;
|
|
157
|
+
}
|
|
158
|
+
let mut arr = [0u8; 8];
|
|
159
|
+
arr.copy_from_slice(&data[pos..pos + 8]);
|
|
160
|
+
len = u64::from_be_bytes(arr);
|
|
161
|
+
pos += 8;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let mut mask = [0u8; 4];
|
|
165
|
+
if masked {
|
|
166
|
+
if data.len() < pos + 4 {
|
|
167
|
+
return None;
|
|
168
|
+
}
|
|
169
|
+
mask.copy_from_slice(&data[pos..pos + 4]);
|
|
170
|
+
pos += 4;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let payload = data.get(pos..pos + len as usize)?;
|
|
174
|
+
let payload = if masked {
|
|
175
|
+
let mut v = vec![0u8; payload.len()];
|
|
176
|
+
masked_copy(&mut v, payload, mask);
|
|
177
|
+
v
|
|
178
|
+
} else {
|
|
179
|
+
payload.to_vec()
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
Some(Frame {
|
|
183
|
+
fin,
|
|
184
|
+
opcode,
|
|
185
|
+
payload,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── NAPI entry points ──────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
#[napi(object)]
|
|
192
|
+
pub struct WsFrame {
|
|
193
|
+
pub fin: bool,
|
|
194
|
+
pub opcode: u32,
|
|
195
|
+
pub payload: Buffer,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// Encode a WebSocket frame. `opcode`: 1=text, 2=binary, 8=close, 9=ping,
|
|
199
|
+
/// 10=pong. `mask` applies client masking (RFC 6455 requires clients to mask).
|
|
200
|
+
#[napi]
|
|
201
|
+
pub fn ws_frame_encode(opcode: u32, payload: Uint8Array, mask: bool, fin: bool) -> Buffer {
|
|
202
|
+
Buffer::from(encode_frame(opcode as u8, payload.as_ref(), mask, fin))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// Decode a WebSocket frame. Returns `null` on malformed input.
|
|
206
|
+
#[napi]
|
|
207
|
+
pub fn ws_frame_decode(data: Uint8Array) -> Option<WsFrame> {
|
|
208
|
+
decode_frame(data.as_ref()).map(|f| WsFrame {
|
|
209
|
+
fin: f.fin,
|
|
210
|
+
opcode: f.opcode as u32,
|
|
211
|
+
payload: Buffer::from(f.payload),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// Parallel frame-encode batch: packed `[u32 count]{[u32 len][payload]}` in →
|
|
216
|
+
/// packed `[u32 count]{[u32 len][frame]}` out (same opcode/mask/fin for all).
|
|
217
|
+
#[napi]
|
|
218
|
+
pub fn ws_frame_encode_batch_packed(
|
|
219
|
+
data: Uint8Array,
|
|
220
|
+
opcode: u32,
|
|
221
|
+
mask: bool,
|
|
222
|
+
fin: bool,
|
|
223
|
+
) -> Result<Buffer> {
|
|
224
|
+
let items = crate::util::unpack(data.as_ref())?;
|
|
225
|
+
|
|
226
|
+
let mut out = Vec::with_capacity(4 + items.len() * 24);
|
|
227
|
+
out.extend_from_slice(&(items.len() as u32).to_le_bytes());
|
|
228
|
+
|
|
229
|
+
for p in items {
|
|
230
|
+
let r = encode_frame(opcode as u8, p, mask, fin);
|
|
231
|
+
out.extend_from_slice(&(r.len() as u32).to_le_bytes());
|
|
232
|
+
out.extend_from_slice(&r);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
Ok(Buffer::from(out))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Parallel frame-decode batch: packed frames in → packed `[u32 count]{
|
|
239
|
+
/// [u32 len][payload]}` out (opcodes/flags dropped; failed items → empty).
|
|
240
|
+
#[napi]
|
|
241
|
+
pub fn ws_frame_decode_batch_packed(data: Uint8Array) -> Result<Buffer> {
|
|
242
|
+
let items = crate::util::unpack(data.as_ref())?;
|
|
243
|
+
|
|
244
|
+
let mut out = Vec::with_capacity(4 + items.len() * 24);
|
|
245
|
+
out.extend_from_slice(&(items.len() as u32).to_le_bytes());
|
|
246
|
+
|
|
247
|
+
for f in items {
|
|
248
|
+
let r = decode_frame(f).map(|f| f.payload).unwrap_or_default();
|
|
249
|
+
out.extend_from_slice(&(r.len() as u32).to_le_bytes());
|
|
250
|
+
out.extend_from_slice(&r);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
Ok(Buffer::from(out))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[cfg(test)]
|
|
257
|
+
mod tests {
|
|
258
|
+
use super::*;
|
|
259
|
+
|
|
260
|
+
#[test]
|
|
261
|
+
fn encode_short_unmasked() {
|
|
262
|
+
// RFC 6455 §5.7: 0x81 0x05 0x48 0x65 0x6c 0x6c 0x6f
|
|
263
|
+
assert_eq!(encode_frame(0x1, b"Hello", false, true), b"\x81\x05Hello");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#[test]
|
|
267
|
+
fn encode_masked_uses_rfc_example_key() {
|
|
268
|
+
// RFC 6455 §5.7 example: masked "Hello" with key 0x37 0xfa 0x21 0x3d
|
|
269
|
+
let expected = b"\x81\x85\x37\xfa\x21\x3d\x7f\x9f\x4d\x51\x58";
|
|
270
|
+
assert_eq!(encode_frame(0x1, b"Hello", true, true), expected);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#[test]
|
|
274
|
+
fn decode_unmasked_roundtrip() {
|
|
275
|
+
let frame = encode_frame(0x2, b"binary payload", false, true);
|
|
276
|
+
let decoded = decode_frame(&frame).unwrap();
|
|
277
|
+
assert!(decoded.fin);
|
|
278
|
+
assert_eq!(decoded.opcode, 0x2);
|
|
279
|
+
assert_eq!(decoded.payload, b"binary payload");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#[test]
|
|
283
|
+
fn decode_masked_roundtrip() {
|
|
284
|
+
let frame = encode_frame(0x1, b"Hello", true, true);
|
|
285
|
+
let decoded = decode_frame(&frame).unwrap();
|
|
286
|
+
assert_eq!(decoded.payload, b"Hello");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#[test]
|
|
290
|
+
fn decode_medium_and_long_lengths() {
|
|
291
|
+
let medium: Vec<u8> = vec![0xAA; 300];
|
|
292
|
+
let frame = encode_frame(0x2, &medium, false, true);
|
|
293
|
+
assert_eq!(decode_frame(&frame).unwrap().payload, medium);
|
|
294
|
+
|
|
295
|
+
let long: Vec<u8> = vec![0xBB; 70_000];
|
|
296
|
+
let frame = encode_frame(0x2, &long, false, true);
|
|
297
|
+
assert_eq!(decode_frame(&frame).unwrap().payload, long);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn decode_rejects_truncated() {
|
|
302
|
+
assert!(decode_frame(b"").is_none());
|
|
303
|
+
assert!(decode_frame(b"\x81").is_none());
|
|
304
|
+
let frame = encode_frame(0x1, b"Hello", false, true);
|
|
305
|
+
assert!(decode_frame(&frame[..frame.len() - 1]).is_none());
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn fin_and_opcode_are_preserved() {
|
|
310
|
+
let frame = encode_frame(0x9, b"ping", false, false); // ping, not final
|
|
311
|
+
let decoded = decode_frame(&frame).unwrap();
|
|
312
|
+
assert!(!decoded.fin);
|
|
313
|
+
assert_eq!(decoded.opcode, 0x9);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#[test]
|
|
317
|
+
fn masked_copy_matches_byte_loop_for_all_lengths() {
|
|
318
|
+
// Property test: for every length 0..=64 plus a large payload, the
|
|
319
|
+
// word-masked copy must equal the reference byte loop.
|
|
320
|
+
let mask = DEFAULT_MASK;
|
|
321
|
+
for len in 0..=64usize {
|
|
322
|
+
let src: Vec<u8> = (0..len as u8).collect();
|
|
323
|
+
let expected: Vec<u8> = src
|
|
324
|
+
.iter()
|
|
325
|
+
.enumerate()
|
|
326
|
+
.map(|(i, &b)| b ^ mask[i & 3])
|
|
327
|
+
.collect();
|
|
328
|
+
let mut got = vec![0u8; len];
|
|
329
|
+
masked_copy(&mut got, &src, mask);
|
|
330
|
+
assert_eq!(got, expected, "len {len}");
|
|
331
|
+
}
|
|
332
|
+
let big: Vec<u8> = (0..10_000u32).map(|i| (i % 251) as u8).collect();
|
|
333
|
+
let expected: Vec<u8> = big
|
|
334
|
+
.iter()
|
|
335
|
+
.enumerate()
|
|
336
|
+
.map(|(i, &b)| b ^ mask[i & 3])
|
|
337
|
+
.collect();
|
|
338
|
+
let mut got = vec![0u8; big.len()];
|
|
339
|
+
masked_copy(&mut got, &big, mask);
|
|
340
|
+
assert_eq!(got, expected);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#[test]
|
|
344
|
+
fn encode_decode_masked_all_lengths() {
|
|
345
|
+
// Masked round-trips straddling the 4-byte word boundary (incl. empty,
|
|
346
|
+
// 1..8, the 125-byte short limit, and a >125 medium payload).
|
|
347
|
+
for len in [0usize, 1, 2, 3, 4, 5, 6, 7, 8, 125, 126, 127, 300] {
|
|
348
|
+
let payload: Vec<u8> = (0..len).map(|i| (i % 256) as u8).collect();
|
|
349
|
+
let frame = encode_frame(0x2, &payload, true, true);
|
|
350
|
+
let decoded = decode_frame(&frame).unwrap();
|
|
351
|
+
assert_eq!(decoded.payload, payload, "len {len}");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
//! Property-based tests (proptest) — adversarial inputs for the wire parsers.
|
|
2
|
+
//!
|
|
3
|
+
//! Complements the deterministic xorshift corpus in `test_support.rs` with
|
|
4
|
+
//! randomized, auto-shrinking inputs. The core property for a security-sensitive
|
|
5
|
+
//! native parser is: **given arbitrary (possibly hostile) bytes, it returns a
|
|
6
|
+
//! Result/answer WITHOUT panicking and never reads out of bounds.** Run with
|
|
7
|
+
//! `cargo test`. proptest is a dev-dependency only — it never ships.
|
|
8
|
+
|
|
9
|
+
#![cfg(test)]
|
|
10
|
+
|
|
11
|
+
use proptest::prelude::*;
|
|
12
|
+
|
|
13
|
+
use crate::http::cookie_parser::cookie_parse_packed_vec;
|
|
14
|
+
use crate::http::form::form_parse_packed_vec;
|
|
15
|
+
use crate::http::headers::HeaderRefs;
|
|
16
|
+
use crate::http::media_type::parse_media_type_core;
|
|
17
|
+
use crate::http::query_parser::query_parse_packed_vec;
|
|
18
|
+
use crate::json::fast_schema::compile;
|
|
19
|
+
use crate::util::bytes::decode_percent_at;
|
|
20
|
+
|
|
21
|
+
proptest! {
|
|
22
|
+
/// HeaderRefs::parse must never panic on arbitrary (possibly truncated,
|
|
23
|
+
/// forged length-field) packed header bytes.
|
|
24
|
+
#[test]
|
|
25
|
+
fn header_refs_parse_never_panics(
|
|
26
|
+
packed in prop::collection::vec(any::<u8>(), 0..1024),
|
|
27
|
+
is_options in any::<bool>(),
|
|
28
|
+
max_headers in 0usize..256usize,
|
|
29
|
+
) {
|
|
30
|
+
let _ = HeaderRefs::parse(&packed, is_options, max_headers);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// query_parse_packed_vec must never panic on arbitrary bytes.
|
|
34
|
+
#[test]
|
|
35
|
+
fn query_parse_never_panics(input in prop::collection::vec(any::<u8>(), 0..1024)) {
|
|
36
|
+
let _ = query_parse_packed_vec(&input);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// decode_percent_at must never panic for any index, and must never
|
|
40
|
+
/// "succeed" past the end of the input.
|
|
41
|
+
#[test]
|
|
42
|
+
fn decode_percent_at_never_panics(
|
|
43
|
+
src in prop::collection::vec(any::<u8>(), 0..512),
|
|
44
|
+
i in 0usize..600usize,
|
|
45
|
+
) {
|
|
46
|
+
if i >= src.len() {
|
|
47
|
+
prop_assert!(decode_percent_at(&src, i).is_none());
|
|
48
|
+
} else {
|
|
49
|
+
let _ = decode_percent_at(&src, i);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// The zero-DOM fast-schema validator must never panic on arbitrary bytes
|
|
54
|
+
/// (the schema shape mirrors the hot ingress schema).
|
|
55
|
+
#[test]
|
|
56
|
+
fn fast_schema_never_panics(input in prop::collection::vec(any::<u8>(), 0..2048)) {
|
|
57
|
+
let schema = serde_json::json!({
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"id": { "type": "number" },
|
|
61
|
+
"name": { "type": "string", "minLength": 1 },
|
|
62
|
+
"tags": { "type": "array", "items": { "type": "string" } },
|
|
63
|
+
},
|
|
64
|
+
"required": ["id"],
|
|
65
|
+
"additionalProperties": false
|
|
66
|
+
});
|
|
67
|
+
let fast = compile(&schema).expect("schema must compile on the fast path");
|
|
68
|
+
let _ = fast.is_valid_bytes(&input);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// cookie_parse_packed_vec must never panic on arbitrary bytes (forged
|
|
72
|
+
/// length prefixes, raw NULs, truncated pairs).
|
|
73
|
+
#[test]
|
|
74
|
+
fn cookie_parse_never_panics(input in prop::collection::vec(any::<u8>(), 0..1024)) {
|
|
75
|
+
let _ = cookie_parse_packed_vec(&input);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// form_parse_packed_vec must never panic on arbitrary bytes (raw %, +/-
|
|
79
|
+
/// handling, control chars).
|
|
80
|
+
#[test]
|
|
81
|
+
fn form_parse_never_panics(input in prop::collection::vec(any::<u8>(), 0..1024)) {
|
|
82
|
+
let _ = form_parse_packed_vec(&input);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// parse_media_type_core must never panic on arbitrary bytes.
|
|
86
|
+
#[test]
|
|
87
|
+
fn media_type_parse_never_panics(input in prop::collection::vec(any::<u8>(), 0..512)) {
|
|
88
|
+
let _ = parse_media_type_core(&input);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// RFC 3986 percent-encode → decode must be the identity for EVERY byte
|
|
92
|
+
/// (the encode set here mirrors url_codec.rs's unreserved set).
|
|
93
|
+
#[test]
|
|
94
|
+
fn percent_encode_decode_roundtrip(input in prop::collection::vec(any::<u8>(), 0..256)) {
|
|
95
|
+
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
96
|
+
let mut encoded = Vec::with_capacity(input.len() * 3);
|
|
97
|
+
for &b in &input {
|
|
98
|
+
let unreserved = matches!(b,
|
|
99
|
+
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
|
|
100
|
+
| b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | 0x27 | b'(' | b')'
|
|
101
|
+
);
|
|
102
|
+
if unreserved {
|
|
103
|
+
encoded.push(b);
|
|
104
|
+
} else {
|
|
105
|
+
encoded.push(b'%');
|
|
106
|
+
encoded.push(HEX[(b >> 4) as usize]);
|
|
107
|
+
encoded.push(HEX[(b & 0x0f) as usize]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let mut decoded = Vec::with_capacity(input.len());
|
|
111
|
+
let mut i = 0usize;
|
|
112
|
+
while i < encoded.len() {
|
|
113
|
+
if encoded[i] == b'%' {
|
|
114
|
+
prop_assert!(i + 2 < encoded.len(), "%XX truncated at {i}");
|
|
115
|
+
// decode_percent_at returns the ABSOLUTE next index (i + 3).
|
|
116
|
+
let (byte, next) = decode_percent_at(&encoded, i).unwrap_or_else(|| {
|
|
117
|
+
panic!("decode_percent_at failed at {i} in {:?}", &encoded[i..])
|
|
118
|
+
});
|
|
119
|
+
decoded.push(byte);
|
|
120
|
+
i = next;
|
|
121
|
+
} else {
|
|
122
|
+
decoded.push(encoded[i]);
|
|
123
|
+
i += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
prop_assert_eq!(decoded, input);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// decode_percent_at round-trips a manually percent-encoded byte (`%HH`).
|
|
131
|
+
#[test]
|
|
132
|
+
fn decode_percent_at_roundtrip_encoded_byte() {
|
|
133
|
+
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
134
|
+
for b in 0u16..256u16 {
|
|
135
|
+
let byte = b as u8;
|
|
136
|
+
let enc = [b'%', HEX[(byte >> 4) as usize], HEX[(byte & 0xf) as usize]];
|
|
137
|
+
assert_eq!(decode_percent_at(&enc, 0), Some((byte, 3)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//! # Selection — which ops the public API should back with native vs TS code.
|
|
2
|
+
//!
|
|
3
|
+
//! This is the SINGLE SOURCE OF TRUTH for the native-vs-TS decision, owned by
|
|
4
|
+
//! the castrum library itself (not by consumers). It is **auto-selected by the
|
|
5
|
+
//! benchmark script**: `scripts/select-native.ts` measures the addon against a
|
|
6
|
+
//! representative pure-TS implementation for every op on the target machine and
|
|
7
|
+
//! writes the winning implementation to `src/selection.json` (committed,
|
|
8
|
+
//! generated — NOT hand-maintained). This module embeds that generated data at
|
|
9
|
+
//! build time (`include_str!`) and serves it through `#[napi] op_impl`.
|
|
10
|
+
//!
|
|
11
|
+
//! Flow:
|
|
12
|
+
//! bun scripts/select-native.ts --write # auto-select → writes src/selection.json
|
|
13
|
+
//! bun run build # embeds the selected decisions into the .node
|
|
14
|
+
//! bun scripts/select-native.ts --check # CI gate: fail on decisive drift
|
|
15
|
+
//!
|
|
16
|
+
//! Consumers read `opImpl(op)` ONCE at load time and bind each operation to a
|
|
17
|
+
//! fixed implementation (TS or native) — they do NOT swap underneath at runtime.
|
|
18
|
+
|
|
19
|
+
use std::collections::HashMap;
|
|
20
|
+
use std::sync::OnceLock;
|
|
21
|
+
|
|
22
|
+
use napi_derive::napi;
|
|
23
|
+
|
|
24
|
+
/// The generated benchmark data (see `src/selection.json`). Embedded at build
|
|
25
|
+
/// time so the `.node` ships the decisions without a runtime file read.
|
|
26
|
+
const SELECTION_JSON: &str = include_str!("../src/selection.json");
|
|
27
|
+
|
|
28
|
+
/// `op name → "native" | "js"`, parsed once from the generated JSON.
|
|
29
|
+
fn selection_map() -> &'static HashMap<String, String> {
|
|
30
|
+
static MAP: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
31
|
+
MAP.get_or_init(|| {
|
|
32
|
+
let value: serde_json::Value =
|
|
33
|
+
serde_json::from_str(SELECTION_JSON).expect("selection.json must be valid JSON");
|
|
34
|
+
let mut map = HashMap::new();
|
|
35
|
+
if let Some(ops) = value.get("ops").and_then(serde_json::Value::as_object) {
|
|
36
|
+
for (op, decision) in ops {
|
|
37
|
+
if let Some(impl_name) = decision.get("impl").and_then(serde_json::Value::as_str) {
|
|
38
|
+
map.insert(op.clone(), impl_name.to_string());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
map
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// The auto-selected native-vs-TS decision for `op`.
|
|
47
|
+
///
|
|
48
|
+
/// Returns `"native"` when the public API should back `op` with the Rust addon,
|
|
49
|
+
/// `"js"` when it should run the pure-TS implementation, or `None` for unknown
|
|
50
|
+
/// ops. The data comes from `src/selection.json` — generated by
|
|
51
|
+
/// `scripts/select-native.ts --write`, never hand-written here.
|
|
52
|
+
#[napi]
|
|
53
|
+
pub fn op_impl(op: String) -> Option<String> {
|
|
54
|
+
selection_map().get(&op).cloned()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[cfg(test)]
|
|
58
|
+
mod tests {
|
|
59
|
+
use super::*;
|
|
60
|
+
|
|
61
|
+
#[test]
|
|
62
|
+
fn map_is_populated_from_generated_json() {
|
|
63
|
+
let map = selection_map();
|
|
64
|
+
assert!(!map.is_empty(), "selection.json must contain ops");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[test]
|
|
68
|
+
fn every_entry_is_native_or_js() {
|
|
69
|
+
let map = selection_map();
|
|
70
|
+
for (op, impl_name) in map {
|
|
71
|
+
assert!(
|
|
72
|
+
impl_name == "native" || impl_name == "js",
|
|
73
|
+
"op {op} has invalid impl {impl_name}"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#[test]
|
|
79
|
+
fn unknown_op_is_none() {
|
|
80
|
+
assert_eq!(op_impl("doesNotExist".to_string()), None);
|
|
81
|
+
assert_eq!(op_impl("".to_string()), None);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#[test]
|
|
85
|
+
fn well_established_decisions_hold() {
|
|
86
|
+
// These just pin the direction of the auto-selected data so a bad
|
|
87
|
+
// regeneration is caught; the values themselves come from the bench.
|
|
88
|
+
assert_eq!(op_impl("fnv1a64".to_string()).as_deref(), Some("native"));
|
|
89
|
+
assert_eq!(op_impl("validateEmail".to_string()).as_deref(), Some("js"));
|
|
90
|
+
assert_eq!(
|
|
91
|
+
op_impl("gzipDecompress".to_string()).as_deref(),
|
|
92
|
+
Some("native")
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|