castrum 0.7.0

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 (86) hide show
  1. package/Cargo.toml +73 -0
  2. package/README.md +432 -0
  3. package/bench.ts +3 -0
  4. package/castrum.linux-x64-gnu.node +0 -0
  5. package/index.ts +24 -0
  6. package/package.json +94 -0
  7. package/rust/batch.rs +195 -0
  8. package/rust/cookie_parser.rs +70 -0
  9. package/rust/cors.rs +263 -0
  10. package/rust/hashing.rs +164 -0
  11. package/rust/headers.rs +234 -0
  12. package/rust/hmac_sha256.rs +113 -0
  13. package/rust/http_parser.rs +132 -0
  14. package/rust/ingress.rs +616 -0
  15. package/rust/ingress_constants.rs +89 -0
  16. package/rust/ip_trust.rs +185 -0
  17. package/rust/json_ops.rs +66 -0
  18. package/rust/json_patch_ops.rs +25 -0
  19. package/rust/json_schema.rs +151 -0
  20. package/rust/json_ser.rs +429 -0
  21. package/rust/lib.rs +48 -0
  22. package/rust/method.rs +141 -0
  23. package/rust/mime_lookup.rs +76 -0
  24. package/rust/output.rs +238 -0
  25. package/rust/proxy.rs +90 -0
  26. package/rust/query_parser.rs +141 -0
  27. package/rust/random_token.rs +52 -0
  28. package/rust/rate_limit.rs +203 -0
  29. package/rust/terminal.rs +95 -0
  30. package/rust/unit_tests.rs +843 -0
  31. package/rust/url_codec.rs +133 -0
  32. package/rust/util.rs +537 -0
  33. package/rust/validation.rs +107 -0
  34. package/rust/websocket.rs +23 -0
  35. package/src/baseline/index.ts +12 -0
  36. package/src/baseline/tasks/cookie.ts +22 -0
  37. package/src/baseline/tasks/hashing.ts +18 -0
  38. package/src/baseline/tasks/hmac.ts +38 -0
  39. package/src/baseline/tasks/http.ts +62 -0
  40. package/src/baseline/tasks/json-patch.ts +12 -0
  41. package/src/baseline/tasks/json.ts +50 -0
  42. package/src/baseline/tasks/mime.ts +5 -0
  43. package/src/baseline/tasks/query.ts +14 -0
  44. package/src/baseline/tasks/token.ts +15 -0
  45. package/src/baseline/tasks/url.ts +11 -0
  46. package/src/baseline/tasks/validation.ts +19 -0
  47. package/src/baseline/tasks/websocket.ts +14 -0
  48. package/src/bench/assert.ts +70 -0
  49. package/src/bench/checks.ts +227 -0
  50. package/src/bench/checksum.ts +27 -0
  51. package/src/bench/comparisons.ts +70 -0
  52. package/src/bench/concurrent-worker.ts +135 -0
  53. package/src/bench/fixtures.ts +233 -0
  54. package/src/bench/index.ts +2 -0
  55. package/src/bench/measure.ts +177 -0
  56. package/src/bench/now.ts +3 -0
  57. package/src/bench/report.ts +79 -0
  58. package/src/bench/run.ts +57 -0
  59. package/src/bench/tasks/complex.ts +232 -0
  60. package/src/bench/tasks/concurrent.ts +213 -0
  61. package/src/bench/tasks/cookie.ts +44 -0
  62. package/src/bench/tasks/hashing.ts +33 -0
  63. package/src/bench/tasks/hmac.ts +36 -0
  64. package/src/bench/tasks/http.ts +50 -0
  65. package/src/bench/tasks/index.ts +52 -0
  66. package/src/bench/tasks/json-patch.ts +21 -0
  67. package/src/bench/tasks/json.ts +33 -0
  68. package/src/bench/tasks/mime.ts +21 -0
  69. package/src/bench/tasks/query.ts +44 -0
  70. package/src/bench/tasks/stress.ts +142 -0
  71. package/src/bench/tasks/token.ts +20 -0
  72. package/src/bench/tasks/url.ts +34 -0
  73. package/src/bench/tasks/validation.ts +57 -0
  74. package/src/bench/tasks/websocket.ts +21 -0
  75. package/src/bench/types.ts +60 -0
  76. package/src/data/json-rows.ts +33 -0
  77. package/src/ingress/constants.ts +56 -0
  78. package/src/ingress/fast.ts +1082 -0
  79. package/src/ingress/handlers.ts +1202 -0
  80. package/src/ingress/index.ts +457 -0
  81. package/src/ingress/packed-input.ts +59 -0
  82. package/src/native/index.ts +207 -0
  83. package/src/rust-ffi/index.ts +555 -0
  84. package/src/shared/bytes.ts +36 -0
  85. package/src/shared/json.ts +18 -0
  86. package/src/shared/packed.ts +316 -0
