castrum 0.9.9 → 0.9.11

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/rust/ffi/util.rs CHANGED
@@ -122,6 +122,19 @@ pub(crate) fn panic_guard<T>(f: impl FnOnce() -> T, fallback: T) -> T {
122
122
  std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(fallback)
123
123
  }
124
124
 
125
+ /// Wrap a `#[no_mangle] extern "C"` body so a panic returns `fallback`
126
+ /// instead of unwinding into the host runtime. Every C-ABI export uses this
127
+ /// (a source-level test in `ffi/tests.rs` enforces it).
128
+ ///
129
+ /// `return` inside the block returns from the guarded closure, so the bodies
130
+ /// stay readable.
131
+ macro_rules! c_abi {
132
+ ($fallback:expr, $body:block) => {
133
+ $crate::ffi::util::panic_guard(|| $body, $fallback)
134
+ };
135
+ }
136
+ pub(crate) use c_abi;
137
+
125
138
  /// Write the packed `[u8 allowed][u32 remaining LE][i64 reset_ms LE]` verdict;
126
139
  /// returns bytes written or the exact required size (13) when `out_cap` is too
127
140
  /// small. Never returns 0 on a valid call.
@@ -10,6 +10,7 @@
10
10
  // in JS and an engine re-encode — measured ~100 ns/call of pure waste;
11
11
  // guide rule "cstring args are a LOSS for BYTE inputs").
12
12
 
13
+ use super::util::c_abi;
13
14
  /// Email / UUID / IPv4 / IPv6 validators → 1/0 (`cstring` ARG shape).
14
15
  macro_rules! validator_c_abi {
15
16
  ($name:ident, $core:path) => {
@@ -19,10 +20,12 @@ macro_rules! validator_c_abi {
19
20
  /// `data` must be a valid NUL-terminated C string.
20
21
  #[no_mangle]
21
22
  pub unsafe extern "C" fn $name(data: *const std::os::raw::c_char) -> u8 {
22
- if data.is_null() {
23
- return 0;
24
- }
25
- u8::from($core(std::ffi::CStr::from_ptr(data).to_bytes()))
23
+ c_abi!(0, {
24
+ if data.is_null() {
25
+ return 0;
26
+ }
27
+ u8::from($core(std::ffi::CStr::from_ptr(data).to_bytes()))
28
+ })
26
29
  }
27
30
  };
28
31
  }
@@ -40,15 +43,17 @@ macro_rules! validator_bytes_c_abi {
40
43
  /// `data` must be valid for reads of `len` bytes.
41
44
  #[no_mangle]
42
45
  pub unsafe extern "C" fn $name(data: *const u8, len: usize) -> u8 {
43
- if data.is_null() && len != 0 {
44
- return 0;
45
- }
46
- let bytes = if len == 0 {
47
- &[]
48
- } else {
49
- std::slice::from_raw_parts(data, len)
50
- };
51
- u8::from($core(bytes))
46
+ c_abi!(0, {
47
+ if data.is_null() && len != 0 {
48
+ return 0;
49
+ }
50
+ let bytes = if len == 0 {
51
+ &[]
52
+ } else {
53
+ std::slice::from_raw_parts(data, len)
54
+ };
55
+ u8::from($core(bytes))
56
+ })
52
57
  }
53
58
  };
54
59
  }
