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.
- 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,881 @@
|
|
|
1
|
+
// rust/json/json_ser.rs — JSON serialization helpers extracted from ingress.rs
|
|
2
|
+
// Zero-alloc JSON escaping, cookie→JSON and query→JSON conversion
|
|
3
|
+
// Uses memchr-based skip for bulk unescaped bytes (~95% path)
|
|
4
|
+
|
|
5
|
+
use crate::util::bytes::{cookie_pairs, HEX_LOWER as JSON_HEX_LOWER};
|
|
6
|
+
|
|
7
|
+
/// True when every byte is ASCII (< 0x80), checked word-at-a-time.
|
|
8
|
+
///
|
|
9
|
+
/// Equivalent to `bytes.iter().all(|&b| b < 0x80)` but skips the per-byte
|
|
10
|
+
/// loop for the bulk of the buffer (one u64 high-bit test per 8 bytes).
|
|
11
|
+
/// `as_chunks` types each chunk as `[u8; 8]`, so the word conversion cannot
|
|
12
|
+
/// panic and the bounds check elides on the hot path.
|
|
13
|
+
#[inline(always)]
|
|
14
|
+
fn is_ascii(bytes: &[u8]) -> bool {
|
|
15
|
+
let (chunks, remainder) = bytes.as_chunks::<8>();
|
|
16
|
+
for word in chunks {
|
|
17
|
+
let w = u64::from_le_bytes(*word);
|
|
18
|
+
if (w & 0x8080_8080_8080_8080) != 0 {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
remainder.iter().all(|&b| b < 0x80)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Determine whether bytes are valid UTF-8 (cached via a bit check).
|
|
26
|
+
#[inline(always)]
|
|
27
|
+
fn is_valid_utf8(bytes: &[u8]) -> bool {
|
|
28
|
+
// Fast path: small ASCII-only strings are very common.
|
|
29
|
+
if bytes.is_empty() {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
// For pure ASCII we can skip the full simdutf8 check.
|
|
33
|
+
if is_ascii(bytes) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
std::str::from_utf8(bytes).is_ok()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Compute the JSON-escaped length without writing, validating UTF-8 in the
|
|
40
|
+
/// SAME pass as the memchr3 escape scan (previously `is_valid_utf8` + the
|
|
41
|
+
/// memchr3 scan were two full passes). Uses memchr to find special characters
|
|
42
|
+
/// in bulk.
|
|
43
|
+
///
|
|
44
|
+
/// # Correctness (matches `write_json_escaped`)
|
|
45
|
+
///
|
|
46
|
+
/// memchr3's needles (`"` 0x22, `\` 0x5C, `\n` 0x0A) are all < 0x80, and a valid
|
|
47
|
+
/// UTF-8 multi-byte sequence is made only of bytes >= 0x80, so an ASCII byte
|
|
48
|
+
/// can never appear inside a multi-byte sequence. Splitting the buffer at
|
|
49
|
+
/// memchr3 hits therefore never splits a multi-byte sequence, and
|
|
50
|
+
/// `str::from_utf8(whole)` is OK ⇔ every gap is OK. `write_json_escaped`
|
|
51
|
+
/// derives the same valid/invalid decision from `is_valid_utf8`, so the length
|
|
52
|
+
/// estimate stays an EXACT accounting of the written output (enforced by the
|
|
53
|
+
/// `write_json_escaped_never_overflows_exact_buffer` test).
|
|
54
|
+
#[inline(always)]
|
|
55
|
+
pub fn json_escaped_len(bytes: &[u8]) -> usize {
|
|
56
|
+
// Pure ASCII is valid UTF-8 by construction — skip gap validation and just
|
|
57
|
+
// do the escape accounting.
|
|
58
|
+
if is_ascii(bytes) {
|
|
59
|
+
return json_escaped_len_impl(bytes);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let mut total = bytes.len(); // start with all bytes as 1
|
|
63
|
+
let mut offset = 0usize;
|
|
64
|
+
|
|
65
|
+
while let Some(pos) = memchr::memchr3(b'"', b'\\', b'\n', &bytes[offset..]) {
|
|
66
|
+
let abs = offset + pos;
|
|
67
|
+
if std::str::from_utf8(&bytes[offset..abs]).is_err() {
|
|
68
|
+
// Invalid UTF-8 → the binary escape path escapes EVERY byte as
|
|
69
|
+
// `\u00XX` (6 bytes each), matching `write_json_escaped`.
|
|
70
|
+
return bytes.len().saturating_mul(6);
|
|
71
|
+
}
|
|
72
|
+
// Control chars before the special are escaped too — they contribute
|
|
73
|
+
// the same extra count the writer emits. This keeps the length an
|
|
74
|
+
// EXACT accounting of the escaped output, so the output is always
|
|
75
|
+
// RFC-8259-valid and never depends on memchr3's needle set.
|
|
76
|
+
total += control_escape_extra(&bytes[offset..abs]);
|
|
77
|
+
|
|
78
|
+
// memchr3 only matches ", \, \n — every hit is a 2-byte escape (+1).
|
|
79
|
+
total += 1;
|
|
80
|
+
|
|
81
|
+
offset = abs + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Trailing run after the last special — may contain control chars.
|
|
85
|
+
if std::str::from_utf8(&bytes[offset..]).is_err() {
|
|
86
|
+
return bytes.len().saturating_mul(6);
|
|
87
|
+
}
|
|
88
|
+
total += control_escape_extra(&bytes[offset..]);
|
|
89
|
+
total
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Extra bytes (beyond the base 1 already counted per input byte) required to
|
|
93
|
+
/// escape control characters in a run that contains NO `"`, `\` or `\n`
|
|
94
|
+
/// (those three memchr3 specials are accounted for by the caller).
|
|
95
|
+
///
|
|
96
|
+
/// - `\r` (0x0d), `\t` (0x09), 0x08 and 0x0c become 2-byte escapes → +1.
|
|
97
|
+
/// - every other control char (< 0x20) becomes `\uXXXX` (6 bytes → +5).
|
|
98
|
+
#[inline(always)]
|
|
99
|
+
fn control_escape_extra(run: &[u8]) -> usize {
|
|
100
|
+
let mut extra = 0;
|
|
101
|
+
for &b in run {
|
|
102
|
+
if b < 0x20 {
|
|
103
|
+
extra += if b == b'\r' || b == b'\t' || b == 0x08 || b == 0x0c {
|
|
104
|
+
1
|
|
105
|
+
} else {
|
|
106
|
+
5
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
extra
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Inner: assumes valid UTF-8, uses memchr for skip.
|
|
114
|
+
#[inline(always)]
|
|
115
|
+
fn json_escaped_len_impl(bytes: &[u8]) -> usize {
|
|
116
|
+
let mut total = bytes.len(); // start with all bytes as 1
|
|
117
|
+
let mut offset = 0;
|
|
118
|
+
|
|
119
|
+
// Use memchr3 to find any of the special chars: ", \, \n
|
|
120
|
+
while let Some(pos) = memchr::memchr3(b'"', b'\\', b'\n', &bytes[offset..]) {
|
|
121
|
+
let abs = offset + pos;
|
|
122
|
+
|
|
123
|
+
// Control chars before the special are escaped too — they contribute
|
|
124
|
+
// the same extra count the writer emits. This keeps the length an
|
|
125
|
+
// EXACT accounting of the escaped output, so the output is always
|
|
126
|
+
// RFC-8259-valid and never depends on memchr3's needle set.
|
|
127
|
+
total += control_escape_extra(&bytes[offset..abs]);
|
|
128
|
+
|
|
129
|
+
// memchr3 only matches ", \, \n — every hit is a 2-byte escape (+1).
|
|
130
|
+
total += 1;
|
|
131
|
+
|
|
132
|
+
offset = abs + 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Trailing run after the last special — may contain control chars.
|
|
136
|
+
total += control_escape_extra(&bytes[offset..]);
|
|
137
|
+
total
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Write JSON-escaped bytes into the buffer at the current position.
|
|
141
|
+
/// Returns the number of bytes written.
|
|
142
|
+
#[inline(always)]
|
|
143
|
+
pub fn write_json_escaped(out: &mut [u8], pos: &mut usize, bytes: &[u8]) -> usize {
|
|
144
|
+
let start = *pos;
|
|
145
|
+
if is_valid_utf8(bytes) {
|
|
146
|
+
write_json_escaped_utf8(out, pos, bytes);
|
|
147
|
+
} else {
|
|
148
|
+
// Binary: escape every byte as \u00XX
|
|
149
|
+
for &b in bytes {
|
|
150
|
+
out[*pos] = b'\\';
|
|
151
|
+
out[*pos + 1] = b'u';
|
|
152
|
+
out[*pos + 2] = b'0';
|
|
153
|
+
out[*pos + 3] = b'0';
|
|
154
|
+
out[*pos + 4] = JSON_HEX_LOWER[(b >> 4) as usize];
|
|
155
|
+
out[*pos + 5] = JSON_HEX_LOWER[(b & 0x0f) as usize];
|
|
156
|
+
*pos += 6;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
*pos - start
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Write a run of bytes that contains no `"`, `\` or `\n`, escaping any
|
|
163
|
+
/// control characters (< 0x20) present. Assumes `out` has room for
|
|
164
|
+
/// `run.len() + control_escape_extra(run)` bytes.
|
|
165
|
+
#[inline(always)]
|
|
166
|
+
fn write_escaped_run(out: &mut [u8], pos: &mut usize, run: &[u8]) {
|
|
167
|
+
if run.is_empty() {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
let mut i = 0;
|
|
171
|
+
while i < run.len() {
|
|
172
|
+
let b = run[i];
|
|
173
|
+
match b {
|
|
174
|
+
b'\r' => {
|
|
175
|
+
out[*pos] = b'\\';
|
|
176
|
+
out[*pos + 1] = b'r';
|
|
177
|
+
*pos += 2;
|
|
178
|
+
i += 1;
|
|
179
|
+
}
|
|
180
|
+
b'\t' => {
|
|
181
|
+
out[*pos] = b'\\';
|
|
182
|
+
out[*pos + 1] = b't';
|
|
183
|
+
*pos += 2;
|
|
184
|
+
i += 1;
|
|
185
|
+
}
|
|
186
|
+
0x08 => {
|
|
187
|
+
out[*pos] = b'\\';
|
|
188
|
+
out[*pos + 1] = b'b';
|
|
189
|
+
*pos += 2;
|
|
190
|
+
i += 1;
|
|
191
|
+
}
|
|
192
|
+
0x0c => {
|
|
193
|
+
out[*pos] = b'\\';
|
|
194
|
+
out[*pos + 1] = b'f';
|
|
195
|
+
*pos += 2;
|
|
196
|
+
i += 1;
|
|
197
|
+
}
|
|
198
|
+
b if b < 0x20 => {
|
|
199
|
+
// Control character (< 0x20) → \u00XX
|
|
200
|
+
out[*pos] = b'\\';
|
|
201
|
+
out[*pos + 1] = b'u';
|
|
202
|
+
out[*pos + 2] = b'0';
|
|
203
|
+
out[*pos + 3] = b'0';
|
|
204
|
+
out[*pos + 4] = JSON_HEX_LOWER[(b >> 4) as usize];
|
|
205
|
+
out[*pos + 5] = JSON_HEX_LOWER[(b & 0x0f) as usize];
|
|
206
|
+
*pos += 6;
|
|
207
|
+
i += 1;
|
|
208
|
+
}
|
|
209
|
+
_ => {
|
|
210
|
+
// Bulk copy of safe bytes up to next control char.
|
|
211
|
+
let rest = &run[i..];
|
|
212
|
+
match rest.iter().position(|&c| c < 0x20) {
|
|
213
|
+
Some(n) => {
|
|
214
|
+
out[*pos..*pos + n].copy_from_slice(&rest[..n]);
|
|
215
|
+
*pos += n;
|
|
216
|
+
i += n;
|
|
217
|
+
}
|
|
218
|
+
None => {
|
|
219
|
+
out[*pos..*pos + rest.len()].copy_from_slice(rest);
|
|
220
|
+
*pos += rest.len();
|
|
221
|
+
i = run.len();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// Write escaped bytes for valid UTF-8 input.
|
|
230
|
+
#[inline(always)]
|
|
231
|
+
fn write_json_escaped_utf8(out: &mut [u8], pos: &mut usize, bytes: &[u8]) {
|
|
232
|
+
let mut offset = 0usize;
|
|
233
|
+
|
|
234
|
+
while let Some(mut idx) = memchr::memchr3(b'"', b'\\', b'\n', &bytes[offset..]) {
|
|
235
|
+
idx += offset;
|
|
236
|
+
|
|
237
|
+
// Copy the unescaped bytes before the special character, escaping any
|
|
238
|
+
// control chars memchr3 skipped — a raw control char (e.g. `\t` before
|
|
239
|
+
// a `"`) would produce RFC-8259-invalid JSON.
|
|
240
|
+
write_escaped_run(out, pos, &bytes[offset..idx]);
|
|
241
|
+
|
|
242
|
+
let b = bytes[idx];
|
|
243
|
+
if b == b'"' {
|
|
244
|
+
out[*pos] = b'\\';
|
|
245
|
+
out[*pos + 1] = b'"';
|
|
246
|
+
*pos += 2;
|
|
247
|
+
} else if b == b'\\' {
|
|
248
|
+
out[*pos] = b'\\';
|
|
249
|
+
out[*pos + 1] = b'\\';
|
|
250
|
+
*pos += 2;
|
|
251
|
+
} else {
|
|
252
|
+
// memchr3 only matches '"', '\\' and '\n'.
|
|
253
|
+
debug_assert_eq!(b, b'\n');
|
|
254
|
+
out[*pos] = b'\\';
|
|
255
|
+
out[*pos + 1] = b'n';
|
|
256
|
+
*pos += 2;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
offset = idx + 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Trailing run after the last special — may contain control chars.
|
|
263
|
+
write_escaped_run(out, pos, &bytes[offset..]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// Parse cookies from `input` and write JSON to `out` with max_pairs limit.
|
|
267
|
+
#[inline]
|
|
268
|
+
pub fn cookie_json_into_slice(
|
|
269
|
+
input: &[u8],
|
|
270
|
+
out: &mut [u8],
|
|
271
|
+
max_pairs: usize,
|
|
272
|
+
) -> std::result::Result<usize, String> {
|
|
273
|
+
if out.len() < 2 {
|
|
274
|
+
return Err("output buffer too small for cookie JSON".to_string());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
out[0] = b'{';
|
|
278
|
+
let mut pos = 1usize;
|
|
279
|
+
|
|
280
|
+
for (count, (name, value)) in cookie_pairs(input).take(max_pairs).enumerate() {
|
|
281
|
+
let needed =
|
|
282
|
+
(if count == 0 { 0 } else { 1 }) + 5 + json_escaped_len(name) + json_escaped_len(value);
|
|
283
|
+
|
|
284
|
+
if needed > out.len().saturating_sub(pos) {
|
|
285
|
+
return Err("output buffer too small for cookie JSON".to_string());
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if count != 0 {
|
|
289
|
+
out[pos] = b',';
|
|
290
|
+
pos += 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
out[pos] = b'"';
|
|
294
|
+
pos += 1;
|
|
295
|
+
write_json_escaped(out, &mut pos, name);
|
|
296
|
+
out[pos] = b'"';
|
|
297
|
+
pos += 1;
|
|
298
|
+
out[pos] = b':';
|
|
299
|
+
pos += 1;
|
|
300
|
+
out[pos] = b'"';
|
|
301
|
+
pos += 1;
|
|
302
|
+
write_json_escaped(out, &mut pos, value);
|
|
303
|
+
out[pos] = b'"';
|
|
304
|
+
pos += 1;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if pos + 1 > out.len() {
|
|
308
|
+
return Err("output buffer too small for cookie JSON".to_string());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
out[pos] = b'}';
|
|
312
|
+
pos += 1;
|
|
313
|
+
Ok(pos)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/// Parse packed query pairs and write JSON to `out` with max_pairs limit.
|
|
317
|
+
///
|
|
318
|
+
/// Test-only reference oracle: the ingress hot path uses the single-pass
|
|
319
|
+
/// `query_to_json_into_slice` (which superseded the two-step pipeline this
|
|
320
|
+
/// helper represents); `unit_tests.rs` keeps it as the byte-parity reference.
|
|
321
|
+
#[cfg(test)]
|
|
322
|
+
pub fn packed_pairs_to_json_into_slice(
|
|
323
|
+
packed: &[u8],
|
|
324
|
+
out: &mut [u8],
|
|
325
|
+
max_pairs: usize,
|
|
326
|
+
) -> std::result::Result<usize, String> {
|
|
327
|
+
let read_u32 = |data: &[u8], offset: usize| -> Option<u32> {
|
|
328
|
+
let slice = data.get(offset..offset + 4)?;
|
|
329
|
+
Some(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
if out.len() < 2 {
|
|
333
|
+
return Err("output buffer too small for packed pairs JSON".to_string());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if packed.len() < 4 {
|
|
337
|
+
out[0..2].copy_from_slice(b"{}");
|
|
338
|
+
return Ok(2);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let count =
|
|
342
|
+
read_u32(packed, 0).ok_or_else(|| "packed pairs: truncated count".to_string())? as usize;
|
|
343
|
+
|
|
344
|
+
out[0] = b'{';
|
|
345
|
+
let mut pos = 1usize;
|
|
346
|
+
let mut src = 4usize;
|
|
347
|
+
|
|
348
|
+
for (written_pairs, _) in (0..count).enumerate() {
|
|
349
|
+
if written_pairs >= max_pairs {
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let key_len = read_u32(packed, src)
|
|
354
|
+
.ok_or_else(|| "packed pairs: truncated key length".to_string())?
|
|
355
|
+
as usize;
|
|
356
|
+
src += 4;
|
|
357
|
+
|
|
358
|
+
if src + key_len > packed.len() {
|
|
359
|
+
return Err("packed pairs: truncated key".to_string());
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let key = &packed[src..src + key_len];
|
|
363
|
+
src += key_len;
|
|
364
|
+
|
|
365
|
+
let val_len = read_u32(packed, src)
|
|
366
|
+
.ok_or_else(|| "packed pairs: truncated value length".to_string())?
|
|
367
|
+
as usize;
|
|
368
|
+
src += 4;
|
|
369
|
+
|
|
370
|
+
if src + val_len > packed.len() {
|
|
371
|
+
return Err("packed pairs: truncated value".to_string());
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let val = &packed[src..src + val_len];
|
|
375
|
+
src += val_len;
|
|
376
|
+
|
|
377
|
+
let needed = (if written_pairs == 0 { 0 } else { 1 })
|
|
378
|
+
+ 5
|
|
379
|
+
+ json_escaped_len(key)
|
|
380
|
+
+ json_escaped_len(val);
|
|
381
|
+
|
|
382
|
+
if needed > out.len().saturating_sub(pos) {
|
|
383
|
+
return Err("output buffer too small for packed pairs JSON".to_string());
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if written_pairs != 0 {
|
|
387
|
+
out[pos] = b',';
|
|
388
|
+
pos += 1;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
out[pos] = b'"';
|
|
392
|
+
pos += 1;
|
|
393
|
+
write_json_escaped(out, &mut pos, key);
|
|
394
|
+
out[pos] = b'"';
|
|
395
|
+
pos += 1;
|
|
396
|
+
out[pos] = b':';
|
|
397
|
+
pos += 1;
|
|
398
|
+
out[pos] = b'"';
|
|
399
|
+
pos += 1;
|
|
400
|
+
write_json_escaped(out, &mut pos, val);
|
|
401
|
+
out[pos] = b'"';
|
|
402
|
+
pos += 1;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if pos + 1 > out.len() {
|
|
406
|
+
return Err("output buffer too small for packed pairs JSON".to_string());
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
out[pos] = b'}';
|
|
410
|
+
pos += 1;
|
|
411
|
+
Ok(pos)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/// Distinct failure modes for [`query_to_json_into_slice`], so the caller can
|
|
415
|
+
/// map malformed input → 400 and a too-small output buffer → truncated (the
|
|
416
|
+
/// same split the old packed pipeline had between its parse and write steps).
|
|
417
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
418
|
+
pub enum QueryJsonError {
|
|
419
|
+
Malformed,
|
|
420
|
+
BufferTooSmall,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/// Decode a single URL-form component (`+` → space, `%XX` → byte) into `out`.
|
|
424
|
+
///
|
|
425
|
+
/// Shared decoder with `query_parser::write_decoded_form_component` (this one
|
|
426
|
+
/// appends to a `Vec` rather than writing a length-prefixed slice); errors on
|
|
427
|
+
/// malformed `%XX`.
|
|
428
|
+
#[inline]
|
|
429
|
+
fn decode_query_component(
|
|
430
|
+
src: &[u8],
|
|
431
|
+
out: &mut Vec<u8>,
|
|
432
|
+
) -> std::result::Result<(), QueryJsonError> {
|
|
433
|
+
let start = out.len();
|
|
434
|
+
out.resize(start + src.len(), 0);
|
|
435
|
+
let written = crate::util::bytes::decode_form_component_into(src, &mut out[start..]).map_err(
|
|
436
|
+
|e| match e {
|
|
437
|
+
crate::util::bytes::FormDecodeError::Malformed => QueryJsonError::Malformed,
|
|
438
|
+
crate::util::bytes::FormDecodeError::BufferTooSmall => QueryJsonError::BufferTooSmall,
|
|
439
|
+
},
|
|
440
|
+
)?;
|
|
441
|
+
out.truncate(start + written);
|
|
442
|
+
Ok(())
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/// Parse a URL query string and write JSON directly to `out` with a
|
|
446
|
+
/// `max_pairs` limit — one pass over the raw query with a single reused
|
|
447
|
+
/// scratch buffer, no intermediate packed buffer and no second parse.
|
|
448
|
+
///
|
|
449
|
+
/// Semantics match the two-step `query_parse_packed_vec` +
|
|
450
|
+
/// `packed_pairs_to_json_into_slice` pipeline (which this replaces on the
|
|
451
|
+
/// ingress hot path): pairs split on `&`, `key=value` (no `=` → empty value),
|
|
452
|
+
/// `+`/`%XX` decoded, keys/values JSON-escaped (UTF-8-aware, identical to
|
|
453
|
+
/// `write_json_escaped`). `Malformed` (caller → 400) on bad `%XX`;
|
|
454
|
+
/// `BufferTooSmall` (caller → truncated) when `out` cannot hold the result.
|
|
455
|
+
#[inline]
|
|
456
|
+
pub fn query_to_json_into_slice(
|
|
457
|
+
input: &[u8],
|
|
458
|
+
out: &mut [u8],
|
|
459
|
+
max_pairs: usize,
|
|
460
|
+
) -> std::result::Result<usize, QueryJsonError> {
|
|
461
|
+
if out.len() < 2 {
|
|
462
|
+
return Err(QueryJsonError::BufferTooSmall);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
out[0] = b'{';
|
|
466
|
+
let mut pos = 1usize;
|
|
467
|
+
let mut written_pairs = 0usize;
|
|
468
|
+
|
|
469
|
+
// Shared scratch for percent-decoded components — reused across all pairs
|
|
470
|
+
// (grows only to the largest component; bounded by the caller's query
|
|
471
|
+
// limit), so there is no per-pair allocation.
|
|
472
|
+
let mut scratch: Vec<u8> = Vec::new();
|
|
473
|
+
|
|
474
|
+
for pair in input.split(|&b| b == b'&') {
|
|
475
|
+
if pair.is_empty() {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if written_pairs >= max_pairs {
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
let (key, value) = match pair.iter().position(|&b| b == b'=') {
|
|
482
|
+
Some(eq) => (&pair[..eq], &pair[eq + 1..]),
|
|
483
|
+
None => (pair, &[][..]),
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// Percent-encoding fast path: a component with no `%` and no `+`
|
|
487
|
+
// decodes to itself (Cow::Borrowed), so we skip the decode scratch and
|
|
488
|
+
// both extra passes, writing the borrowed slices straight to JSON. This
|
|
489
|
+
// is the common case (`?page=1&limit=20`-style queries) and removes one
|
|
490
|
+
// heap allocation + decode per pair per request on the ingress hot path.
|
|
491
|
+
if memchr::memchr2(b'%', b'+', pair).is_none() {
|
|
492
|
+
let key_len = json_escaped_len(key);
|
|
493
|
+
let val_len = json_escaped_len(value);
|
|
494
|
+
|
|
495
|
+
let needed = (if written_pairs == 0 { 0 } else { 1 }) + 5 + key_len + val_len;
|
|
496
|
+
if needed > out.len().saturating_sub(pos) {
|
|
497
|
+
return Err(QueryJsonError::BufferTooSmall);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if written_pairs != 0 {
|
|
501
|
+
out[pos] = b',';
|
|
502
|
+
pos += 1;
|
|
503
|
+
}
|
|
504
|
+
out[pos] = b'"';
|
|
505
|
+
pos += 1;
|
|
506
|
+
write_json_escaped(out, &mut pos, key);
|
|
507
|
+
out[pos] = b'"';
|
|
508
|
+
pos += 1;
|
|
509
|
+
out[pos] = b':';
|
|
510
|
+
pos += 1;
|
|
511
|
+
out[pos] = b'"';
|
|
512
|
+
pos += 1;
|
|
513
|
+
write_json_escaped(out, &mut pos, value);
|
|
514
|
+
out[pos] = b'"';
|
|
515
|
+
pos += 1;
|
|
516
|
+
|
|
517
|
+
written_pairs += 1;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Single-pass percent-decode: decode each component ONCE into the
|
|
522
|
+
// shared scratch as `[decoded_key][decoded_value]`, then derive the
|
|
523
|
+
// escaped lengths and write from the same buffers. (The previous code
|
|
524
|
+
// decoded every component twice — once in a length pass, once in a
|
|
525
|
+
// write pass — i.e. four decodes per pair.)
|
|
526
|
+
scratch.clear();
|
|
527
|
+
decode_query_component(key, &mut scratch)?;
|
|
528
|
+
let key_end = scratch.len();
|
|
529
|
+
decode_query_component(value, &mut scratch)?;
|
|
530
|
+
let key_len = json_escaped_len(&scratch[..key_end]);
|
|
531
|
+
let val_len = json_escaped_len(&scratch[key_end..]);
|
|
532
|
+
|
|
533
|
+
let needed = (if written_pairs == 0 { 0 } else { 1 }) + 5 + key_len + val_len;
|
|
534
|
+
if needed > out.len().saturating_sub(pos) {
|
|
535
|
+
return Err(QueryJsonError::BufferTooSmall);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Write pass (from the already-decoded scratch buffers).
|
|
539
|
+
if written_pairs != 0 {
|
|
540
|
+
out[pos] = b',';
|
|
541
|
+
pos += 1;
|
|
542
|
+
}
|
|
543
|
+
out[pos] = b'"';
|
|
544
|
+
pos += 1;
|
|
545
|
+
write_json_escaped(out, &mut pos, &scratch[..key_end]);
|
|
546
|
+
out[pos] = b'"';
|
|
547
|
+
pos += 1;
|
|
548
|
+
out[pos] = b':';
|
|
549
|
+
pos += 1;
|
|
550
|
+
out[pos] = b'"';
|
|
551
|
+
pos += 1;
|
|
552
|
+
write_json_escaped(out, &mut pos, &scratch[key_end..]);
|
|
553
|
+
out[pos] = b'"';
|
|
554
|
+
pos += 1;
|
|
555
|
+
|
|
556
|
+
written_pairs += 1;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if pos + 1 > out.len() {
|
|
560
|
+
return Err(QueryJsonError::BufferTooSmall);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
out[pos] = b'}';
|
|
564
|
+
pos += 1;
|
|
565
|
+
Ok(pos)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/// Write the full metadata JSON envelope (requestId, path, cookies, query).
|
|
569
|
+
///
|
|
570
|
+
/// The metadata envelope legitimately carries many fields; the flat signature
|
|
571
|
+
/// keeps the writer allocation-free (no options struct on the hot path).
|
|
572
|
+
#[allow(clippy::too_many_arguments)]
|
|
573
|
+
#[inline]
|
|
574
|
+
pub fn write_full_body_json(
|
|
575
|
+
out: &mut [u8],
|
|
576
|
+
pos: usize,
|
|
577
|
+
request_id: &[u8],
|
|
578
|
+
path: &[u8],
|
|
579
|
+
cookies_start: usize,
|
|
580
|
+
cookies_len: usize,
|
|
581
|
+
query_start: usize,
|
|
582
|
+
query_len: usize,
|
|
583
|
+
) -> usize {
|
|
584
|
+
const P1: &[u8] = b"{\"ok\":true,\"requestId\":\"";
|
|
585
|
+
const P2: &[u8] = b"\",\"path\":\"";
|
|
586
|
+
const P3: &[u8] = b"\",\"cookies\":";
|
|
587
|
+
const P4: &[u8] = b",\"query\":";
|
|
588
|
+
|
|
589
|
+
let cookies_eff_len = if cookies_len > 0 { cookies_len } else { 2 };
|
|
590
|
+
let query_eff_len = if query_len > 0 { query_len } else { 2 };
|
|
591
|
+
|
|
592
|
+
let required = P1.len()
|
|
593
|
+
+ json_escaped_len(request_id)
|
|
594
|
+
+ P2.len()
|
|
595
|
+
+ json_escaped_len(path)
|
|
596
|
+
+ P3.len()
|
|
597
|
+
+ cookies_eff_len
|
|
598
|
+
+ P4.len()
|
|
599
|
+
+ query_eff_len
|
|
600
|
+
+ 1;
|
|
601
|
+
|
|
602
|
+
let end = match pos.checked_add(required) {
|
|
603
|
+
Some(v) => v,
|
|
604
|
+
None => return 0,
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
if end > out.len() {
|
|
608
|
+
return 0;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
let mut wp = pos;
|
|
612
|
+
out[wp..wp + P1.len()].copy_from_slice(P1);
|
|
613
|
+
wp += P1.len();
|
|
614
|
+
write_json_escaped(out, &mut wp, request_id);
|
|
615
|
+
out[wp..wp + P2.len()].copy_from_slice(P2);
|
|
616
|
+
wp += P2.len();
|
|
617
|
+
write_json_escaped(out, &mut wp, path);
|
|
618
|
+
out[wp..wp + P3.len()].copy_from_slice(P3);
|
|
619
|
+
wp += P3.len();
|
|
620
|
+
|
|
621
|
+
if cookies_len > 0 {
|
|
622
|
+
out.copy_within(cookies_start..cookies_start + cookies_len, wp);
|
|
623
|
+
wp += cookies_len;
|
|
624
|
+
} else {
|
|
625
|
+
out[wp..wp + 2].copy_from_slice(b"{}");
|
|
626
|
+
wp += 2;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
out[wp..wp + P4.len()].copy_from_slice(P4);
|
|
630
|
+
wp += P4.len();
|
|
631
|
+
|
|
632
|
+
if query_len > 0 {
|
|
633
|
+
out.copy_within(query_start..query_start + query_len, wp);
|
|
634
|
+
wp += query_len;
|
|
635
|
+
} else {
|
|
636
|
+
out[wp..wp + 2].copy_from_slice(b"{}");
|
|
637
|
+
wp += 2;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
out[wp] = b'}';
|
|
641
|
+
wp += 1;
|
|
642
|
+
wp - pos
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
#[cfg(test)]
|
|
646
|
+
mod tests {
|
|
647
|
+
use super::{
|
|
648
|
+
cookie_json_into_slice, json_escaped_len, packed_pairs_to_json_into_slice,
|
|
649
|
+
query_to_json_into_slice, write_json_escaped, QueryJsonError,
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
#[test]
|
|
653
|
+
fn json_escaped_len_plain_ascii() {
|
|
654
|
+
assert_eq!(json_escaped_len(b"hello world"), 11);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
#[test]
|
|
658
|
+
fn json_escaped_len_quotes_and_backslash() {
|
|
659
|
+
// "a\"b" -> a, \", b = 3 chars + 1 extra for the escaped quote.
|
|
660
|
+
assert_eq!(json_escaped_len(b"a\"b"), 4);
|
|
661
|
+
// backslash doubles: bytes a, \, b = 3 -> 4 escaped.
|
|
662
|
+
assert_eq!(json_escaped_len(b"a\\b"), 4);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
#[test]
|
|
666
|
+
fn json_escaped_len_newline() {
|
|
667
|
+
assert_eq!(json_escaped_len(b"a\nb"), 4);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#[test]
|
|
671
|
+
fn json_escaped_len_control_char_wide() {
|
|
672
|
+
// 0x01 must be escaped as \u0001 -> 6 bytes for 1 input byte.
|
|
673
|
+
assert_eq!(json_escaped_len(&[b'a', 0x01, b'b']), 8);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
#[test]
|
|
677
|
+
fn json_escaped_len_short_control_escapes() {
|
|
678
|
+
// \r, \t, \x08, \x0c are written as 2-byte escapes, each contributing
|
|
679
|
+
// +1. (memchr3 only finds ", \, \n; the run scanner handles these.)
|
|
680
|
+
assert_eq!(json_escaped_len(b"a\rb"), 4); // a, \r, b
|
|
681
|
+
assert_eq!(json_escaped_len(b"a\tb"), 4);
|
|
682
|
+
assert_eq!(json_escaped_len(&[b'a', 0x08, b'b']), 4);
|
|
683
|
+
assert_eq!(json_escaped_len(&[b'a', 0x0c, b'b']), 4);
|
|
684
|
+
// Mixed: newline (memchr3 path) + tab (trailing path).
|
|
685
|
+
assert_eq!(json_escaped_len(b"a\n\tb"), 6);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
#[test]
|
|
689
|
+
fn json_escaped_len_fused_matches_write_on_utf8_corpus() {
|
|
690
|
+
// The fused len (single pass: memchr3 + gap UTF-8 validation) must exactly
|
|
691
|
+
// equal the bytes `write_json_escaped` emits for valid non-ASCII UTF-8 AND
|
|
692
|
+
// invalid UTF-8 (where every byte becomes \u00XX).
|
|
693
|
+
let cases: &[&[u8]] = &[
|
|
694
|
+
"héllo wörld".as_bytes(), // valid non-ASCII, no escapes
|
|
695
|
+
"caf\u{00e9} \u{201c}quoted\u{201d}".as_bytes(), // valid with escapes
|
|
696
|
+
&[b'a', 0xC3, 0xA9, b'b'], // é valid
|
|
697
|
+
&[b'"', 0xC3, 0xA9, b'\\'], // escapes + multibyte
|
|
698
|
+
&[0xFF, 0xFE, b'a'], // invalid UTF-8
|
|
699
|
+
&[b'a', 0x80, b'b'], // lone continuation byte
|
|
700
|
+
&[0xC3, b' ', b'x'], // truncated multibyte
|
|
701
|
+
&[b'a', b'\\', 0xFF, b'\n', 0x01], // mixed invalid + escapes
|
|
702
|
+
"日本語のテキスト".as_bytes(), // pure multibyte, no ASCII
|
|
703
|
+
];
|
|
704
|
+
for input in cases {
|
|
705
|
+
let len = json_escaped_len(input);
|
|
706
|
+
let mut out = vec![0u8; len];
|
|
707
|
+
let mut pos = 0usize;
|
|
708
|
+
write_json_escaped(&mut out, &mut pos, input);
|
|
709
|
+
assert_eq!(pos, len, "len must match written for {input:?}");
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
#[test]
|
|
714
|
+
fn json_escaped_len_invalid_utf8_is_len_times_six() {
|
|
715
|
+
let cases: &[&[u8]] = &[
|
|
716
|
+
&[0xFF, 0xFE],
|
|
717
|
+
&[b'a', 0x80, b'b'],
|
|
718
|
+
&[0xC3, b' '],
|
|
719
|
+
&[b'a', b'\\', 0xFF],
|
|
720
|
+
];
|
|
721
|
+
for input in cases {
|
|
722
|
+
let len = json_escaped_len(input);
|
|
723
|
+
assert_eq!(len, input.len() * 6, "input: {input:?}");
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
#[test]
|
|
728
|
+
fn write_json_escaped_never_overflows_exact_buffer() {
|
|
729
|
+
// Regression: a buffer sized EXACTLY by json_escaped_len must never
|
|
730
|
+
// overflow. Before the fix, \r/\t/\x08/\x0c were undercounted → the write
|
|
731
|
+
// past the end panicked (caught by napi → 500) or corrupted memory.
|
|
732
|
+
let cases: &[&[u8]] = &[
|
|
733
|
+
b"a\rb",
|
|
734
|
+
b"a\tb",
|
|
735
|
+
&[b'a', 0x08, b'b'],
|
|
736
|
+
&[b'a', 0x0c, b'b'],
|
|
737
|
+
b"cookie=1; other=2\r\n\t",
|
|
738
|
+
b"\t\r\x08\x0c\"\\\n\x01\x1f",
|
|
739
|
+
];
|
|
740
|
+
|
|
741
|
+
for input in cases {
|
|
742
|
+
let len = json_escaped_len(input);
|
|
743
|
+
let mut out = vec![0u8; len];
|
|
744
|
+
let mut pos = 0usize;
|
|
745
|
+
write_json_escaped(&mut out, &mut pos, input);
|
|
746
|
+
assert_eq!(
|
|
747
|
+
pos, len,
|
|
748
|
+
"must write exactly json_escaped_len bytes: {input:?}"
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
#[test]
|
|
754
|
+
fn write_json_escaped_escapes_control_before_special() {
|
|
755
|
+
// Regression: control chars that appear BEFORE a memchr3 special (", \,
|
|
756
|
+
// \n) must still be escaped. Previously they were copied raw into the JSON
|
|
757
|
+
// string → RFC-8259-invalid output (e.g. a cookie value `a\tb"c` or a URL
|
|
758
|
+
// query `?q=%09%22`). The length accounting must match the write exactly.
|
|
759
|
+
let cases: &[(&[u8], &[u8])] = &[
|
|
760
|
+
// a\rb"c → a \\r b \"
|
|
761
|
+
(
|
|
762
|
+
b"a\rb\"c",
|
|
763
|
+
b"a\\rb\\\"c", // \r → \\r, " → \"
|
|
764
|
+
),
|
|
765
|
+
// a\tb\\c → a \t b \ \
|
|
766
|
+
(
|
|
767
|
+
b"a\tb\\c",
|
|
768
|
+
b"a\\tb\\\\c", // \t → \\t, \ → \\
|
|
769
|
+
),
|
|
770
|
+
// 0x01 before \n → \u0001 then \n
|
|
771
|
+
(b"a\x01b\nc", b"a\\u0001b\\nc"),
|
|
772
|
+
];
|
|
773
|
+
|
|
774
|
+
for (input, expected) in cases {
|
|
775
|
+
let len = json_escaped_len(input);
|
|
776
|
+
let mut out = vec![0u8; len];
|
|
777
|
+
let mut pos = 0usize;
|
|
778
|
+
write_json_escaped(&mut out, &mut pos, input);
|
|
779
|
+
assert_eq!(pos, len, "accounting must be exact for {input:?}");
|
|
780
|
+
assert_eq!(
|
|
781
|
+
&out[..pos],
|
|
782
|
+
*expected,
|
|
783
|
+
"output must be valid JSON for {input:?}"
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
#[test]
|
|
789
|
+
fn cookie_json_into_slice_short_control_escapes() {
|
|
790
|
+
// Cookie values containing \r/\t must serialize correctly into a buffer
|
|
791
|
+
// sized by the (fixed) length accounting.
|
|
792
|
+
let mut out = vec![0u8; 256];
|
|
793
|
+
let written = cookie_json_into_slice(b"a=va\tl; b=x\ry", &mut out, 100).unwrap();
|
|
794
|
+
assert_eq!(&out[..written], b"{\"a\":\"va\\tl\",\"b\":\"x\\ry\"}");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
#[test]
|
|
798
|
+
fn cookie_json_into_slice_output() {
|
|
799
|
+
let mut out = vec![0u8; 256];
|
|
800
|
+
let written = cookie_json_into_slice(b"a=1; b=hello world", &mut out, 100).unwrap();
|
|
801
|
+
assert_eq!(&out[..written], b"{\"a\":\"1\",\"b\":\"hello world\"}");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
#[test]
|
|
805
|
+
fn cookie_json_into_slice_unwraps_dquote() {
|
|
806
|
+
let mut out = vec![0u8; 256];
|
|
807
|
+
// RFC 6265 §5.2: a DQUOTE-wrapped cookie value is unwrapped before
|
|
808
|
+
// serializing (matches the JS fallback + the native cookie parser).
|
|
809
|
+
let written = cookie_json_into_slice(b"k=\"v\"", &mut out, 100).unwrap();
|
|
810
|
+
assert_eq!(&out[..written], b"{\"k\":\"v\"}");
|
|
811
|
+
// JSON escaping still applies to unquoted values with special characters.
|
|
812
|
+
let written = cookie_json_into_slice(b"k=a\"b", &mut out, 100).unwrap();
|
|
813
|
+
assert_eq!(&out[..written], b"{\"k\":\"a\\\"b\"}");
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
#[test]
|
|
817
|
+
fn cookie_json_into_slice_small_buffer_errors() {
|
|
818
|
+
let mut out = vec![0u8; 8];
|
|
819
|
+
let res = cookie_json_into_slice(b"a=1; b=2; c=3; d=4", &mut out, 100);
|
|
820
|
+
assert!(
|
|
821
|
+
res.is_err(),
|
|
822
|
+
"truncation must surface as an error, not silent data loss"
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
#[test]
|
|
827
|
+
fn packed_pairs_to_json_into_slice_output() {
|
|
828
|
+
// Build packed query pairs for a=1 & b=2 via the query parser, then serialize.
|
|
829
|
+
let packed = crate::http::query_parser::query_parse_packed_vec(b"a=1&b=2").unwrap();
|
|
830
|
+
let mut out = vec![0u8; 256];
|
|
831
|
+
let written = packed_pairs_to_json_into_slice(&packed, &mut out, 100).unwrap();
|
|
832
|
+
assert_eq!(&out[..written], b"{\"a\":\"1\",\"b\":\"2\"}");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
#[test]
|
|
836
|
+
fn query_to_json_into_slice_matches_packed_pipeline() {
|
|
837
|
+
// The direct writer must produce byte-identical output to the two-step
|
|
838
|
+
// query_parse_packed_vec + packed_pairs_to_json_into_slice pipeline it
|
|
839
|
+
// replaces on the ingress hot path.
|
|
840
|
+
let cases: &[&[u8]] = &[
|
|
841
|
+
b"a=1&b=2",
|
|
842
|
+
b"name=John%20Doe&q=a+b",
|
|
843
|
+
b"flag",
|
|
844
|
+
b"",
|
|
845
|
+
b"x=%41%42",
|
|
846
|
+
b"a=%ZZ",
|
|
847
|
+
b"k=%E2%82%AC", // euro (valid UTF-8 after decode)
|
|
848
|
+
b"weird=%FF", // invalid UTF-8 after decode (binary escape path)
|
|
849
|
+
b"a=1&a=2&a=3",
|
|
850
|
+
b"spaces=+a+b+c+",
|
|
851
|
+
];
|
|
852
|
+
for &raw in cases {
|
|
853
|
+
let packed = crate::http::query_parser::query_parse_packed_vec(raw);
|
|
854
|
+
let mut direct_out = vec![0u8; 512];
|
|
855
|
+
let direct = query_to_json_into_slice(raw, &mut direct_out, 100);
|
|
856
|
+
match (&packed, &direct) {
|
|
857
|
+
(Ok(packed), Ok(written)) => {
|
|
858
|
+
let mut ref_out = vec![0u8; 512];
|
|
859
|
+
let ref_written =
|
|
860
|
+
packed_pairs_to_json_into_slice(packed, &mut ref_out, 100).unwrap();
|
|
861
|
+
assert_eq!(
|
|
862
|
+
&direct_out[..*written],
|
|
863
|
+
&ref_out[..ref_written],
|
|
864
|
+
"query={raw:?}"
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
(Err(_), Err(QueryJsonError::Malformed)) => {} // both reject malformed %XX
|
|
868
|
+
(other, _) => {
|
|
869
|
+
panic!("mismatched outcome for query={raw:?}: {other:?} vs {direct:?}")
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Buffer-too-small must surface as BufferTooSmall (→ truncated), not Malformed.
|
|
875
|
+
let mut tiny = vec![0u8; 2];
|
|
876
|
+
assert!(matches!(
|
|
877
|
+
query_to_json_into_slice(b"a=1", &mut tiny, 100),
|
|
878
|
+
Err(QueryJsonError::BufferTooSmall)
|
|
879
|
+
));
|
|
880
|
+
}
|
|
881
|
+
}
|