@@ -0,0 +1,843 @@
1
+ // rust/unit_tests.rs — Rust unit tests for the pure-Rust core modules.
2
+ //
3
+ // The napi-facing entry points are thin wrappers; these tests exercise the
4
+ // actual logic (parsers, rate limiter, IP trust, CORS, output layout, JSON
5
+ // serialization) directly. Run with `cargo test`.
6
+
7
+ #![cfg(test)]
8
+
9
+ use crate::cors::{CorsEngine, CorsOptions};
10
+ use crate::headers::HeaderRefs;
11
+ use crate::ip_trust::{resolve_client_ip, ProxyTrustMode};
12
+ use crate::method::MethodKind;
13
+ use crate::output::{
14
+ compute_header_variant, write_output_header, OUT_BODY_JSON_LEN, OUT_COOKIES_JSON_LEN,
15
+ OUT_DATA_START, OUT_ERROR_CODE, OUT_FLAGS, OUT_HEADER_VARIANT, OUT_QUERY_JSON_LEN,
16
+ OUT_RATE_LIMIT, OUT_RATE_REMAINING, OUT_RATE_RESET, OUT_RETRY_AFTER, OUT_STATUS, OUT_VERDICT,
17
+ HV_CORS_PREFLIGHT, HV_CORS_SIMPLE, HV_JSON, HV_RATE_ACTIVE, HV_RATE_LIMITED,
18
+ };
19
+ use crate::rate_limit::KeyedRateLimiter;
20
+
21
+ // ── Shared helpers ────────────────────────────────────────────────
22
+
23
+ /// Build a packed header buffer: [u16 count] { [u16 name_len][name][u32 val_len][val] }.
24
+ fn pack_headers<'a, I>(pairs: I) -> Vec<u8>
25
+ where
26
+ I: IntoIterator<Item = (&'a str, &'a str)>,
27
+ {
28
+ let pairs: Vec<(&str, &str)> = pairs.into_iter().collect();
29
+ let mut out = Vec::new();
30
+ out.extend_from_slice(&(pairs.len() as u16).to_le_bytes());
31
+ for (name, value) in pairs {
32
+ out.extend_from_slice(&(name.len() as u16).to_le_bytes());
33
+ out.extend_from_slice(name.as_bytes());
34
+ out.extend_from_slice(&(value.len() as u32).to_le_bytes());
35
+ out.extend_from_slice(value.as_bytes());
36
+ }
37
+ out
38
+ }
39
+
40
+ /// Decode a packed pairs buffer: [u32 count] { [u32 key_len][key][u32 val_len][val] }.
41
+ fn decode_packed_pairs(packed: &[u8]) -> Vec<(Vec<u8>, Vec<u8>)> {
42
+ assert!(packed.len() >= 4, "packed buffer too short");
43
+ let count = u32::from_le_bytes([packed[0], packed[1], packed[2], packed[3]]) as usize;
44
+ let mut out = Vec::with_capacity(count);
45
+ let mut pos = 4usize;
46
+ for _ in 0..count {
47
+ let key_len = u32::from_le_bytes([packed[pos], packed[pos + 1], packed[pos + 2], packed[pos + 3]]) as usize;
48
+ pos += 4;
49
+ let key = packed[pos..pos + key_len].to_vec();
50
+ pos += key_len;
51
+ let val_len = u32::from_le_bytes([packed[pos], packed[pos + 1], packed[pos + 2], packed[pos + 3]]) as usize;
52
+ pos += 4;
53
+ let val = packed[pos..pos + val_len].to_vec();
54
+ pos += val_len;
55
+ out.push((key, val));
56
+ }
57
+ out
58
+ }
59
+
60
+ // ── rate_limit ────────────────────────────────────────────────────
61
+
62
+ #[test]
63
+ fn rate_limit_allows_up_to_limit_within_window() {
64
+ let rl = KeyedRateLimiter::new(5, 1000, None);
65
+ let key = 42;
66
+
67
+ for i in 0..5 {
68
+ let o = rl.check_key(key, 100 + i as u64);
69
+ assert!(o.allowed, "request {} should be allowed", i);
70
+ let expected = (5 - i - 1) as u32;
71
+ assert_eq!(o.remaining, expected, "remaining mismatch at request {}", i);
72
+ }
73
+
74
+ let denied = rl.check_key(key, 105);
75
+ assert!(!denied.allowed, "6th request within window should be denied");
76
+ assert_eq!(denied.remaining, 0);
77
+ }
78
+
79
+ #[test]
80
+ fn rate_limit_denied_after_limit() {
81
+ let rl = KeyedRateLimiter::new(2, 60_000, None);
82
+ assert!(rl.check_key(1, 0).allowed);
83
+ assert!(rl.check_key(1, 1).allowed);
84
+ assert!(!rl.check_key(1, 2).allowed);
85
+ }
86
+
87
+ #[test]
88
+ fn rate_limit_window_advances_and_recovers() {
89
+ let rl = KeyedRateLimiter::new(2, 1000, None);
90
+ let key = 7;
91
+
92
+ assert!(rl.check_key(key, 0).allowed);
93
+ assert!(rl.check_key(key, 1).allowed);
94
+ assert!(!rl.check_key(key, 2).allowed);
95
+
96
+ // New window: previous weight decays; at least one slot must open.
97
+ let o = rl.check_key(key, 1001);
98
+ assert!(o.allowed, "request after window rollover should be allowed");
99
+ assert!(o.reset_ms > 1001, "reset should be in the future");
100
+ }
101
+
102
+ #[test]
103
+ fn rate_limit_reset_ms_is_in_future() {
104
+ let rl = KeyedRateLimiter::new(3, 5000, None);
105
+ let o = rl.check_key(9, 1000);
106
+ assert!(o.allowed);
107
+ assert!(o.reset_ms > 1000);
108
+ }
109
+
110
+ #[test]
111
+ fn rate_limit_zero_limit_denies_everything() {
112
+ let rl = KeyedRateLimiter::new(0, 1000, None);
113
+ let o = rl.check_key(1, 0);
114
+ assert!(!o.allowed);
115
+ assert_eq!(o.remaining, 0);
116
+ }
117
+
118
+ #[test]
119
+ fn rate_limit_max_limit_allows_everything() {
120
+ let rl = KeyedRateLimiter::new(u32::MAX, 1000, None);
121
+ let o = rl.check_key(1, 0);
122
+ assert!(o.allowed);
123
+ assert_eq!(o.remaining, u32::MAX);
124
+ }
125
+
126
+ #[test]
127
+ fn rate_limit_keys_are_independent() {
128
+ let rl = KeyedRateLimiter::new(1, 1000, None);
129
+ assert!(rl.check_key(1, 0).allowed);
130
+ assert!(!rl.check_key(1, 1).allowed);
131
+ assert!(rl.check_key(2, 1).allowed, "different key should have its own bucket");
132
+ }
133
+
134
+ #[test]
135
+ fn rate_limit_seed_is_stable_per_instance() {
136
+ let a = KeyedRateLimiter::new(10, 1000, None);
137
+ let b = KeyedRateLimiter::new(10, 1000, None);
138
+ // Seeds differ per instance (unique per limiter id) but are stable within one.
139
+ assert_ne!(a.seed(), b.seed());
140
+ assert_eq!(a.seed(), a.seed());
141
+ }
142
+
143
+ #[test]
144
+ fn rate_limit_shared_limiter_is_shared_by_config() {
145
+ use std::sync::Arc;
146
+
147
+ let a = crate::rate_limit::shared_limiter(100, 60_000, None);
148
+ let b = crate::rate_limit::shared_limiter(100, 60_000, None);
149
+ assert!(
150
+ Arc::ptr_eq(&a, &b),
151
+ "identical config must share one process-wide limiter"
152
+ );
153
+
154
+ let c = crate::rate_limit::shared_limiter(100, 60_000, Some(10_000));
155
+ assert!(
156
+ !Arc::ptr_eq(&a, &c),
157
+ "different max_entries must not share a limiter"
158
+ );
159
+
160
+ let d = crate::rate_limit::shared_limiter(200, 60_000, None);
161
+ assert!(
162
+ !Arc::ptr_eq(&a, &d),
163
+ "different limit must not share a limiter"
164
+ );
165
+ }
166
+
167
+ #[test]
168
+ fn rate_limit_shared_limiter_shares_budget() {
169
+ // Two instances with the same config share one bucket — a request consumed
170
+ // via one instance must count against the other (prevents route-splitting
171
+ // bypass).
172
+ let a = crate::rate_limit::shared_limiter(2, 60_000, None);
173
+ let b = crate::rate_limit::shared_limiter(2, 60_000, None);
174
+ let key = 1234u64;
175
+
176
+ assert!(a.check_key(key, 0).allowed);
177
+ assert!(b.check_key(key, 1).allowed, "shared budget consumed by a");
178
+ assert!(
179
+ !a.check_key(key, 2).allowed,
180
+ "budget must be exhausted across both instances"
181
+ );
182
+ }
183
+
184
+ // ── ip_trust ──────────────────────────────────────────────────────
185
+
186
+ #[test]
187
+ fn ip_trust_disabled_mode_is_none() {
188
+ let mode = ProxyTrustMode::from_config(false, None).unwrap();
189
+ assert!(mode.is_none());
190
+ }
191
+
192
+ #[test]
193
+ fn ip_trust_enabled_without_networks_trusts_nothing() {
194
+ // Regression: this must NOT become ProxyTrustMode::All (spoofing vector).
195
+ let mode = ProxyTrustMode::from_config(true, None).unwrap();
196
+ assert!(!mode.is_none());
197
+ assert!(!mode.is_trusted("10.0.0.1".parse().unwrap()));
198
+ assert!(!mode.is_trusted("8.8.8.8".parse().unwrap()));
199
+ }
200
+
201
+ #[test]
202
+ fn ip_trust_enabled_with_empty_networks_trusts_nothing() {
203
+ let mode = ProxyTrustMode::from_config(true, Some(vec![])).unwrap();
204
+ assert!(!mode.is_trusted("10.0.0.1".parse().unwrap()));
205
+ }
206
+
207
+ #[test]
208
+ fn ip_trust_network_list_matches() {
209
+ let mode = ProxyTrustMode::from_config(true, Some(vec!["10.0.0.0/8".to_string()])).unwrap();
210
+ assert!(mode.is_trusted("10.1.2.3".parse().unwrap()));
211
+ assert!(!mode.is_trusted("8.8.8.8".parse().unwrap()));
212
+ }
213
+
214
+ #[test]
215
+ fn ip_trust_single_ip_network() {
216
+ let mode = ProxyTrustMode::from_config(true, Some(vec!["192.168.1.1".to_string()])).unwrap();
217
+ assert!(mode.is_trusted("192.168.1.1".parse().unwrap()));
218
+ assert!(!mode.is_trusted("192.168.1.2".parse().unwrap()));
219
+ }
220
+
221
+ #[test]
222
+ fn ip_trust_invalid_network_is_error() {
223
+ let res = ProxyTrustMode::from_config(true, Some(vec!["not-an-ip".to_string()]));
224
+ assert!(res.is_err());
225
+ }
226
+
227
+ #[test]
228
+ fn ip_trust_resolves_socket_ip_when_not_trusting_proxy() {
229
+ let mode = ProxyTrustMode::from_config(false, None).unwrap();
230
+ let (resolved, peer_trusted) = resolve_client_ip(
231
+ &mode,
232
+ b"203.0.113.5",
233
+ Some(b"6.6.6.6"),
234
+ None,
235
+ );
236
+ assert!(!peer_trusted);
237
+ // Socket IP must win; XFF must be ignored.
238
+ match resolved {
239
+ crate::ip_trust::ResolvedIp::V4(o) => assert_eq!(o, [203, 0, 113, 5]),
240
+ _ => panic!("expected V4 socket IP"),
241
+ }
242
+ }
243
+
244
+ #[test]
245
+ fn ip_trust_untrusted_socket_ignores_xff() {
246
+ // Socket is NOT in the trusted networks -> XFF cannot spoof the client IP.
247
+ let mode = ProxyTrustMode::from_config(true, Some(vec!["10.0.0.0/8".to_string()])).unwrap();
248
+ let (resolved, peer_trusted) = resolve_client_ip(
249
+ &mode,
250
+ b"203.0.113.9",
251
+ Some(b"6.6.6.6"),
252
+ None,
253
+ );
254
+ assert!(!peer_trusted);
255
+ match resolved {
256
+ crate::ip_trust::ResolvedIp::V4(o) => assert_eq!(o, [203, 0, 113, 9]),
257
+ _ => panic!("expected V4 socket IP"),
258
+ }
259
+ }
260
+
261
+ #[test]
262
+ fn ip_trust_trusted_socket_uses_leftmost_untrusted_xff() {
263
+ let mode = ProxyTrustMode::from_config(true, Some(vec!["10.0.0.0/8".to_string()])).unwrap();
264
+ let (resolved, peer_trusted) = resolve_client_ip(
265
+ &mode,
266
+ b"10.0.0.5",
267
+ Some(b"8.8.8.8, 10.0.0.1, 10.0.0.2"),
268
+ None,
269
+ );
270
+ assert!(peer_trusted);
271
+ // Right-to-left: 10.0.0.2 trusted, 10.0.0.1 trusted, 8.8.8.8 NOT trusted -> client = 8.8.8.8.
272
+ match resolved {
273
+ crate::ip_trust::ResolvedIp::V4(o) => assert_eq!(o, [8, 8, 8, 8]),
274
+ _ => panic!("expected V4 8.8.8.8"),
275
+ }
276
+ }
277
+
278
+ #[test]
279
+ fn ip_trust_all_trusted_xff_returns_last_entry() {
280
+ let mode = ProxyTrustMode::from_config(true, Some(vec!["10.0.0.0/8".to_string()])).unwrap();
281
+ let (resolved, peer_trusted) = resolve_client_ip(
282
+ &mode,
283
+ b"10.0.0.5",
284
+ Some(b"10.0.0.1, 10.0.0.2"),
285
+ None,
286
+ );
287
+ assert!(peer_trusted);
288
+ match resolved {
289
+ crate::ip_trust::ResolvedIp::V4(o) => assert_eq!(o, [10, 0, 0, 1]),
290
+ _ => panic!("expected V4 10.0.0.1"),
291
+ }
292
+ }
293
+
294
+ // ── output ────────────────────────────────────────────────────────
295
+
296
+ #[test]
297
+ fn output_compute_header_variant_bits() {
298
+ assert_eq!(compute_header_variant(false, false, false, false, true), HV_JSON);
299
+ assert_eq!(
300
+ compute_header_variant(true, false, false, false, true),
301
+ HV_JSON | HV_CORS_SIMPLE
302
+ );
303
+ assert_eq!(
304
+ compute_header_variant(false, true, false, false, true),
305
+ HV_JSON | HV_CORS_PREFLIGHT
306
+ );
307
+ assert_eq!(
308
+ compute_header_variant(true, false, true, false, true),
309
+ HV_JSON | HV_CORS_SIMPLE | HV_RATE_ACTIVE
310
+ );
311
+ assert_eq!(
312
+ compute_header_variant(false, false, true, true, true),
313
+ HV_JSON | HV_RATE_ACTIVE | HV_RATE_LIMITED
314
+ );
315
+ }
316
+
317
+ #[test]
318
+ fn output_write_header_layout() {
319
+ let mut out = vec![0u8; 1024];
320
+ let written = write_output_header(
321
+ &mut out,
322
+ 0, // verdict
323
+ 3, // error_code
324
+ 200, // status
325
+ 0x1F, // flags
326
+ 100, // rate_limit
327
+ 50, // rate_remaining
328
+ 123_456, // rate_reset_ms
329
+ 5, // retry_after_ms
330
+ 10, // cookies_json_len
331
+ 20, // query_json_len
332
+ HV_JSON | HV_RATE_ACTIVE,
333
+ 30, // body_json_len
334
+ );
335
+
336
+ assert_eq!(out[OUT_VERDICT], 0);
337
+ assert_eq!(out[OUT_ERROR_CODE], 3);
338
+ assert_eq!(
339
+ u16::from_le_bytes([out[OUT_STATUS], out[OUT_STATUS + 1]]),
340
+ 200
341
+ );
342
+ assert_eq!(
343
+ u32::from_le_bytes([out[OUT_FLAGS], out[OUT_FLAGS + 1], out[OUT_FLAGS + 2], out[OUT_FLAGS + 3]]),
344
+ 0x1F
345
+ );
346
+ assert_eq!(
347
+ u32::from_le_bytes([
348
+ out[OUT_RATE_LIMIT],
349
+ out[OUT_RATE_LIMIT + 1],
350
+ out[OUT_RATE_LIMIT + 2],
351
+ out[OUT_RATE_LIMIT + 3],
352
+ ]),
353
+ 100
354
+ );
355
+ assert_eq!(
356
+ u32::from_le_bytes([
357
+ out[OUT_RATE_REMAINING],
358
+ out[OUT_RATE_REMAINING + 1],
359
+ out[OUT_RATE_REMAINING + 2],
360
+ out[OUT_RATE_REMAINING + 3],
361
+ ]),
362
+ 50
363
+ );
364
+ let reset = u64::from_le_bytes(out[OUT_RATE_RESET..OUT_RATE_RESET + 8].try_into().unwrap());
365
+ assert_eq!(reset, 123_456);
366
+ let retry = u64::from_le_bytes(out[OUT_RETRY_AFTER..OUT_RETRY_AFTER + 8].try_into().unwrap());
367
+ assert_eq!(retry, 5);
368
+ assert_eq!(
369
+ u32::from_le_bytes([
370
+ out[OUT_COOKIES_JSON_LEN],
371
+ out[OUT_COOKIES_JSON_LEN + 1],
372
+ out[OUT_COOKIES_JSON_LEN + 2],
373
+ out[OUT_COOKIES_JSON_LEN + 3],
374
+ ]),
375
+ 10
376
+ );
377
+ assert_eq!(
378
+ u32::from_le_bytes([
379
+ out[OUT_QUERY_JSON_LEN],
380
+ out[OUT_QUERY_JSON_LEN + 1],
381
+ out[OUT_QUERY_JSON_LEN + 2],
382
+ out[OUT_QUERY_JSON_LEN + 3],
383
+ ]),
384
+ 20
385
+ );
386
+ assert_eq!(out[OUT_HEADER_VARIANT], HV_JSON | HV_RATE_ACTIVE);
387
+ assert_eq!(
388
+ u32::from_le_bytes([
389
+ out[OUT_BODY_JSON_LEN],
390
+ out[OUT_BODY_JSON_LEN + 1],
391
+ out[OUT_BODY_JSON_LEN + 2],
392
+ out[OUT_BODY_JSON_LEN + 3],
393
+ ]),
394
+ 30
395
+ );
396
+
397
+ assert_eq!(written, OUT_DATA_START + 10 + 20 + 30);
398
+ }
399
+
400
+ #[test]
401
+ fn output_write_header_normalizes_invalid_status() {
402
+ let mut out = vec![0u8; 128];
403
+ let written = write_output_header(
404
+ &mut out, 1, 6, 42, 0, 0, 0, 0, 0, 0, 0, 0, 0,
405
+ );
406
+ let status = u16::from_le_bytes([out[OUT_STATUS], out[OUT_STATUS + 1]]);
407
+ assert_eq!(status, 500, "invalid status must be coerced to 500");
408
+ assert_eq!(written, OUT_DATA_START);
409
+ }
410
+
411
+ #[test]
412
+ fn output_write_header_keeps_101() {
413
+ let mut out = vec![0u8; 128];
414
+ write_output_header(&mut out, 1, 0, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0);
415
+ let status = u16::from_le_bytes([out[OUT_STATUS], out[OUT_STATUS + 1]]);
416
+ assert_eq!(status, 101);
417
+ }
418
+
419
+ // ── headers ───────────────────────────────────────────────────────
420
+
421
+ #[test]
422
+ fn headers_parse_empty_ok() {
423
+ let h = HeaderRefs::parse(b"", false, 100).unwrap();
424
+ assert!(h.origin().is_none());
425
+ assert!(h.cookie().is_none());
426
+ }
427
+
428
+ #[test]
429
+ fn headers_parse_presence_and_values() {
430
+ let packed = pack_headers([
431
+ ("Origin", "https://example.com"),
432
+ ("Cookie", "session=abc"),
433
+ ("X-Forwarded-For", "1.2.3.4"),
434
+ ]);
435
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
436
+ assert_eq!(h.origin(), Some(&b"https://example.com"[..]));
437
+ assert_eq!(h.cookie(), Some(&b"session=abc"[..]));
438
+ assert_eq!(h.xff(), Some(&b"1.2.3.4"[..]));
439
+ }
440
+
441
+ #[test]
442
+ fn headers_parse_case_insensitive_names() {
443
+ let packed = pack_headers([("ORIGIN", "https://x.io")]);
444
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
445
+ assert_eq!(h.origin(), Some(&b"https://x.io"[..]));
446
+ }
447
+
448
+ #[test]
449
+ fn headers_parse_acrm_only_for_options() {
450
+ let packed = pack_headers([
451
+ ("access-control-request-method", "POST"),
452
+ ("access-control-request-headers", "Content-Type"),
453
+ ]);
454
+ // Non-OPTIONS: ACRM/ACRH should not be captured.
455
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
456
+ assert!(!h.has_acrm());
457
+ // OPTIONS: they should be captured.
458
+ let h2 = HeaderRefs::parse(&packed, true, 100).unwrap();
459
+ assert!(h2.has_acrm());
460
+ assert!(h2.has_acrh());
461
+ }
462
+
463
+ #[test]
464
+ fn headers_parse_too_many_headers_rejected() {
465
+ let mut pairs: Vec<(String, String)> = Vec::new();
466
+ for i in 0..10 {
467
+ pairs.push(("x".to_string(), format!("v{i}")));
468
+ }
469
+
470
+ let mut packed = Vec::new();
471
+ packed.extend_from_slice(&(pairs.len() as u16).to_le_bytes());
472
+ for (name, value) in &pairs {
473
+ packed.extend_from_slice(&(name.len() as u16).to_le_bytes());
474
+ packed.extend_from_slice(name.as_bytes());
475
+ packed.extend_from_slice(&(value.len() as u32).to_le_bytes());
476
+ packed.extend_from_slice(value.as_bytes());
477
+ }
478
+
479
+ assert!(HeaderRefs::parse(&packed, false, 5).is_err());
480
+ }
481
+
482
+ #[test]
483
+ fn headers_parse_malformed_rejected() {
484
+ // Declares a name_len that runs past the end of the buffer.
485
+ let packed = [2u8, 0, 0xFF, 0xFF, b'x'];
486
+ assert!(HeaderRefs::parse(&packed, false, 100).is_err());
487
+ }
488
+
489
+ // ── method ────────────────────────────────────────────────────────
490
+
491
+ #[test]
492
+ fn method_from_bytes_ignore_case_upper() {
493
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"GET"), MethodKind::Get);
494
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"HEAD"), MethodKind::Head);
495
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"POST"), MethodKind::Post);
496
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"PUT"), MethodKind::Put);
497
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"PATCH"), MethodKind::Patch);
498
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"DELETE"), MethodKind::Delete);
499
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"OPTIONS"), MethodKind::Options);
500
+ }
501
+
502
+ #[test]
503
+ fn method_from_bytes_ignore_case_lower() {
504
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"get"), MethodKind::Get);
505
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"post"), MethodKind::Post);
506
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"options"), MethodKind::Options);
507
+ }
508
+
509
+ #[test]
510
+ fn method_from_bytes_ignore_case_unknown() {
511
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"FETCH"), MethodKind::Other);
512
+ assert_eq!(MethodKind::from_bytes_ignore_case(b""), MethodKind::Other);
513
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"GETX"), MethodKind::Other);
514
+ assert_eq!(MethodKind::from_bytes_ignore_case(b"POSTING"), MethodKind::Other);
515
+ }
516
+
517
+ #[test]
518
+ fn method_from_str_upper() {
519
+ assert_eq!(MethodKind::from_str("GET"), MethodKind::Get);
520
+ assert_eq!(MethodKind::from_str("DELETE"), MethodKind::Delete);
521
+ assert_eq!(MethodKind::from_str("WEIRD"), MethodKind::Other);
522
+ }
523
+
524
+ #[test]
525
+ fn method_bits_are_distinct() {
526
+ let bits: std::collections::HashSet<u16> =
527
+ [MethodKind::Get, MethodKind::Head, MethodKind::Post, MethodKind::Put, MethodKind::Patch, MethodKind::Delete, MethodKind::Options]
528
+ .iter()
529
+ .map(|m| m.bit())
530
+ .collect();
531
+ assert_eq!(bits.len(), 7, "each method must have a unique bit");
532
+ }
533
+
534
+ // ── cors ──────────────────────────────────────────────────────────
535
+
536
+ fn cors_options(origin: Vec<String>, methods: Vec<String>, headers: Vec<String>, creds: bool) -> CorsOptions {
537
+ CorsOptions {
538
+ allow_origin: Some(origin),
539
+ allow_methods: Some(methods),
540
+ allow_headers: Some(headers),
541
+ expose_headers: None,
542
+ allow_credentials: Some(creds),
543
+ max_age: None,
544
+ }
545
+ }
546
+
547
+ #[test]
548
+ fn cors_wildcard_with_credentials_rejected() {
549
+ let opts = cors_options(vec!["*".to_string()], vec![], vec![], true);
550
+ assert!(CorsEngine::from_options(Some(opts)).is_err());
551
+ }
552
+
553
+ #[test]
554
+ fn cors_wildcard_without_credentials_ok() {
555
+ let opts = cors_options(vec!["*".to_string()], vec![], vec![], false);
556
+ let engine = CorsEngine::from_options(Some(opts)).unwrap();
557
+ let packed = pack_headers([("origin", "https://anywhere.com")]);
558
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
559
+ let ev = engine.evaluate(MethodKind::Get, &h);
560
+ assert!(ev.allowed);
561
+ assert!(!ev.preflight);
562
+ }
563
+
564
+ #[test]
565
+ fn cors_allowlist_matching_origin() {
566
+ let opts = cors_options(
567
+ vec!["https://app.example.com".to_string()],
568
+ vec!["GET".to_string(), "POST".to_string()],
569
+ vec![],
570
+ true,
571
+ );
572
+ let engine = CorsEngine::from_options(Some(opts)).unwrap();
573
+ let packed = pack_headers([("origin", "https://app.example.com")]);
574
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
575
+ let ev = engine.evaluate(MethodKind::Get, &h);
576
+ assert!(ev.allowed);
577
+ }
578
+
579
+ #[test]
580
+ fn cors_allowlist_non_matching_origin() {
581
+ let opts = cors_options(
582
+ vec!["https://app.example.com".to_string()],
583
+ vec!["GET".to_string()],
584
+ vec![],
585
+ true,
586
+ );
587
+ let engine = CorsEngine::from_options(Some(opts)).unwrap();
588
+ let packed = pack_headers([("origin", "https://evil.example.net")]);
589
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
590
+ let ev = engine.evaluate(MethodKind::Get, &h);
591
+ assert!(!ev.allowed);
592
+ }
593
+
594
+ #[test]
595
+ fn cors_no_origin_never_allowed() {
596
+ let opts = cors_options(vec!["*".to_string()], vec![], vec![], false);
597
+ let engine = CorsEngine::from_options(Some(opts)).unwrap();
598
+ let packed = pack_headers([]);
599
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
600
+ let ev = engine.evaluate(MethodKind::Get, &h);
601
+ assert!(!ev.allowed);
602
+ }
603
+
604
+ #[test]
605
+ fn cors_preflight_allowed() {
606
+ let opts = cors_options(
607
+ vec!["https://app.example.com".to_string()],
608
+ vec!["POST".to_string()],
609
+ vec!["Content-Type".to_string()],
610
+ true,
611
+ );
612
+ let engine = CorsEngine::from_options(Some(opts)).unwrap();
613
+ let packed = pack_headers([
614
+ ("origin", "https://app.example.com"),
615
+ ("access-control-request-method", "POST"),
616
+ ("access-control-request-headers", "Content-Type"),
617
+ ]);
618
+ let h = HeaderRefs::parse(&packed, true, 100).unwrap();
619
+ let ev = engine.evaluate(MethodKind::Options, &h);
620
+ assert!(ev.preflight);
621
+ assert!(ev.allowed);
622
+ }
623
+
624
+ #[test]
625
+ fn cors_preflight_disallowed_method() {
626
+ let opts = cors_options(
627
+ vec!["https://app.example.com".to_string()],
628
+ vec!["GET".to_string()],
629
+ vec![],
630
+ true,
631
+ );
632
+ let engine = CorsEngine::from_options(Some(opts)).unwrap();
633
+ let packed = pack_headers([
634
+ ("origin", "https://app.example.com"),
635
+ ("access-control-request-method", "DELETE"),
636
+ ]);
637
+ let h = HeaderRefs::parse(&packed, true, 100).unwrap();
638
+ let ev = engine.evaluate(MethodKind::Options, &h);
639
+ assert!(ev.preflight);
640
+ assert!(!ev.allowed);
641
+ }
642
+
643
+ #[test]
644
+ fn cors_preflight_disallowed_header() {
645
+ let opts = cors_options(
646
+ vec!["https://app.example.com".to_string()],
647
+ vec!["POST".to_string()],
648
+ vec!["Content-Type".to_string()],
649
+ true,
650
+ );
651
+ let engine = CorsEngine::from_options(Some(opts)).unwrap();
652
+ let packed = pack_headers([
653
+ ("origin", "https://app.example.com"),
654
+ ("access-control-request-method", "POST"),
655
+ ("access-control-request-headers", "X-Secret-Token"),
656
+ ]);
657
+ let h = HeaderRefs::parse(&packed, true, 100).unwrap();
658
+ let ev = engine.evaluate(MethodKind::Options, &h);
659
+ assert!(ev.preflight);
660
+ assert!(!ev.allowed);
661
+ }
662
+
663
+ // ── query_parser ──────────────────────────────────────────────────
664
+
665
+ #[test]
666
+ fn query_parse_basic_pairs() {
667
+ let packed = crate::query_parser::query_parse_packed_vec(b"a=1&b=2").unwrap();
668
+ let pairs = decode_packed_pairs(&packed);
669
+ assert_eq!(pairs.len(), 2);
670
+ assert_eq!(pairs[0], (b"a".to_vec(), b"1".to_vec()));
671
+ assert_eq!(pairs[1], (b"b".to_vec(), b"2".to_vec()));
672
+ }
673
+
674
+ #[test]
675
+ fn query_parse_percent_and_plus_decoding() {
676
+ let packed = crate::query_parser::query_parse_packed_vec(b"name=John%20Doe&q=a+b").unwrap();
677
+ let pairs = decode_packed_pairs(&packed);
678
+ assert_eq!(pairs.len(), 2);
679
+ assert_eq!(pairs[0], (b"name".to_vec(), b"John Doe".to_vec()));
680
+ assert_eq!(pairs[1], (b"q".to_vec(), b"a b".to_vec()));
681
+ }
682
+
683
+ #[test]
684
+ fn query_parse_empty_value() {
685
+ let packed = crate::query_parser::query_parse_packed_vec(b"flag").unwrap();
686
+ let pairs = decode_packed_pairs(&packed);
687
+ assert_eq!(pairs.len(), 1);
688
+ assert_eq!(pairs[0], (b"flag".to_vec(), b"".to_vec()));
689
+ }
690
+
691
+ #[test]
692
+ fn query_parse_invalid_percent_rejected() {
693
+ assert!(crate::query_parser::query_parse_packed_vec(b"a=%ZZ").is_err());
694
+ }
695
+
696
+ #[test]
697
+ fn query_parse_empty_input() {
698
+ let packed = crate::query_parser::query_parse_packed_vec(b"").unwrap();
699
+ let pairs = decode_packed_pairs(&packed);
700
+ assert!(pairs.is_empty());
701
+ }
702
+
703
+ // ── cookie_parser ─────────────────────────────────────────────────
704
+
705
+ #[test]
706
+ fn cookie_parse_basic() {
707
+ let packed = crate::cookie_parser::cookie_parse_packed_vec(b"a=1; b=2");
708
+ let pairs = decode_packed_pairs(&packed);
709
+ assert_eq!(pairs.len(), 2);
710
+ assert_eq!(pairs[0], (b"a".to_vec(), b"1".to_vec()));
711
+ assert_eq!(pairs[1], (b"b".to_vec(), b"2".to_vec()));
712
+ }
713
+
714
+ #[test]
715
+ fn cookie_parse_trims_whitespace() {
716
+ let packed = crate::cookie_parser::cookie_parse_packed_vec(b" a = 1 ; b = hello world ");
717
+ let pairs = decode_packed_pairs(&packed);
718
+ assert_eq!(pairs.len(), 2);
719
+ assert_eq!(pairs[0], (b"a".to_vec(), b"1".to_vec()));
720
+ assert_eq!(pairs[1], (b"b".to_vec(), b"hello world".to_vec()));
721
+ }
722
+
723
+ #[test]
724
+ fn cookie_parse_skips_empty_name() {
725
+ let packed = crate::cookie_parser::cookie_parse_packed_vec(b"=1; =2; ok=3");
726
+ let pairs = decode_packed_pairs(&packed);
727
+ assert_eq!(pairs.len(), 1);
728
+ assert_eq!(pairs[0], (b"ok".to_vec(), b"3".to_vec()));
729
+ }
730
+
731
+ #[test]
732
+ fn cookie_parse_empty_value() {
733
+ let packed = crate::cookie_parser::cookie_parse_packed_vec(b"session=");
734
+ let pairs = decode_packed_pairs(&packed);
735
+ assert_eq!(pairs.len(), 1);
736
+ assert_eq!(pairs[0], (b"session".to_vec(), b"".to_vec()));
737
+ }
738
+
739
+ // ── json_ser ──────────────────────────────────────────────────────
740
+
741
+ #[test]
742
+ fn json_escaped_len_plain_ascii() {
743
+ assert_eq!(crate::json_ser::json_escaped_len(b"hello world"), 11);
744
+ }
745
+
746
+ #[test]
747
+ fn json_escaped_len_quotes_and_backslash() {
748
+ // "a\"b" -> a, \", b = 3 chars + 1 extra for the escaped quote.
749
+ assert_eq!(crate::json_ser::json_escaped_len(b"a\"b"), 4);
750
+ // backslash doubles: bytes a, \, b = 3 -> 4 escaped.
751
+ assert_eq!(crate::json_ser::json_escaped_len(b"a\\b"), 4);
752
+ }
753
+
754
+ #[test]
755
+ fn json_escaped_len_newline() {
756
+ assert_eq!(crate::json_ser::json_escaped_len(b"a\nb"), 4);
757
+ }
758
+
759
+ #[test]
760
+ fn json_escaped_len_control_char_wide() {
761
+ // 0x01 must be escaped as \u0001 -> 6 bytes for 1 input byte.
762
+ assert_eq!(crate::json_ser::json_escaped_len(&[b'a', 0x01, b'b']), 8);
763
+ }
764
+
765
+ #[test]
766
+ fn cookie_json_into_slice_output() {
767
+ let mut out = vec![0u8; 256];
768
+ let written = crate::json_ser::cookie_json_into_slice(b"a=1; b=hello world", &mut out, 100).unwrap();
769
+ assert_eq!(&out[..written], b"{\"a\":\"1\",\"b\":\"hello world\"}");
770
+ }
771
+
772
+ #[test]
773
+ fn cookie_json_into_slice_escapes() {
774
+ let mut out = vec![0u8; 256];
775
+ let written = crate::json_ser::cookie_json_into_slice(b"k=\"v\"", &mut out, 100).unwrap();
776
+ assert_eq!(&out[..written], b"{\"k\":\"\\\"v\\\"\"}");
777
+ }
778
+
779
+ #[test]
780
+ fn cookie_json_into_slice_small_buffer_errors() {
781
+ let mut out = vec![0u8; 8];
782
+ let res = crate::json_ser::cookie_json_into_slice(b"a=1; b=2; c=3; d=4", &mut out, 100);
783
+ assert!(res.is_err(), "truncation must surface as an error, not silent data loss");
784
+ }
785
+
786
+ #[test]
787
+ fn packed_pairs_to_json_into_slice_output() {
788
+ // Build packed query pairs for a=1 & b=2 via the query parser, then serialize.
789
+ let packed = crate::query_parser::query_parse_packed_vec(b"a=1&b=2").unwrap();
790
+ let mut out = vec![0u8; 256];
791
+ let written = crate::json_ser::packed_pairs_to_json_into_slice(&packed, &mut out, 100).unwrap();
792
+ assert_eq!(&out[..written], b"{\"a\":\"1\",\"b\":\"2\"}");
793
+ }
794
+
795
+ // ── Malformed-input panic safety ──────────────────────────────────
796
+
797
+ // Deterministic xorshift PRNG so the fuzz-style tests are reproducible.
798
+ struct Rng(u64);
799
+
800
+ impl Rng {
801
+ fn next(&mut self) -> u64 {
802
+ let mut x = self.0;
803
+ x ^= x << 13;
804
+ x ^= x >> 7;
805
+ x ^= x << 17;
806
+ self.0 = x;
807
+ x
808
+ }
809
+
810
+ fn bytes(&mut self, len: usize) -> Vec<u8> {
811
+ (0..len).map(|_| self.next() as u8).collect()
812
+ }
813
+ }
814
+
815
+ #[test]
816
+ fn parsers_do_not_panic_on_malformed_input() {
817
+ let mut rng = Rng(0xdeadbeef);
818
+
819
+ for _ in 0..2000 {
820
+ let len = (rng.next() % 64) as usize;
821
+ let data = rng.bytes(len);
822
+
823
+ // Every reachable parser must return Ok/Err, never panic.
824
+ let _ = crate::query_parser::query_parse_packed_vec(&data);
825
+ let _ = crate::cookie_parser::cookie_parse_packed_vec(&data);
826
+ let _ = HeaderRefs::parse(&data, (rng.next() & 1) == 1, 100);
827
+ let _ = crate::json_ser::cookie_json_into_slice(&data, &mut vec![0u8; 64], 100);
828
+ let _ = crate::json_ser::json_escaped_len(&data);
829
+ }
830
+ }
831
+
832
+ #[test]
833
+ fn header_parser_do_not_panic_on_adversarial_packed_headers() {
834
+ let mut rng = Rng(0xc0ffee);
835
+
836
+ for _ in 0..2000 {
837
+ // Length fields are arbitrary bytes -> must not cause OOB reads/panics.
838
+ let len = (rng.next() % 48) as usize;
839
+ let data = rng.bytes(len);
840
+ let _ = HeaderRefs::parse(&data, true, 200);
841
+ let _ = HeaderRefs::parse(&data, false, 200);
842
+ }
843
+ }