@@ -103,21 +108,23 @@ pub unsafe extern "C" fn castrum_hex_validate_batch(
103
108
  out: *mut u8,
104
109
  out_cap: usize,
105
110
  ) -> usize {
106
- if data.is_null() || out.is_null() {
107
- return 0;
108
- }
109
- let input = std::slice::from_raw_parts(data, len);
110
- let needed = crate::util::validation::hex_batch_count(input);
111
- if needed > out_cap {
112
- return needed;
113
- }
114
- // `needed` verdict bytes fit — one bounded scratch Vec, one copy out.
115
- let mut buf = Vec::with_capacity(needed);
116
- match crate::util::validation::hex_batch_valid_into(input, width as usize, &mut buf) {
117
- Ok(()) if buf.len() <= out_cap => {
118
- std::slice::from_raw_parts_mut(out, buf.len()).copy_from_slice(&buf);
119
- buf.len()
111
+ c_abi!(0, {
112
+ if data.is_null() || out.is_null() {
113
+ return 0;
114
+ }
115
+ let input = std::slice::from_raw_parts(data, len);
116
+ let needed = crate::util::validation::hex_batch_count(input);
117
+ if needed > out_cap {
118
+ return needed;
119
+ }
120
+ // `needed` verdict bytes fit — one bounded scratch Vec, one copy out.
121
+ let mut buf = Vec::with_capacity(needed);
122
+ match crate::util::validation::hex_batch_valid_into(input, width as usize, &mut buf) {
123
+ Ok(()) if buf.len() <= out_cap => {
124
+ std::slice::from_raw_parts_mut(out, buf.len()).copy_from_slice(&buf);
125
+ buf.len()
126
+ }
127
+ _ => 0,
120
128
  }
121
- _ => 0,
122
- }
129
+ })
123
130
  }
@@ -4,6 +4,10 @@
4
4
  // fixed-width writer for hot loops. The date arithmetic uses Howard Hinnant's
5
5
  // days↔civil algorithms (epoch-correct). Extracted from `etag.rs` so the
6
6
  // HTTP cache semantics file owns only ETags + conditional requests.
7
+ //
8
+ // NOTE: we intentionally do NOT use the `httpdate` crate — it panics when
9
+ // formatting pre-1970 timestamps and years >= 10000, both of which this module
10
+ // supports (see `http_date_into_out_of_range_year_falls_back`).
7
11
 
8
12
  use napi::bindgen_prelude::*;
9
13
  use napi_derive::napi;
@@ -38,3 +38,194 @@ fn header_parser_do_not_panic_on_adversarial_packed_headers() {
38
38
  let _ = HeaderRefs::parse(&data, false, 200);
39
39
  }
40
40
  }
