castrum 0.9.2 → 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.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
@@ -0,0 +1,140 @@
1
+ // rust/http/cookie_parser.rs — Unified zero-alloc cookie parser
2
+ // Eliminated the _vec variant; all callers use the single _into_slice path.
3
+ // Upper-bound pre-allocation for callers that need a Vec.
4
+
5
+ use crate::util::bytes::cookie_pairs;
6
+ use crate::util::{write_bytes, write_u32_le};
7
+ use napi::bindgen_prelude::*;
8
+ use napi_derive::napi;
9
+
10
+ /// Parse cookies into a caller-provided slice (zero-alloc).
11
+ /// Output format: [u32 count] repeated { [u32 name_len] [name] [u32 value_len] [value] }
12
+ #[inline]
13
+ pub fn cookie_parse_packed_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
14
+ let mut pos = 0usize;
15
+ write_u32_le(out, &mut pos, 0)?;
16
+
17
+ let mut count = 0u32;
18
+
19
+ for (name, value) in cookie_pairs(input) {
20
+ write_u32_le(out, &mut pos, name.len() as u32)?;
21
+ write_bytes(out, &mut pos, name)?;
22
+
23
+ write_u32_le(out, &mut pos, value.len() as u32)?;
24
+ write_bytes(out, &mut pos, value)?;
25
+
26
+ count += 1;
27
+ }
28
+
29
+ out[0..4].copy_from_slice(&count.to_le_bytes());
30
+ Ok(pos)
31
+ }
32
+
33
+ /// Compute the EXACT packed output size for `input` WITHOUT writing — the
34
+ /// "needed-size" pass for the C-ABI convention. Mirrors
35
+ /// [`cookie_parse_packed_into_slice`] (trim + DQUOTE-unwrap, no decode), so the
36
+ /// reported size is byte-exact.
37
+ #[inline]
38
+ pub fn cookie_parse_packed_size(input: &[u8]) -> Result<usize> {
39
+ let mut size = 4usize; // count prefix
40
+ for (name, value) in crate::util::bytes::cookie_pairs(input) {
41
+ size += 4 + name.len();
42
+ size += 4 + value.len();
43
+ }
44
+ Ok(size)
45
+ }
46
+
47
+ /// Allocate and parse cookies. Uses conservative upper bound to avoid a pre-scan.
48
+ #[inline]
49
+ pub fn cookie_parse_packed_vec(input: &[u8]) -> Result<Vec<u8>> {
50
+ let upper_bound = input.len().saturating_mul(9).saturating_add(16);
51
+ let mut out = vec![0u8; upper_bound];
52
+ let written = cookie_parse_packed_into_slice(input, &mut out)?;
53
+ out.truncate(written);
54
+ Ok(out)
55
+ }
56
+
57
+ #[napi]
58
+ pub fn cookie_parse_packed(input: Uint8Array) -> Result<Buffer> {
59
+ cookie_parse_packed_vec(input.as_ref()).map(Buffer::from)
60
+ }
61
+
62
+ #[napi]
63
+ pub fn cookie_parse_packed_into(input: Uint8Array, mut output: Uint8Array) -> Result<u32> {
64
+ crate::util::run_packed_into(&input, &mut output, cookie_parse_packed_into_slice)
65
+ }
66
+
67
+ #[cfg(test)]
68
+ mod tests {
69
+ use super::cookie_parse_packed_vec;
70
+ use crate::test_support::decode_packed_pairs;
71
+
72
+ #[test]
73
+ fn cookie_parse_basic() {
74
+ let packed = cookie_parse_packed_vec(b"a=1; b=2").unwrap();
75
+ let pairs = decode_packed_pairs(&packed);
76
+ assert_eq!(pairs.len(), 2);
77
+ assert_eq!(pairs[0], (b"a".to_vec(), b"1".to_vec()));
78
+ assert_eq!(pairs[1], (b"b".to_vec(), b"2".to_vec()));
79
+ }
80
+
81
+ #[test]
82
+ fn cookie_parse_trims_whitespace() {
83
+ let packed = cookie_parse_packed_vec(b" a = 1 ; b = hello world ").unwrap();
84
+ let pairs = decode_packed_pairs(&packed);
85
+ assert_eq!(pairs.len(), 2);
86
+ assert_eq!(pairs[0], (b"a".to_vec(), b"1".to_vec()));
87
+ assert_eq!(pairs[1], (b"b".to_vec(), b"hello world".to_vec()));
88
+ }
89
+
90
+ #[test]
91
+ fn cookie_parse_skips_empty_name() {
92
+ let packed = cookie_parse_packed_vec(b"=1; =2; ok=3").unwrap();
93
+ let pairs = decode_packed_pairs(&packed);
94
+ assert_eq!(pairs.len(), 1);
95
+ assert_eq!(pairs[0], (b"ok".to_vec(), b"3".to_vec()));
96
+ }
97
+
98
+ #[test]
99
+ fn cookie_parse_empty_value() {
100
+ let packed = cookie_parse_packed_vec(b"session=").unwrap();
101
+ let pairs = decode_packed_pairs(&packed);
102
+ assert_eq!(pairs.len(), 1);
103
+ assert_eq!(pairs[0], (b"session".to_vec(), b"".to_vec()));
104
+ }
105
+
106
+ #[test]
107
+ fn cookie_parse_equals_in_value() {
108
+ // Only the first `=` separates name from value.
109
+ let packed = cookie_parse_packed_vec(b"a=b=c").unwrap();
110
+ let pairs = decode_packed_pairs(&packed);
111
+ assert_eq!(pairs.len(), 1);
112
+ assert_eq!(pairs[0], (b"a".to_vec(), b"b=c".to_vec()));
113
+ }
114
+
115
+ #[test]
116
+ fn cookie_parse_expires_value_with_commas() {
117
+ let packed =
118
+ cookie_parse_packed_vec(b"session=abc; expires=Wed, 21 Oct 2015 07:28:00 GMT").unwrap();
119
+ let pairs = decode_packed_pairs(&packed);
120
+ assert_eq!(pairs.len(), 2);
121
+ assert_eq!(
122
+ pairs[1],
123
+ (
124
+ b"expires".to_vec(),
125
+ b"Wed, 21 Oct 2015 07:28:00 GMT".to_vec()
126
+ )
127
+ );
128
+ }
129
+
130
+ #[test]
131
+ fn cookie_parse_quotes_are_not_special() {
132
+ // Cookies split on `;` unconditionally — quotes are ordinary data, and a
133
+ // token without `=` becomes a pair with an empty value.
134
+ let packed = cookie_parse_packed_vec(b"a=\"x;y\"").unwrap();
135
+ let pairs = decode_packed_pairs(&packed);
136
+ assert_eq!(pairs.len(), 2);
137
+ assert_eq!(pairs[0], (b"a".to_vec(), b"\"x".to_vec()));
138
+ assert_eq!(pairs[1], (b"y\"".to_vec(), b"".to_vec()));
139
+ }
140
+ }
@@ -0,0 +1,289 @@
1
+ // rust/http/etag.rs — HTTP cache semantics (ETag + conditional requests).
2
+ //
3
+ // ETag generation (strong/weak, crc32-based) and the `ConditionalRequest`
4
+ // higher-order instance that turns If-None-Match / If-Modified-Since into a
5
+ // 304 decision. HTTP-date (IMF-fixdate) formatting/parsing lives in the
6
+ // `http_date` submodule; the conditional request reuses
7
+ // `http_date::parse_http_date_secs` for If-Modified-Since.
8
+
9
+ use napi::bindgen_prelude::*;
10
+ use napi_derive::napi;
11
+
12
+ use crate::http::http_date::parse_http_date_secs;
13
+ use crate::util::bytes::trim_ascii_whitespace;
14
+
15
+ // ── ETag ─────────────────────────────────────────────────────────
16
+
17
+ /// Format a crc32 digest as an 8-char lowercase hex ETag value. Written into a
18
+ /// fixed stack buffer (10 or 12 bytes) — no `format!`/heap allocation.
19
+ pub fn etag_from_crc32(crc: u32, weak: bool) -> String {
20
+ let mut buf = [0u8; 12];
21
+ let start = if weak { 2 } else { 0 };
22
+ if weak {
23
+ buf[0] = b'W';
24
+ buf[1] = b'/';
25
+ }
26
+ let hex = crate::util::bytes::HEX_LOWER;
27
+ buf[start] = b'"';
28
+ buf[start + 1] = hex[((crc >> 28) & 0x0f) as usize];
29
+ buf[start + 2] = hex[((crc >> 24) & 0x0f) as usize];
30
+ buf[start + 3] = hex[((crc >> 20) & 0x0f) as usize];
31
+ buf[start + 4] = hex[((crc >> 16) & 0x0f) as usize];
32
+ buf[start + 5] = hex[((crc >> 12) & 0x0f) as usize];
33
+ buf[start + 6] = hex[((crc >> 8) & 0x0f) as usize];
34
+ buf[start + 7] = hex[((crc >> 4) & 0x0f) as usize];
35
+ buf[start + 8] = hex[(crc & 0x0f) as usize];
36
+ buf[start + 9] = b'"';
37
+ String::from_utf8(buf[..start + 10].to_vec()).expect("etag is ASCII")
38
+ }
39
+
40
+ /// Format a crc32 digest as an 8-char lowercase hex ETag value directly into
41
+ /// `out` (zero-alloc — no `String`/heap allocation). Returns the number of
42
+ /// bytes written (10 strong, 12 weak). Errors if `out` is too small.
43
+ pub fn etag_from_crc32_into(crc: u32, weak: bool, out: &mut [u8]) -> Result<usize> {
44
+ let len = if weak { 12 } else { 10 };
45
+ if out.len() < len {
46
+ return Err(Error::from_reason("etag: output buffer too small"));
47
+ }
48
+ let hex = crate::util::bytes::HEX_LOWER;
49
+ let mut pos = 0usize;
50
+ if weak {
51
+ out[pos] = b'W';
52
+ out[pos + 1] = b'/';
53
+ pos += 2;
54
+ }
55
+ out[pos] = b'"';
56
+ pos += 1;
57
+ out[pos] = hex[((crc >> 28) & 0x0f) as usize];
58
+ out[pos + 1] = hex[((crc >> 24) & 0x0f) as usize];
59
+ out[pos + 2] = hex[((crc >> 20) & 0x0f) as usize];
60
+ out[pos + 3] = hex[((crc >> 16) & 0x0f) as usize];
61
+ out[pos + 4] = hex[((crc >> 12) & 0x0f) as usize];
62
+ out[pos + 5] = hex[((crc >> 8) & 0x0f) as usize];
63
+ out[pos + 6] = hex[((crc >> 4) & 0x0f) as usize];
64
+ out[pos + 7] = hex[(crc & 0x0f) as usize];
65
+ out[pos + 8] = b'"';
66
+ Ok(len)
67
+ }
68
+
69
+ #[napi]
70
+ pub fn etag(input: Uint8Array, weak: Option<bool>) -> Buffer {
71
+ let crc = crc32fast::hash(input.as_ref());
72
+ Buffer::from(etag_from_crc32(crc, weak.unwrap_or(false)).into_bytes())
73
+ }
74
+
75
+ /// Reusable-output etag: writes the ETag into `output` and returns bytes
76
+ /// written (10 strong / 12 weak). Errors if `output` is too small.
77
+ #[napi]
78
+ pub fn etag_into(input: Uint8Array, mut output: Uint8Array, weak: Option<bool>) -> Result<u32> {
79
+ let weak = weak.unwrap_or(false);
80
+ crate::util::run_packed_into(&input, &mut output, move |inp, out| {
81
+ let crc = crc32fast::hash(inp);
82
+ etag_from_crc32_into(crc, weak, out)
83
+ })
84
+ }
85
+
86
+ /// Packed ETag batch: `[u32 count]{[u32 len][data]}` in →
87
+ /// `[u32 count]{[u32 len][etag]}` out (10 strong / 12 weak bytes each).
88
+ #[napi]
89
+ pub fn etag_batch_packed(input: Uint8Array, weak: Option<bool>) -> Result<Buffer> {
90
+ let weak = weak.unwrap_or(false);
91
+ let iter = crate::util::packed::PackedIter::new(input.as_ref())?;
92
+ let count = iter.len();
93
+ let per = if weak { 12 } else { 10 };
94
+ // Direct-write: ETags are fixed-size (10 strong / 12 weak), so no per-item
95
+ // `String`/`Vec` allocation.
96
+ let mut out = Vec::with_capacity(4 + count.saturating_mul(4 + per));
97
+ out.extend_from_slice(&(count as u32).to_le_bytes());
98
+ for item in iter {
99
+ let crc = crc32fast::hash(item);
100
+ out.extend_from_slice(&(per as u32).to_le_bytes());
101
+ let start = out.len();
102
+ out.resize(start + per, 0);
103
+ etag_from_crc32_into(crc, weak, &mut out[start..start + per])?;
104
+ }
105
+ Ok(Buffer::from(out))
106
+ }
107
+
108
+ // ── Conditional requests (If-None-Match / If-Modified-Since) ─────
109
+
110
+ /// Strip the `W/` weak prefix from an opaque-tag.
111
+ fn strip_weak(tag: &[u8]) -> &[u8] {
112
+ if tag.starts_with(b"W/") {
113
+ &tag[2..]
114
+ } else {
115
+ tag
116
+ }
117
+ }
118
+
119
+ /// Weak equality of two opaque-tags (RFC 7232 §2.3.2).
120
+ fn etag_weak_eq(a: &[u8], b: &[u8]) -> bool {
121
+ strip_weak(trim_ascii_whitespace(a)) == strip_weak(trim_ascii_whitespace(b))
122
+ }
123
+
124
+ /// Higher-order instance: per-resource state (etag + last-modified) computed
125
+ /// once, then reused across requests to answer "is this 304?".
126
+ #[napi]
127
+ pub struct ConditionalRequest {
128
+ etag_value: Vec<u8>,
129
+ last_modified_secs: i64,
130
+ }
131
+
132
+ /// Pure core: evaluate is-not-modified against a precompiled etag + last
133
+ /// modified. Shared by the napi method and the C-ABI opaque-handle path, so
134
+ /// both transports answer identically (304 semantics: If-None-Match wins over
135
+ /// If-Modified-Since).
136
+ pub(crate) fn is_not_modified_core(
137
+ etag_value: &[u8],
138
+ last_modified_secs: i64,
139
+ if_none_match: Option<&[u8]>,
140
+ if_modified_since: Option<&[u8]>,
141
+ ) -> bool {
142
+ if let Some(inm) = if_none_match {
143
+ let header = trim_ascii_whitespace(inm);
144
+ if header == b"*" {
145
+ return true;
146
+ }
147
+ return header
148
+ .split(|&b| b == b',')
149
+ .any(|candidate| etag_weak_eq(candidate, etag_value));
150
+ }
151
+ if let Some(ims) = if_modified_since {
152
+ if last_modified_secs > 0 {
153
+ if let Some(secs) = parse_http_date_secs(ims) {
154
+ return last_modified_secs <= secs;
155
+ }
156
+ }
157
+ }
158
+ false
159
+ }
160
+
161
+ #[napi]
162
+ impl ConditionalRequest {
163
+ #[napi(constructor)]
164
+ pub fn new(etag_value: Uint8Array, last_modified_secs: Option<f64>) -> Self {
165
+ Self {
166
+ etag_value: etag_value.as_ref().to_vec(),
167
+ last_modified_secs: last_modified_secs.map(|v| v as i64).unwrap_or(0).max(0),
168
+ }
169
+ }
170
+
171
+ /// Opaque handle to the precompiled state, for the `bun:ffi` C-ABI fast path
172
+ /// (`castrum_conditional_is_not_modified` in rust/ffi.rs). Only valid while
173
+ /// THIS instance is alive; the JS wrapper holds the instance for the handle
174
+ /// lifetime (same contract as `Ingress::ingress_inner_ptr`).
175
+ #[napi]
176
+ pub fn inner_ptr(&self) -> u64 {
177
+ self as *const ConditionalRequest as u64
178
+ }
179
+
180
+ /// true → "304 Not Modified" (If-None-Match wins over If-Modified-Since).
181
+ #[napi]
182
+ pub fn is_not_modified(
183
+ &self,
184
+ if_none_match: Option<Uint8Array>,
185
+ if_modified_since: Option<Uint8Array>,
186
+ ) -> bool {
187
+ is_not_modified_core(
188
+ &self.etag_value,
189
+ self.last_modified_secs,
190
+ if_none_match.as_deref(),
191
+ if_modified_since.as_deref(),
192
+ )
193
+ }
194
+ }
195
+
196
+ /// C-ABI support: evaluate is-not-modified against the precompiled state.
197
+ ///
198
+ /// # Safety
199
+ /// `p` must be a valid `*const ConditionalRequest` obtained from `inner_ptr`
200
+ /// and must stay alive for the call (the JS wrapper holds the napi instance).
201
+ pub(crate) unsafe fn conditional_is_not_modified(
202
+ p: *const ConditionalRequest,
203
+ inm: Option<&[u8]>,
204
+ ims: Option<&[u8]>,
205
+ ) -> bool {
206
+ let c = &*p;
207
+ is_not_modified_core(&c.etag_value, c.last_modified_secs, inm, ims)
208
+ }
209
+
210
+ #[cfg(test)]
211
+ mod tests {
212
+ use super::*;
213
+
214
+ #[test]
215
+ fn etag_formatting() {
216
+ assert_eq!(etag_from_crc32(0xdead_beef, false), "\"deadbeef\"");
217
+ assert_eq!(etag_from_crc32(0x0000_0001, true), "W/\"00000001\"");
218
+ }
219
+
220
+ #[test]
221
+ fn etag_from_crc32_into_matches_allocating() {
222
+ let crc = crc32fast::hash(b"hello world, this is etag data");
223
+ let strong = etag_from_crc32(crc, false);
224
+ let weak = etag_from_crc32(crc, true);
225
+ let mut out = [0u8; 12];
226
+ let n = etag_from_crc32_into(crc, false, &mut out).unwrap();
227
+ assert_eq!(n, strong.len());
228
+ assert_eq!(&out[..n], strong.as_bytes());
229
+ let n2 = etag_from_crc32_into(crc, true, &mut out).unwrap();
230
+ assert_eq!(n2, weak.len());
231
+ assert_eq!(&out[..n2], weak.as_bytes());
232
+ }
233
+
234
+ #[test]
235
+ fn etag_from_crc32_into_small_buffer_errors() {
236
+ let mut out = [0u8; 4];
237
+ assert!(etag_from_crc32_into(0xdead_beef, false, &mut out).is_err());
238
+ let mut out2 = [0u8; 10];
239
+ assert!(etag_from_crc32_into(0xdead_beef, true, &mut out2).is_err());
240
+ }
241
+
242
+ #[test]
243
+ fn etag_into_reports_length() {
244
+ let data = b"hello world, this is etag data";
245
+ let out = Uint8Array::new(vec![0u8; 16]);
246
+ let n = etag_into(Uint8Array::new(data.to_vec()), out, None).unwrap();
247
+ assert_eq!(
248
+ n as usize,
249
+ etag_from_crc32(crc32fast::hash(data), false).len()
250
+ );
251
+ let out2 = Uint8Array::new(vec![0u8; 16]);
252
+ let n2 = etag_into(Uint8Array::new(data.to_vec()), out2, Some(true)).unwrap();
253
+ assert_eq!(
254
+ n2 as usize,
255
+ etag_from_crc32(crc32fast::hash(data), true).len()
256
+ );
257
+ }
258
+
259
+ #[test]
260
+ fn conditional_if_none_match_star() {
261
+ let c = ConditionalRequest::new(Uint8Array::new(b"\"abc123\"".to_vec()), Some(1_000.0));
262
+ assert!(c.is_not_modified(Some(Uint8Array::new(b"*".to_vec())), None));
263
+ }
264
+
265
+ #[test]
266
+ fn conditional_weak_etag_match() {
267
+ let c = ConditionalRequest::new(Uint8Array::new(b"\"abc123\"".to_vec()), Some(1_000.0));
268
+ assert!(c.is_not_modified(
269
+ Some(Uint8Array::new(b"\"xyz\", W/\"abc123\"".to_vec())),
270
+ None
271
+ ));
272
+ assert!(!c.is_not_modified(Some(Uint8Array::new(b"\"xyz\", \"abc124\"".to_vec())), None));
273
+ }
274
+
275
+ #[test]
276
+ fn conditional_if_modified_since() {
277
+ // last-modified = 1970-01-01 00:16:40 (secs 1000).
278
+ let c = ConditionalRequest::new(Uint8Array::new(b"\"abc123\"".to_vec()), Some(1_000.0));
279
+ // IMS equal to last-modified → not modified → 304.
280
+ let equal = Uint8Array::new(b"Thu, 01 Jan 1970 00:16:40 GMT".to_vec());
281
+ assert!(c.is_not_modified(None, Some(equal)));
282
+ // IMS after last-modified → not modified → 304.
283
+ let after = Uint8Array::new(b"Thu, 01 Jan 1970 00:16:41 GMT".to_vec());
284
+ assert!(c.is_not_modified(None, Some(after)));
285
+ // IMS before last-modified → modified → 200.
286
+ let before = Uint8Array::new(b"Thu, 01 Jan 1970 00:00:01 GMT".to_vec());
287
+ assert!(!c.is_not_modified(None, Some(before)));
288
+ }
289
+ }
@@ -0,0 +1,132 @@
1
+ // rust/http/form.rs — application/x-www-form-urlencoded body parser.
2
+ //
3
+ // Reuses the zero-alloc query-parser core (`query_parse_packed_into_slice`):
4
+ // it already splits on '&', decodes '+'→space and %XX, which is exactly the
5
+ // x-www-form-urlencoded wire format. This module adds the public napi surface:
6
+ // a scalar `form_parse_packed` and the higher-order `FormParser` instance,
7
+ // which owns a reusable output buffer so repeated parses don't reallocate.
8
+
9
+ use napi::bindgen_prelude::*;
10
+ use napi_derive::napi;
11
+
12
+ use crate::http::query_parser::query_parse_packed_into_slice;
13
+
14
+ /// Parse an application/x-www-form-urlencoded body into packed pairs.
15
+ /// Output: `[u32 count] repeated { [u32 name_len] [name] [u32 value_len] [value] }`
16
+ #[inline]
17
+ pub fn form_parse_packed_vec(input: &[u8]) -> Result<Vec<u8>> {
18
+ // Same conservative upper bound as the query parser (9x + 16).
19
+ let upper_bound = input.len().saturating_mul(9).saturating_add(16);
20
+ let mut out = vec![0u8; upper_bound];
21
+ let written = query_parse_packed_into_slice(input, &mut out)?;
22
+ out.truncate(written);
23
+ Ok(out)
24
+ }
25
+
26
+ #[napi]
27
+ pub fn form_parse_packed(input: Uint8Array) -> Result<Buffer> {
28
+ form_parse_packed_vec(input.as_ref()).map(Buffer::from)
29
+ }
30
+
31
+ /// Higher-order instance: owns a reusable output buffer so repeated parses do
32
+ /// not reallocate. Buffer allocation happens once in the constructor (or is
33
+ /// amortized via grow-on-demand in `parse`), and `parse` writes into the
34
+ /// reused buffer — the compiled-once pattern shared by `SchemaValidator` /
35
+ /// `HmacSigner` / `TemplateRenderer`.
36
+ #[napi]
37
+ pub struct FormParser {
38
+ buf: Vec<u8>,
39
+ }
40
+
41
+ #[napi]
42
+ impl FormParser {
43
+ #[napi(constructor)]
44
+ pub fn new(capacity: Option<u32>) -> Self {
45
+ // Clamp the initial buffer to a sane cap so a huge capacity can't
46
+ // force a multi-GiB single allocation; parse() still grows on demand.
47
+ const MAX_CAPACITY: usize = 16 * 1024 * 1024;
48
+ let cap = (capacity.unwrap_or(4096).max(256) as usize).min(MAX_CAPACITY);
49
+ Self {
50
+ buf: vec![0u8; cap],
51
+ }
52
+ }
53
+
54
+ /// Parse into the instance's reusable buffer and return the packed pairs.
55
+ #[napi]
56
+ pub fn parse(&mut self, input: Uint8Array) -> Result<Buffer> {
57
+ let need = input.len().saturating_mul(9).saturating_add(16);
58
+ if self.buf.len() < need {
59
+ self.buf.resize(need, 0);
60
+ }
61
+ let written = query_parse_packed_into_slice(input.as_ref(), &mut self.buf)?;
62
+ Ok(Buffer::from(self.buf[..written].to_vec()))
63
+ }
64
+
65
+ /// Zero-alloc parse into a caller-provided output buffer.
66
+ #[napi]
67
+ pub fn parse_into(&self, input: Uint8Array, mut output: Uint8Array) -> Result<u32> {
68
+ crate::util::run_packed_into(&input, &mut output, query_parse_packed_into_slice)
69
+ }
70
+ }
71
+
72
+ #[cfg(test)]
73
+ mod tests {
74
+ use super::*;
75
+ use crate::test_support::decode_packed_pairs;
76
+
77
+ #[test]
78
+ fn form_parse_basic_pairs() {
79
+ let packed = form_parse_packed_vec(b"name=John%20Doe&age=30&tags=a&tags=b").unwrap();
80
+ let pairs = decode_packed_pairs(&packed);
81
+ assert_eq!(pairs.len(), 4);
82
+ assert_eq!(pairs[0], (b"name".to_vec(), b"John Doe".to_vec()));
83
+ assert_eq!(pairs[1], (b"age".to_vec(), b"30".to_vec()));
84
+ assert_eq!(pairs[2], (b"tags".to_vec(), b"a".to_vec()));
85
+ assert_eq!(pairs[3], (b"tags".to_vec(), b"b".to_vec()));
86
+ }
87
+
88
+ #[test]
89
+ fn form_parse_plus_and_percent() {
90
+ let packed = form_parse_packed_vec(b"a+b=c%2Bd&empty=").unwrap();
91
+ let pairs = decode_packed_pairs(&packed);
92
+ assert_eq!(pairs[0], (b"a b".to_vec(), b"c+d".to_vec()));
93
+ assert_eq!(pairs[1], (b"empty".to_vec(), b"".to_vec()));
94
+ }
95
+
96
+ #[test]
97
+ fn form_parse_key_without_equals() {
98
+ let packed = form_parse_packed_vec(b"flag&x=1").unwrap();
99
+ let pairs = decode_packed_pairs(&packed);
100
+ assert_eq!(pairs[0], (b"flag".to_vec(), b"".to_vec()));
101
+ assert_eq!(pairs[1], (b"x".to_vec(), b"1".to_vec()));
102
+ }
103
+
104
+ #[test]
105
+ fn form_parse_empty_input() {
106
+ let packed = form_parse_packed_vec(b"").unwrap();
107
+ assert!(decode_packed_pairs(&packed).is_empty());
108
+ }
109
+
110
+ #[test]
111
+ fn form_parse_rejects_malformed_percent() {
112
+ assert!(form_parse_packed_vec(b"a=%ZZ").is_err());
113
+ }
114
+
115
+ #[test]
116
+ fn form_parser_reuses_buffer_across_parses() {
117
+ let mut parser = FormParser::new(Some(64));
118
+ let a = parser.parse(Uint8Array::new(b"x=1&y=2".to_vec())).unwrap();
119
+ assert_eq!(
120
+ decode_packed_pairs(a.as_ref()),
121
+ vec![
122
+ (b"x".to_vec(), b"1".to_vec()),
123
+ (b"y".to_vec(), b"2".to_vec())
124
+ ]
125
+ );
126
+ // A larger input grows the reusable buffer and still parses.
127
+ let big = format!("k={}", "a".repeat(200));
128
+ let b = parser.parse(Uint8Array::new(big.into_bytes())).unwrap();
129
+ let pairs = decode_packed_pairs(b.as_ref());
130
+ assert_eq!(pairs[0].1, b"a".repeat(200));
131
+ }
132
+ }