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,180 @@
1
+ // rust/http/query_parser.rs — Unified zero-alloc query string parser
2
+ // Single _into_slice code path with _vec wrapper for callers needing Vec.
3
+ // Returns Cow<[u8]> when no decoding is needed (zero allocation).
4
+
5
+ use crate::util::write_u32_le;
6
+ use napi::bindgen_prelude::*;
7
+ use napi_derive::napi;
8
+
9
+ /// Write a single URL-decoded form component into the output slice.
10
+ #[inline]
11
+ fn write_decoded_form_component(src: &[u8], out: &mut [u8], pos: &mut usize) -> Result<()> {
12
+ let len_pos = *pos;
13
+ write_u32_le(out, pos, 0)?;
14
+ // The decoded length never exceeds `src.len()`; the shared decoder checks
15
+ // the remaining buffer against the ACTUAL decoded length (so a `%XX`-heavy
16
+ // component only needs room for its decoded form, matching the pre-refactor
17
+ // behavior where a buffer sized to the decoded length succeeds).
18
+ let written = crate::util::bytes::decode_form_component_into(src, &mut out[*pos..]).map_err(
19
+ |e| match e {
20
+ crate::util::bytes::FormDecodeError::Malformed => {
21
+ Error::from_reason("invalid %-encoding: malformed %XX sequence")
22
+ }
23
+ crate::util::bytes::FormDecodeError::BufferTooSmall => {
24
+ Error::from_reason("packed output: buffer too small")
25
+ }
26
+ },
27
+ )?;
28
+ *pos += written;
29
+ let decoded_len = written as u32;
30
+ out[len_pos..len_pos + 4].copy_from_slice(&decoded_len.to_le_bytes());
31
+ Ok(())
32
+ }
33
+
34
+ /// Parse application/x-www-form-urlencoded into packed pairs.
35
+ /// Output: [u32 count] repeated { [u32 dec_name_len] [decoded_name] [u32 dec_val_len] [decoded_val] }
36
+ #[inline]
37
+ pub fn query_parse_packed_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
38
+ let mut pos = 0usize;
39
+ write_u32_le(out, &mut pos, 0)?;
40
+ let mut count = 0u32;
41
+
42
+ for pair in input.split(|&b| b == b'&') {
43
+ if pair.is_empty() {
44
+ continue;
45
+ }
46
+ let (key, value) = match pair.iter().position(|&b| b == b'=') {
47
+ Some(eq) => (&pair[..eq], &pair[eq + 1..]),
48
+ None => (pair, &[][..]),
49
+ };
50
+ write_decoded_form_component(key, out, &mut pos)?;
51
+ write_decoded_form_component(value, out, &mut pos)?;
52
+ count += 1;
53
+ }
54
+
55
+ out[0..4].copy_from_slice(&count.to_le_bytes());
56
+ Ok(pos)
57
+ }
58
+
59
+ /// Compute the EXACT packed output size for `input` WITHOUT writing — the
60
+ /// "needed-size" pass for the C-ABI convention. Mirrors
61
+ /// [`query_parse_packed_into_slice`]'s structure exactly (split on `&`, skip
62
+ /// empty pairs, split at `=`), so the reported size is byte-exact and a
63
+ /// malformed `%XX` still surfaces as `Err` (caller → `0`).
64
+ #[inline]
65
+ pub fn query_parse_packed_size(input: &[u8]) -> Result<usize> {
66
+ let mut size = 4usize; // count prefix
67
+ for pair in input.split(|&b| b == b'&') {
68
+ if pair.is_empty() {
69
+ continue;
70
+ }
71
+ let (key, value) = match pair.iter().position(|&b| b == b'=') {
72
+ Some(eq) => (&pair[..eq], &pair[eq + 1..]),
73
+ None => (pair, &[][..]),
74
+ };
75
+ size += 4 + crate::util::bytes::decode_form_component_len(key)
76
+ .map_err(|_| Error::from_reason("invalid %-encoding: malformed %XX sequence"))?;
77
+ size += 4 + crate::util::bytes::decode_form_component_len(value)
78
+ .map_err(|_| Error::from_reason("invalid %-encoding: malformed %XX sequence"))?;
79
+ }
80
+ Ok(size)
81
+ }
82
+
83
+ /// Allocating parser — conservative upper bound, no pre-scan.
84
+ #[inline]
85
+ pub fn query_parse_packed_vec(input: &[u8]) -> Result<Vec<u8>> {
86
+ // Packed output is larger than input: each component gets 4-byte length prefix.
87
+ // Upper bound: 4 (count) + input.len() * 2 * 5 ≈ 10x for worst-case (many small pairs).
88
+ // Conservative: 9x + 16 (same as before).
89
+ let upper_bound = input.len().saturating_mul(9).saturating_add(16);
90
+ let mut out = vec![0u8; upper_bound];
91
+ match query_parse_packed_into_slice(input, &mut out) {
92
+ Ok(written) => {
93
+ out.truncate(written);
94
+ Ok(out)
95
+ }
96
+ Err(e) => Err(e),
97
+ }
98
+ }
99
+
100
+ #[napi]
101
+ pub fn query_parse_packed(input: Uint8Array) -> Result<Buffer> {
102
+ Ok(Buffer::from(query_parse_packed_vec(input.as_ref())?))
103
+ }
104
+
105
+ #[napi]
106
+ pub fn query_parse_packed_into(input: Uint8Array, mut output: Uint8Array) -> Result<u32> {
107
+ crate::util::run_packed_into(&input, &mut output, query_parse_packed_into_slice)
108
+ }
109
+
110
+ #[cfg(test)]
111
+ mod tests {
112
+ use super::query_parse_packed_vec;
113
+ use crate::test_support::decode_packed_pairs;
114
+
115
+ #[test]
116
+ fn query_parse_basic_pairs() {
117
+ let packed = query_parse_packed_vec(b"a=1&b=2").unwrap();
118
+ let pairs = decode_packed_pairs(&packed);
119
+ assert_eq!(pairs.len(), 2);
120
+ assert_eq!(pairs[0], (b"a".to_vec(), b"1".to_vec()));
121
+ assert_eq!(pairs[1], (b"b".to_vec(), b"2".to_vec()));
122
+ }
123
+
124
+ #[test]
125
+ fn query_parse_percent_and_plus_decoding() {
126
+ let packed = query_parse_packed_vec(b"name=John%20Doe&q=a+b").unwrap();
127
+ let pairs = decode_packed_pairs(&packed);
128
+ assert_eq!(pairs.len(), 2);
129
+ assert_eq!(pairs[0], (b"name".to_vec(), b"John Doe".to_vec()));
130
+ assert_eq!(pairs[1], (b"q".to_vec(), b"a b".to_vec()));
131
+ }
132
+
133
+ #[test]
134
+ fn query_parse_empty_value() {
135
+ let packed = query_parse_packed_vec(b"flag").unwrap();
136
+ let pairs = decode_packed_pairs(&packed);
137
+ assert_eq!(pairs.len(), 1);
138
+ assert_eq!(pairs[0], (b"flag".to_vec(), b"".to_vec()));
139
+ }
140
+
141
+ #[test]
142
+ fn query_parse_invalid_percent_rejected() {
143
+ assert!(query_parse_packed_vec(b"a=%ZZ").is_err());
144
+ }
145
+
146
+ #[test]
147
+ fn query_parse_empty_input() {
148
+ let packed = query_parse_packed_vec(b"").unwrap();
149
+ let pairs = decode_packed_pairs(&packed);
150
+ assert!(pairs.is_empty());
151
+ }
152
+
153
+ #[test]
154
+ fn query_parse_null_byte_preserved() {
155
+ // `%00` decodes to a NUL byte inside the value (byte-oriented parser).
156
+ let packed = query_parse_packed_vec(b"a=%00b").unwrap();
157
+ let pairs = decode_packed_pairs(&packed);
158
+ assert_eq!(pairs.len(), 1);
159
+ assert_eq!(pairs[0], (b"a".to_vec(), b"\x00b".to_vec()));
160
+ }
161
+
162
+ #[test]
163
+ fn query_parse_semicolon_is_data() {
164
+ // In a query string `&` separates pairs; `;` is ordinary data.
165
+ let packed = query_parse_packed_vec(b"a=1;b=2").unwrap();
166
+ let pairs = decode_packed_pairs(&packed);
167
+ assert_eq!(pairs.len(), 1);
168
+ assert_eq!(pairs[0], (b"a".to_vec(), b"1;b=2".to_vec()));
169
+ }
170
+
171
+ #[test]
172
+ fn query_parse_non_utf8_byte_passthrough() {
173
+ // `%FF` decodes to a raw 0xFF byte; the parser does not require valid
174
+ // UTF-8 in query values (callers must handle it when they do).
175
+ let packed = query_parse_packed_vec(b"a=%FF").unwrap();
176
+ let pairs = decode_packed_pairs(&packed);
177
+ assert_eq!(pairs.len(), 1);
178
+ assert_eq!(pairs[0], (b"a".to_vec(), vec![0xFF]));
179
+ }
180
+ }
@@ -0,0 +1,296 @@
1
+ // rust/http/url_codec.rs — URL percent-encoding
2
+ use crate::util::bytes::{decode_percent_at, HEX_UPPER};
3
+ use crate::util::{ensure_capacity, write_bytes};
4
+ use memchr::memchr;
5
+ use napi::bindgen_prelude::*;
6
+ use napi_derive::napi;
7
+
8
+ const fn is_unreserved_char(b: u8) -> bool {
9
+ matches!(b,
10
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
11
+ | b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | 0x27 | b'(' | b')'
12
+ )
13
+ }
14
+
15
+ /// 256-entry lookup for RFC 3986 unreserved characters — one table load per
16
+ /// byte instead of a chain of range compares on the hot encoding loop.
17
+ static UNRESERVED_LUT: [bool; 256] = {
18
+ let mut t = [false; 256];
19
+ let mut i = 0usize;
20
+ while i < 256 {
21
+ t[i] = is_unreserved_char(i as u8);
22
+ i += 1;
23
+ }
24
+ t
25
+ };
26
+
27
+ #[inline(always)]
28
+ fn is_unreserved(b: u8) -> bool {
29
+ UNRESERVED_LUT[b as usize]
30
+ }
31
+
32
+ /// Percent-encode a raw byte slice (RFC 3986 unreserved set). Shared by the
33
+ /// scalar napi path and the packed batch path.
34
+ fn url_encode_bytes(input: &[u8]) -> Vec<u8> {
35
+ // Exact-size allocation: count bytes needing %XX in one pass, then size the
36
+ // Vec precisely so the write loop never reallocates. Saturating math keeps
37
+ // this overflow-safe under `overflow-checks = true`.
38
+ let reserved = input.iter().filter(|&&b| !is_unreserved(b)).count();
39
+ let mut out = Vec::with_capacity(input.len().saturating_add(reserved.saturating_mul(2)));
40
+ let mut start = 0usize;
41
+
42
+ for (i, &b) in input.iter().enumerate() {
43
+ if !is_unreserved(b) {
44
+ out.extend_from_slice(&input[start..i]);
45
+ // ⭐ Batch the 3-byte %XX write — one extend instead of 3 pushes.
46
+ let mut encoded = [b'%', 0, 0];
47
+ encoded[1] = HEX_UPPER[(b >> 4) as usize];
48
+ encoded[2] = HEX_UPPER[(b & 0x0f) as usize];
49
+ out.extend_from_slice(&encoded);
50
+ start = i + 1;
51
+ }
52
+ }
53
+
54
+ out.extend_from_slice(&input[start..]);
55
+ out
56
+ }
57
+
58
+ #[napi]
59
+ pub fn url_encode(input: Uint8Array) -> Buffer {
60
+ Buffer::from(url_encode_bytes(input.as_ref()))
61
+ }
62
+
63
+ /// Percent-encode a JS string directly → encoded string. Skips the Uint8Array
64
+ /// round-trip of `url_encode` for string callers. Percent-encoded output is
65
+ /// always ASCII, so no UTF-8 validation is needed.
66
+ #[napi]
67
+ pub fn url_encode_str(input: String) -> String {
68
+ let bytes = url_encode_bytes(input.as_bytes());
69
+ // SAFETY: percent-encoded output is ASCII (unreserved chars + %XX hex), so
70
+ // the bytes are valid UTF-8 by construction.
71
+ unsafe { String::from_utf8_unchecked(bytes) }
72
+ }
73
+
74
+ /// Percent-decode a JS string → decoded string. Equivalent to `url_decode` on
75
+ /// the string's UTF-8 bytes; invalid UTF-8 output throws.
76
+ #[napi]
77
+ pub fn url_decode_str(input: String) -> Result<String> {
78
+ let (out, saw_high) = url_decode_bytes_vec(input.as_bytes())?;
79
+ if saw_high {
80
+ simdutf8::basic::from_utf8(&out).map_err(|e| Error::from_reason(e.to_string()))?;
81
+ }
82
+ // SAFETY: either the output is pure ASCII (no high bit observed) or it was
83
+ // validated by simdutf8 above.
84
+ Ok(unsafe { String::from_utf8_unchecked(out) })
85
+ }
86
+
87
+ /// Packed percent-encode batch: `[u32 count]{[u32 len][data]}` in →
88
+ /// `[u32 count]{[u32 len][encoded]}` out.
89
+ #[napi]
90
+ pub fn url_encode_batch_packed(input: Uint8Array) -> Result<Buffer> {
91
+ crate::util::run_packed_batch(input.as_ref(), url_encode_bytes).map(Buffer::from)
92
+ }
93
+
94
+ #[napi]
95
+ pub fn url_decode(input: Uint8Array) -> Result<Buffer> {
96
+ let (out, saw_high) = url_decode_bytes_vec(input.as_ref())?;
97
+ // The single decode pass already told us whether any output byte is
98
+ // >= 0x80. ASCII output is trivially valid UTF-8, so the full simdutf8
99
+ // validation pass is only needed when a high bit was observed.
100
+ if saw_high {
101
+ simdutf8::basic::from_utf8(&out).map_err(|e| Error::from_reason(e.to_string()))?;
102
+ }
103
+ Ok(Buffer::from(out))
104
+ }
105
+
106
+ /// Packed percent-decode batch (UTF-8 validated). Items that fail decoding
107
+ /// (malformed %XX or invalid UTF-8) yield an empty result — skip-on-error,
108
+ /// matching the other decode batches.
109
+ #[napi]
110
+ pub fn url_decode_batch_packed(input: Uint8Array) -> Result<Buffer> {
111
+ crate::util::run_packed_batch(input.as_ref(), |v| match url_decode_bytes_vec(v) {
112
+ Ok((out, saw_high)) => {
113
+ if saw_high && simdutf8::basic::from_utf8(&out).is_err() {
114
+ Vec::new()
115
+ } else {
116
+ out
117
+ }
118
+ }
119
+ Err(_) => Vec::new(),
120
+ })
121
+ .map(Buffer::from)
122
+ }
123
+
124
+ /// Packed strict percent-decode batch (no UTF-8 validation).
125
+ #[napi]
126
+ pub fn url_decode_bytes_batch_packed(input: Uint8Array) -> Result<Buffer> {
127
+ crate::util::run_packed_batch(input.as_ref(), |v| {
128
+ url_decode_bytes_vec(v)
129
+ .map(|(out, _)| out)
130
+ .unwrap_or_default()
131
+ })
132
+ .map(Buffer::from)
133
+ }
134
+
135
+ pub fn url_encode_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
136
+ let mut pos = 0usize;
137
+ let mut start = 0usize;
138
+
139
+ for (i, &b) in input.iter().enumerate() {
140
+ if !is_unreserved(b) {
141
+ write_bytes(out, &mut pos, &input[start..i])?;
142
+ ensure_capacity(out, pos, 3)?;
143
+ out[pos] = b'%';
144
+ out[pos + 1] = HEX_UPPER[(b >> 4) as usize];
145
+ out[pos + 2] = HEX_UPPER[(b & 0x0f) as usize];
146
+ pos += 3;
147
+ start = i + 1;
148
+ }
149
+ }
150
+
151
+ write_bytes(out, &mut pos, &input[start..])?;
152
+ Ok(pos)
153
+ }
154
+
155
+ #[napi]
156
+ pub fn url_encode_into(input: Uint8Array, mut output: Uint8Array) -> Result<u32> {
157
+ crate::util::run_packed_into(&input, &mut output, url_encode_into_slice)
158
+ }
159
+
160
+ #[inline]
161
+ fn url_decode_bytes_vec(input: &[u8]) -> Result<(Vec<u8>, bool)> {
162
+ let mut out = Vec::with_capacity(input.len());
163
+ let mut pos = 0usize;
164
+ let mut saw_high = false;
165
+
166
+ while let Some(rel) = memchr(b'%', &input[pos..]) {
167
+ let i = pos + rel;
168
+ let run = &input[pos..i];
169
+ // Copied runs are passed through verbatim; check them for high bytes.
170
+ if !run.is_ascii() {
171
+ saw_high = true;
172
+ }
173
+ out.extend_from_slice(run);
174
+
175
+ let (byte, next) = decode_percent_at(input, i)
176
+ .ok_or_else(|| Error::from_reason("invalid %-encoding: malformed %XX sequence"))?;
177
+ // `%XX` can decode to a non-ASCII byte (e.g. `%C3%A9`); track it.
178
+ if byte >= 0x80 {
179
+ saw_high = true;
180
+ }
181
+ out.push(byte);
182
+ pos = next;
183
+ }
184
+
185
+ let tail = &input[pos..];
186
+ if !tail.is_ascii() {
187
+ saw_high = true;
188
+ }
189
+ out.extend_from_slice(tail);
190
+ Ok((out, saw_high))
191
+ }
192
+
193
+ #[napi]
194
+ pub fn url_decode_bytes(input: Uint8Array) -> Result<Buffer> {
195
+ let (out, _saw_high) = url_decode_bytes_vec(input.as_ref())?;
196
+ Ok(Buffer::from(out))
197
+ }
198
+
199
+ pub fn url_decode_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
200
+ let mut pos = 0usize;
201
+ let mut src_pos = 0usize;
202
+
203
+ while let Some(rel) = memchr(b'%', &input[src_pos..]) {
204
+ let i = src_pos + rel;
205
+ write_bytes(out, &mut pos, &input[src_pos..i])?;
206
+
207
+ let (byte, next) = decode_percent_at(input, i)
208
+ .ok_or_else(|| Error::from_reason("invalid %-encoding: malformed %XX sequence"))?;
209
+
210
+ ensure_capacity(out, pos, 1)?;
211
+ out[pos] = byte;
212
+ pos += 1;
213
+ src_pos = next;
214
+ }
215
+
216
+ write_bytes(out, &mut pos, &input[src_pos..])?;
217
+ Ok(pos)
218
+ }
219
+
220
+ #[napi]
221
+ pub fn url_decode_into(input: Uint8Array, mut output: Uint8Array) -> Result<u32> {
222
+ crate::util::run_packed_into(&input, &mut output, url_decode_into_slice)
223
+ }
224
+
225
+ #[cfg(test)]
226
+ mod tests {
227
+ use super::*;
228
+
229
+ #[test]
230
+ fn url_encode_reserved_and_unreserved() {
231
+ let mut out = vec![0u8; 64];
232
+ let n = url_encode_into_slice(b"/a b?c=d", &mut out).unwrap();
233
+ // '/', space, '?', '=' are all percent-encoded; alphanumerics are not.
234
+ assert_eq!(&out[..n], b"%2Fa%20b%3Fc%3Dd");
235
+ }
236
+
237
+ #[test]
238
+ fn url_decode_roundtrip() {
239
+ let mut out = vec![0u8; 64];
240
+ let n = url_decode_into_slice(b"%2Fa%20b%3Fc%3Dd", &mut out).unwrap();
241
+ assert_eq!(&out[..n], b"/a b?c=d");
242
+ }
243
+
244
+ #[test]
245
+ fn url_decode_rejects_malformed_percent() {
246
+ let mut out = vec![0u8; 64];
247
+ assert!(url_decode_into_slice(b"%ZZ", &mut out).is_err());
248
+ assert!(url_decode_into_slice(b"%2", &mut out).is_err());
249
+ }
250
+
251
+ #[test]
252
+ fn url_encode_allocating_matches_into_slice() {
253
+ for input in [
254
+ b"hello world & foo=bar".as_slice(),
255
+ b"%2Fa%20b%3Fc%3Dd",
256
+ b"a%20b%20c%20d",
257
+ b"".as_slice(),
258
+ b"!~*'()",
259
+ b"\xff\x00\x80",
260
+ ] {
261
+ let allocating = url_encode_bytes(input);
262
+ let mut out = vec![0u8; input.len() * 3 + 1];
263
+ let n = url_encode_into_slice(input, &mut out).unwrap();
264
+ assert_eq!(allocating, &out[..n], "input: {:?}", input);
265
+ }
266
+ }
267
+ #[test]
268
+ fn url_encode_str_matches_bytes_path() {
269
+ for s in [
270
+ "hello world & foo=bar",
271
+ "!~*'()",
272
+ "",
273
+ "h%C3%A9llo",
274
+ "a/b?c=d#e",
275
+ ] {
276
+ let encoded = url_encode_str(s.to_string());
277
+ assert_eq!(
278
+ encoded.as_bytes(),
279
+ url_encode_bytes(s.as_bytes()),
280
+ "input: {s}"
281
+ );
282
+ }
283
+ }
284
+
285
+ #[test]
286
+ fn url_encode_str_roundtrips() {
287
+ let s = "hello world & foo=bar";
288
+ assert_eq!(url_decode_str(url_encode_str(s.to_string())).unwrap(), s);
289
+ }
290
+
291
+ #[test]
292
+ fn url_decode_str_rejects_invalid_utf8() {
293
+ // Decoded %FF is a lone high byte — not valid UTF-8.
294
+ assert!(url_decode_str("%FF".to_string()).is_err());
295
+ }
296
+ }