41
+
42
+ // ── Hand-rolled writers: arbitrary input × undersized output buffers ──
43
+ //
44
+ // Every writer that takes a caller-provided output slice MUST report an error
45
+ // (Err / None) instead of panicking or writing past the end when the buffer is
46
+ // too small, and MUST NOT read out of bounds when the input is arbitrary bytes.
47
+ // A panic here means a hostile request becomes a 500 (napi) or is merely
48
+ // contained by `panic_guard` (C-ABI) — neither is acceptable.
49
+
50
+ #[test]
51
+ fn hand_rolled_writers_never_panic_on_undersized_buffers() {
52
+ let mut rng = Rng(0x5eed_5eed);
53
+ for _ in 0..3000 {
54
+ let len = (rng.next() % 40) as usize;
55
+ let data = rng.bytes(len);
56
+ for cap in [0usize, 1, 2, 3, 7, 15, 31, data.len(), data.len() * 2 + 1] {
57
+ let mut out = vec![0u8; cap];
58
+ let _ = crate::crypto::base64::hex_encode_into_slice(&data, &mut out);
59
+ let _ = crate::crypto::base64::hex_decode_into_slice(&data, &mut out);
60
+ let _ = crate::http::url_codec::url_encode_into_slice(&data, &mut out);
61
+ let _ = crate::http::url_codec::url_decode_into_slice(&data, &mut out);
62
+ let _ = crate::http::query_parser::query_parse_packed_into_slice(&data, &mut out);
63
+ let _ = crate::http::cookie_parser::cookie_parse_packed_into_slice(&data, &mut out);
64
+ let _ = crate::http::http_parser::http_parse_request_packed_into_slice(&data, &mut out);
65
+ let _ = crate::http::etag::etag_from_crc32_into(rng.next() as u32, true, &mut out);
66
+ let _ = crate::payload::ws_frames::encode_frame_into(
67
+ (rng.next() & 0x0f) as u8,
68
+ &data,
69
+ rng.next() & 1 == 0,
70
+ rng.next() & 1 == 0,
71
+ &mut out,
72
+ );
73
+ let _ = crate::payload::sse::encode_event_into_slice(
74
+ Some("e"),
75
+ &data,
76
+ Some("i"),
77
+ Some(rng.next()),
78
+ &mut out,
79
+ );
80
+ let _ =
81
+ crate::util::packed::write_bitset_batch_into(&data, &mut out, |b| b.len() % 2 == 0);
82
+ let _ = crate::util::packed::write_sum_batch_into(&data, &mut out, |b| b.len() as i64);
83
+ let _ = crate::util::packed::write_u32_batch_into(&data, &mut out, |b| b.len() as u32);
84
+ }
85
+ }
86
+ }
87
+
88
+ // ── Broad parser sweep: arbitrary bytes must never panic ──
89
+
90
+ #[test]
91
+ fn hand_rolled_parsers_never_panic_on_hostile_input() {
92
+ let mut rng = Rng(0xfeed_face);
93
+ let supported = vec!["gzip".to_string(), "br".to_string(), "*".to_string()];
94
+ for _ in 0..3000 {
95
+ let len = (rng.next() % 96) as usize;
96
+ let data = rng.bytes(len);
97
+
98
+ // Decoders / validators over untrusted bytes.
99
+ let _ = crate::crypto::base64::hex_decode_bytes(&data);
100
+ let _ = crate::util::bytes::decode_form_component_len(&data);
101
+ let mut form_out = vec![0u8; data.len()];
102
+ let _ = crate::util::bytes::decode_form_component_into(&data, &mut form_out);
103
+ let mut scratch = Vec::new();
104
+ let _ = crate::util::bytes::decode_form_component_scratch(&data, &mut scratch);
105
+ let _ = crate::util::bytes::hex_decode_32(&data);
106
+ let _: Vec<_> = crate::util::bytes::cookie_pairs(&data).collect();
107
+ let mut verdicts = Vec::new();
108
+ let _ = crate::util::validation::hex_batch_valid_into(
109
+ &data,
110
+ (rng.next() % 40) as usize,
111
+ &mut verdicts,
112
+ );
113
+
114
+ // HTTP-surface parsers.
115
+ let _ = crate::http::method::MethodKind::from_bytes_ignore_case(&data);
116
+ let _ = crate::http::mime_lookup::mime_from_extension_bytes(&data);
117
+ let _ = crate::http::http_date::parse_http_date_secs(&data);
118
+ let _ = crate::http::accept::parse_accept_encoding_core(&data);
119
+ let _ = crate::http::accept::negotiate_encoding_server_preference(&supported, &data);
120
+ let _ = crate::payload::ws_frames::decode_frame(&data);
121
+ }
122
+ }
123
+
124
+ // ── multipart: hostile body/boundary + packed-writer size contract ──
125
+
126
+ #[test]
127
+ fn multipart_parser_never_panics_on_hostile_input() {
128
+ let mut rng = Rng(0xb0_0b0b);
129
+ for _ in 0..1500 {
130
+ let body_len = (rng.next() % 300) as usize;
131
+ let body = rng.bytes(body_len);
132
+ let boundary_len = (rng.next() % 20) as usize;
133
+ let boundary = rng.bytes(boundary_len);
134
+ let limits = crate::http::multipart::Limits {
135
+ max_parts: 16,
136
+ max_field_count: 16,
137
+ max_part_bytes: 128,
138
+ max_total_bytes: 256,
139
+ };
140
+ let parts = crate::http::multipart::parse_multipart_limited(&body, &boundary, &limits);
141
+ let all = crate::http::multipart::parse_multipart(&body, &boundary);
142
+ for p in &all {
143
+ let _ = (
144
+ p.name.len(),
145
+ p.filename.is_some(),
146
+ p.content_type.is_some(),
147
+ p.data.len(),
148
+ );
149
+ }
150
+
151
+ let mut packed = Vec::new();
152
+ crate::http::multipart::parts_to_packed(&parts, &mut packed);
153
+ assert_eq!(
154
+ packed.len(),
155
+ crate::http::multipart::parts_packed_len(&parts),
156
+ "parts_packed_len must match the Vec writer"
157
+ );
158
+ // Undersized packed output must return None (no partial write) …
159
+ if !packed.is_empty() {
160
+ let mut small = vec![0u8; packed.len() - 1];
161
+ assert!(crate::http::multipart::parts_to_packed_into(&parts, &mut small).is_none());
162
+ }
163
+ // … and an exact-size buffer must reproduce the Vec bytes byte-for-byte.
164
+ let mut exact = vec![0u8; packed.len()];
165
+ let n = crate::http::multipart::parts_to_packed_into(&parts, &mut exact)
166
+ .expect("exact-size buffer must succeed");
167
+ assert_eq!(n, packed.len());
168
+ assert_eq!(exact, packed);
169
+ }
170
+ }
171
+
172
+ // ── Exact length accounting for the hand-rolled escape writers ──
173
+ //
174
+ // `json_escaped_len` / `regex_escape_len` are used by the C-ABI "needed size"
175
+ // convention to allocate EXACTLY once. If the reported length ever disagreed
176
+ // with the written length the caller's grow-and-retry loop could spin forever
177
+ // (under-report) or the writer could run off the end (over-report). These are
178
+ // the strongest invariants for those writers.
179
+
180
+ #[test]
181
+ fn json_escaped_len_is_exact_for_hostile_bytes() {
182
+ let mut rng = Rng(0x150_15015);
183
+ // Alphabet biased toward the escape-triggering classes: NUL, controls,
184
+ // the memchr3 specials, high bytes (invalid UTF-8) and valid multi-byte
185
+ // lead/continuation pairs.
186
+ let alphabet = [
187
+ 0u8, 1, 8, 9, 10, 12, 13, 0x1f, b'"', b'\\', b'/', b'a', 0x7f, 0x80, 0xc3, 0xa9, 0xff,
188
+ 0xf0, 0x9f,
189
+ ];
190
+ for _ in 0..5000 {
191
+ let len = (rng.next() % 48) as usize;
192
+ let data: Vec<u8> = (0..len)
193
+ .map(|_| alphabet[(rng.next() % alphabet.len() as u64) as usize])
194
+ .collect();
195
+
196
+ let need = crate::json::json_ser::json_escaped_len(&data);
197
+ let mut out = vec![0u8; need];
198
+ let mut pos = 0usize;
199
+ let written = crate::json::json_ser::write_json_escaped(&mut out, &mut pos, &data);
200
+ assert_eq!(
201
+ written, need,
202
+ "json escaped length mismatch for {data:02x?}"
203
+ );
204
+ assert_eq!(pos, need);
205
+ // Escaped output of a JSON string must itself be valid UTF-8.
206
+ assert!(std::str::from_utf8(&out).is_ok(), "non-UTF-8 escape output");
207
+ }
208
+ }
209
+
210
+ #[test]
211
+ fn regex_escape_write_matches_len_and_reference() {
212
+ let mut rng = Rng(0x2e_9333);
213
+ let metas = b".*+?^${}()|[]\\";
214
+ for _ in 0..5000 {
215
+ let len = (rng.next() % 48) as usize;
216
+ let data = rng.bytes(len);
217
+ let need = crate::util::text::regex_escape_len(&data);
218
+ let mut out = vec![0u8; need];
219
+ let written = crate::util::text::regex_escape_write(&data, &mut out);
220
+ assert_eq!(written, need, "regex escape length mismatch");
221
+ // Independent reference: backslash before each metachar.
222
+ let mut expected = Vec::with_capacity(need);
223
+ for &b in &data {
224
+ if metas.contains(&b) {
225
+ expected.push(b'\\');
226
+ }
227
+ expected.push(b);
228
+ }
229
+ assert_eq!(out, expected, "regex escape output mismatch");
230
+ }
231
+ }
@@ -40,10 +40,14 @@ pub fn encode_event_size(
40
40
  if let Some(retry) = retry {
41
41
  need += 8 + u64_digits(retry); // "retry: " + digits + "\n"
42
42
  }
43
- // One `data: ` (6) + `\n` (1) per input line, the line bytes themselves,
44
- // then the terminating blank `\n`.
45
- let line_count = data.iter().filter(|&&b| b == b'\n').count() + 1;
46
- need + line_count * 7 + data.len() + 1
43
+ // One `data: ` (6) + `\n` (1) per input line, the line bytes themselves
44
+ // (SEGMENTS EXCLUDE the `\n` separators — sum of segment lengths =
45
+ // `data.len() - (line_count - 1)`), then the terminating blank `\n`.
46
+ // Line boundaries found via SIMD memchr — a scalar `filter().count()`
47
+ // pass over the full payload was ~1 cycle/byte (~650ns/KB steady state),
48
+ // collapsing large encodes to 0.10× of JS.
49
+ let line_count = memchr::memchr_iter(b'\n', data).count() + 1;
50
+ need + line_count * 7 + data.len() - (line_count - 1) + 1
47
51
  }
48
52
 
49
53
  /// Encode one SSE event into `out`, returning bytes written. Zero-alloc (no
@@ -98,7 +102,23 @@ pub fn encode_event_into_slice(
98
102
  out[w] = b'\n';
99
103
  w += 1;
100
104
  }
101
- for line in data.split(|&b| b == b'\n') {
105
+ // One `data: ` line per input line: SIMD memchr finds the `\n` boundaries
106
+ // (no scalar split() re-scan of the whole payload), copying each run.
107
+ // `data.split('\n')` semantics are preserved exactly: the tail after the
108
+ // last `\n` is emitted even when empty.
109
+ let mut line_start = 0usize;
110
+ for sep in memchr::memchr_iter(b'\n', data) {
111
+ let line = &data[line_start..sep];
112
+ out[w..w + 6].copy_from_slice(b"data: ");
113
+ w += 6;
114
+ out[w..w + line.len()].copy_from_slice(line);
115
+ w += line.len();
116
+ out[w] = b'\n';
117
+ w += 1;
118
+ line_start = sep + 1;
119
+ }
120
+ {
121
+ let line = &data[line_start..];
102
122
  out[w..w + 6].copy_from_slice(b"data: ");
103
123
  w += 6;
104
124
  out[w..w + line.len()].copy_from_slice(line);
@@ -211,4 +231,57 @@ mod tests {
211
231
  let mut out = [0u8; 3];
212
232
  assert!(encode_event_into_slice(Some("update"), b"payload", None, None, &mut out).is_err());
213
233
  }
234
+
235
+ #[test]
236
+ fn large_multiline_matches_allocating() {
237
+ // 4k lines x 96-byte data — spans well past any SIMD threshold.
238
+ let mut data = Vec::with_capacity(4 * 1024 * 96);
239
+ for i in 0..4 * 1024 {
240
+ data.extend_from_slice(format!("line-{i} ").as_bytes());
241
+ data.extend_from_slice(b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); // 55
242
+ data.push(b'\n');
243
+ }
244
+ let expected = encode_event(None, &data, None, None);
245
+ assert!(
246
+ expected.len() > 256 * 1024,
247
+ "test payload unexpectedly small: {}",
248
+ expected.len()
249
+ );
250
+ let mut out = vec![0u8; encode_event_size(None, &data, None, None)];
251
+ let w = encode_event_into_slice(None, &data, None, None, &mut out).unwrap();
252
+ assert_eq!(w, expected.len());
253
+ assert_eq!(&out[..w], &expected[..]);
254
+ }
255
+
256
+ #[test]
257
+ fn single_large_line_scales_too() {
258
+ // One 1MB line — the old scalar split() path cost ~300us here.
259
+ let mut data = vec![b'a'; 1 << 20];
260
+ data.push(b'\n');
261
+ let expected = encode_event(None, &data, None, None);
262
+ let mut out = vec![0u8; encode_event_size(None, &data, None, None)];
263
+ let w = encode_event_into_slice(None, &data, None, None, &mut out).unwrap();
264
+ assert_eq!(w, expected.len());
265
+ assert_eq!(&out[..w], &expected[..]);
266
+ }
267
+
268
+ #[test]
269
+ fn trailing_newline_emits_empty_tail_line() {
270
+ // split() semantics: "a\n" -> ["a", ""] -> one empty data: line.
271
+ assert_eq!(
272
+ encode_event(None, b"hello\n", None, None),
273
+ b"data: hello\ndata: \n\n"
274
+ );
275
+ assert_eq!(encode_event(None, b"\n", None, None), b"data: \ndata: \n\n");
276
+ assert_eq!(encode_event(None, b"", None, None), b"data: \n\n");
277
+ }
278
+
279
+ #[test]
280
+ fn embedded_crlf_kept_in_line() {
281
+ // Only \n splits lines; \r stays in the line bytes.
282
+ assert_eq!(
283
+ encode_event(None, b"a\r\nb", None, None),
284
+ b"data: a\r\ndata: b\n\n"
285
+ );
286
+ }
214
287
  }
@@ -16,7 +16,9 @@ use crate::http::headers::HeaderRefs;
16
16
  use crate::http::media_type::parse_media_type_core;
17
17
  use crate::http::query_parser::query_parse_packed_vec;
18
18
  use crate::json::fast_schema::compile;
19
- use crate::util::bytes::decode_percent_at;
19
+ use crate::util::bytes::{
20
+ decode_form_component_into, decode_form_component_len, decode_percent_at,
21
+ };
20
22
 
21
23
  proptest! {
22
24
  /// HeaderRefs::parse must never panic on arbitrary (possibly truncated,
@@ -125,6 +127,134 @@ proptest! {
125
127
  }
126
128
  prop_assert_eq!(decoded, input);
127
129
  }
130
+
131
+ // ── Exact size contracts for the hand-rolled decoders/writers ──
132
+
133
+ /// The C-ABI "needed size" pass must never under-report the written length
134
+ /// (an under-report would make the caller's grow-and-retry loop spin
135
+ /// forever); an exact-size buffer must always succeed and agree.
136
+ #[test]
137
+ fn decode_form_component_size_contract(input in prop::collection::vec(any::<u8>(), 0..256)) {
138
+ let reported = decode_form_component_len(&input);
139
+ let mut out = vec![0u8; input.len() + 8];
140
+ let written = decode_form_component_into(&input, &mut out)
141
+ .expect("an input-sized buffer always suffices");
142
+ prop_assert!(written <= reported, "reported {} < written {}", reported, written);
143
+ let mut exact = vec![0u8; reported];
144
+ let w2 = decode_form_component_into(&input, &mut exact)
145
+ .expect("exact reported size must suffice");
146
+ prop_assert_eq!(w2, written);
147
+ }
148
+
149
+ /// `json_escaped_len` must be an EXACT accounting of `write_json_escaped`
150
+ /// for arbitrary bytes, and the escaped output must be valid UTF-8.
151
+ #[test]
152
+ fn json_escaped_len_exact_accounting(input in prop::collection::vec(any::<u8>(), 0..256)) {
153
+ let need = crate::json::json_ser::json_escaped_len(&input);
154
+ let mut out = vec![0u8; need];
155
+ let mut pos = 0usize;
156
+ let written = crate::json::json_ser::write_json_escaped(&mut out, &mut pos, &input);
157
+ prop_assert_eq!(written, need);
158
+ prop_assert_eq!(pos, need);
159
+ prop_assert!(std::str::from_utf8(&out).is_ok());
160
+ }
161
+
162
+ /// `PackedIter`'s three accessors (`next()`/`collect_vec`/
163
+ /// `count_and_total_bytes`) must agree on validity and totals for arbitrary
164
+ /// (forged-length) packed buffers.
165
+ #[test]
166
+ fn packed_iter_self_consistent(input in prop::collection::vec(any::<u8>(), 0..512)) {
167
+ match crate::util::packed::PackedIter::new(&input) {
168
+ Ok(it) => {
169
+ let collected = it.collect_vec();
170
+ let stats = it.count_and_total_bytes();
171
+ prop_assert_eq!(collected.is_ok(), stats.is_ok());
172
+ if let (Ok(items), Ok((count, total))) = (collected, stats) {
173
+ prop_assert_eq!(items.len(), count);
174
+ prop_assert_eq!(items.iter().map(|i| i.len()).sum::<usize>(), total);
175
+ prop_assert_eq!(items.len(), it.len());
176
+ }
177
+ // The Iterator impl must never panic and never yield more items
178
+ // than the header claims.
179
+ prop_assert!(it.take(10_000).count() <= it.len());
180
+ }
181
+ Err(_) => {
182
+ prop_assert!(crate::util::packed::unpack(&input).is_err());
183
+ }
184
+ }
185
+ }
186
+
187
+ /// RFC 3986 encode → decode is the identity over arbitrary bytes, and the
188
+ /// encoder's own output is always accepted by the strict decoder.
189
+ #[test]
190
+ fn url_codec_roundtrip(input in prop::collection::vec(any::<u8>(), 0..256)) {
191
+ let mut enc = vec![0u8; input.len() * 3];
192
+ let n = crate::http::url_codec::url_encode_into_slice(&input, &mut enc)
193
+ .expect("3x buffer always suffices");
194
+ enc.truncate(n);
195
+ let mut dec = vec![0u8; enc.len()];
196
+ let m = crate::http::url_codec::url_decode_into_slice(&enc, &mut dec)
197
+ .expect("the encoder output is always well-formed");
198
+ prop_assert_eq!(&dec[..m], &input[..]);
199
+ }
200
+
201
+ /// SIMD hex encode → decode is the identity for arbitrary bytes and the
202
+ /// `_into` variants agree byte-for-byte with the allocating ones.
203
+ #[test]
204
+ fn hex_simd_roundtrip_arbitrary(input in prop::collection::vec(any::<u8>(), 0..256)) {
205
+ let enc = crate::crypto::base64::hex_encode_bytes(&input);
206
+ prop_assert_eq!(enc.len(), input.len() * 2);
207
+ let mut into_enc = vec![0u8; input.len() * 2];
208
+ let n = crate::crypto::base64::hex_encode_into_slice(&input, &mut into_enc).unwrap();
209
+ prop_assert_eq!(&into_enc[..n], enc.as_bytes());
210
+ let dec = crate::crypto::base64::hex_decode_bytes(enc.as_bytes())
211
+ .expect("own encoder output always decodes");
212
+ prop_assert_eq!(&dec[..], &input[..]);
213
+ let mut into_dec = vec![0u8; input.len()];
214
+ let m = crate::crypto::base64::hex_decode_into_slice(enc.as_bytes(), &mut into_dec).unwrap();
215
+ prop_assert_eq!(&into_dec[..m], &input[..]);
216
+ }
217
+
218
+ /// RFC 6455 encode → decode round-trips for any payload/opcode/flags, and
219
+ /// the zero-alloc writer matches the allocating one byte-for-byte.
220
+ #[test]
221
+ fn ws_frame_roundtrip(
222
+ payload in prop::collection::vec(any::<u8>(), 0..300),
223
+ opcode in 0u8..16,
224
+ mask in any::<bool>(),
225
+ fin in any::<bool>(),
226
+ ) {
227
+ let frame = crate::payload::ws_frames::encode_frame(opcode, &payload, mask, fin);
228
+ let decoded = crate::payload::ws_frames::decode_frame(&frame)
229
+ .expect("a self-produced frame always decodes");
230
+ prop_assert_eq!(decoded.opcode, opcode & 0x0f);
231
+ prop_assert_eq!(decoded.fin, fin);
232
+ prop_assert_eq!(&decoded.payload[..], &payload[..]);
233
+ let mut out = vec![0u8; frame.len()];
234
+ let n = crate::payload::ws_frames::encode_frame_into(opcode, &payload, mask, fin, &mut out)
235
+ .unwrap();
236
+ prop_assert_eq!(&out[..n], &frame[..]);
237
+ }
238
+
239
+ /// SSE `encode_event_size` must be exact and one byte short must error
240
+ /// (never panic / write out of bounds).
241
+ #[test]
242
+ fn sse_encode_size_contract(data in prop::collection::vec(any::<u8>(), 0..256)) {
243
+ let need = crate::payload::sse::encode_event_size(Some("evt"), &data, Some("id"), Some(42));
244
+ let mut out = vec![0u8; need];
245
+ let w = crate::payload::sse::encode_event_into_slice(
246
+ Some("evt"), &data, Some("id"), Some(42), &mut out,
247
+ )
248
+ .unwrap();
249
+ prop_assert_eq!(w, need);
250
+ if need > 0 {
251
+ let mut small = vec![0u8; need - 1];
252
+ prop_assert!(crate::payload::sse::encode_event_into_slice(
253
+ Some("evt"), &data, Some("id"), Some(42), &mut small,
254
+ )
255
+ .is_err());
256
+ }
257
+ }
128
258
  }
129
259
 
130
260
  /// decode_percent_at round-trips a manually percent-encoded byte (`%HH`).
@@ -585,6 +585,38 @@ mod tests {
585
585
  ]
586
586
  );
587
587
  }
588
+ #[test]
589
+ fn decode_form_undersized_buffer_reports_error() {
590
+ // A buffer smaller than the input can only ever yield BufferTooSmall —
591
+ // never a partial write the caller would mistake for success.
592
+ let mut out = [0u8; 4];
593
+ assert_eq!(
594
+ decode_form_component_into(b"plain-ascii", &mut out),
595
+ Err(FormDecodeError::BufferTooSmall)
596
+ );
597
+ // A decoded form that shrinks (3 input bytes -> 1) may still fit.
598
+ let mut out5 = [0u8; 5];
599
+ assert_eq!(
600
+ decode_form_component_into(b"%41%42%43%44%45", &mut out5),
601
+ Ok(5)
602
+ );
603
+ assert_eq!(&out5, b"ABCDE");
604
+ }
605
+
606
+ #[test]
607
+ fn decode_percent_at_boundaries() {
608
+ assert_eq!(decode_percent_at(b"%41", 0), Some((0x41, 3)));
609
+ // Truncated escapes (`%4`, `%`) and a non-hex digit all fail closed.
610
+ assert_eq!(decode_percent_at(b"%4", 0), None);
611
+ assert_eq!(decode_percent_at(b"%", 0), None);
612
+ assert_eq!(decode_percent_at(b"%4G", 0), None);
613
+ assert_eq!(decode_percent_at(b"%g0", 0), None);
614
+ // Lowercase hex is accepted; an out-of-range index must not panic.
615
+ assert_eq!(decode_percent_at(b"%2f", 0), Some((0x2f, 3)));
616
+ assert_eq!(decode_percent_at(b"abc", 10), None);
617
+ assert_eq!(decode_percent_at(b"", 0), None);
618
+ }
619
+
588
620
  #[test]
589
621
  #[should_panic(expected = "hex_encode: output buffer too small")]
590
622
  fn hex_encode_panics_on_undersized() {