castrum 0.9.6 → 0.9.8

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 (88) hide show
  1. package/Cargo.lock +4 -3
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +501 -5
  12. package/dist/index.js +1658 -1314
  13. package/docs/ARCHITECTURE.md +1 -1
  14. package/docs/BENCHMARKS.md +13 -0
  15. package/docs/CASE_STUDY.md +2 -2
  16. package/docs/ENVIRONMENT.md +1 -1
  17. package/docs/FFI_BUN_GUIDE.md +13 -1
  18. package/docs/HOW_TO_ADD_AN_OP.md +24 -0
  19. package/docs/INGRESS.md +4 -4
  20. package/docs/REPO_MAP.md +8 -6
  21. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  22. package/index.ts +1 -0
  23. package/package.json +3 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +170 -7
  29. package/rust/ffi/crypto.rs +182 -61
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +51 -32
  35. package/rust/ffi/tests.rs +197 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/native_route.rs +21 -3
  40. package/rust/task/completion.rs +7 -0
  41. package/rust/task/mod.rs +1 -1
  42. package/rust/task/tests.rs +30 -0
  43. package/src/ingress/decode/baked-result.ts +13 -5
  44. package/src/ingress/decode/fast-result.ts +1 -1
  45. package/src/ingress/decode/result-base.ts +16 -2
  46. package/src/ingress/handlers.ts +103 -31
  47. package/src/ingress/headers/memoized-headers.ts +40 -0
  48. package/src/ingress/native-route.ts +25 -6
  49. package/src/ingress/options.ts +26 -2
  50. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  51. package/src/ingress/response/baked-response.ts +118 -28
  52. package/src/ingress/router.ts +14 -2
  53. package/src/ingress/routes/common.ts +26 -10
  54. package/src/ingress/routes/echo.ts +50 -7
  55. package/src/ingress/routes/json-write.ts +21 -8
  56. package/src/ingress/routes/responder.ts +4 -4
  57. package/src/ingress/server-node.ts +33 -14
  58. package/src/ingress/server.ts +3 -4
  59. package/src/ingress/shared.ts +1 -1
  60. package/src/ingress/types.ts +15 -0
  61. package/src/loader/ops.ts +1 -1
  62. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  63. package/src/native/ffi/build/codecs.ts +0 -296
  64. package/src/native/ffi/build/instances-selftest.ts +264 -0
  65. package/src/native/ffi/build/instances.ts +39 -241
  66. package/src/native/ffi/build/metrics.ts +0 -7
  67. package/src/native/ffi/build/parse-selftest.ts +238 -0
  68. package/src/native/ffi/build/parse.ts +11 -234
  69. package/src/native/ffi/build.ts +5 -1
  70. package/src/native/ffi/selftest.ts +3 -3
  71. package/src/native/ffi/symbols.ts +460 -0
  72. package/src/native/ffi/types.ts +17 -0
  73. package/src/native/ffi.ts +2 -422
  74. package/src/native/ingress-binding.ts +39 -0
  75. package/src/runtime/codec.ts +24 -0
  76. package/src/runtime/types.ts +11 -0
  77. package/src/rust-ffi/batch/build.ts +18 -8
  78. package/src/rust-ffi/batch/types.ts +15 -0
  79. package/src/rust-ffi/options.ts +19 -0
  80. package/src/rust-ffi/scalar/crypto.ts +8 -0
  81. package/src/rust-ffi/scalar/factories.ts +47 -20
  82. package/src/rust-ffi/scalar/http.ts +60 -10
  83. package/src/rust-ffi/scalar/interface.ts +4 -1
  84. package/src/rust-ffi/scalar/payload.ts +27 -1
  85. package/src/shared/buffer-pool.ts +44 -9
  86. package/src/shared/codec.ts +26 -0
  87. package/src/shared/packed/parsers.ts +64 -7
  88. package/src/task/runtime.ts +29 -28
@@ -36,6 +36,18 @@ fn batch_nonce(base: &[u8], index: usize) -> [u8; NONCE_LEN] {
36
36
  nonce
37
37
  }
38
38
 
39
+ /// Reject a nonce that is not exactly [`NONCE_LEN`] bytes. Guards the batch
40
+ /// entry points, whose `copy_from_slice` would otherwise panic.
41
+ fn validate_nonce(nonce: &[u8]) -> Result<()> {
42
+ if nonce.len() != NONCE_LEN {
43
+ return Err(Error::from_reason(format!(
44
+ "aead nonce must be {NONCE_LEN} bytes, got {}",
45
+ nonce.len()
46
+ )));
47
+ }
48
+ Ok(())
49
+ }
50
+
39
51
  fn resolve_algorithm(name: Option<&str>) -> Result<&'static aead::Algorithm> {
40
52
  match name {
41
53
  None | Some("aes-256-gcm") => Ok(&aead::AES_256_GCM),
@@ -197,6 +209,7 @@ pub fn aead_encrypt_batch_packed(
197
209
  algorithm: Option<String>,
198
210
  ) -> Result<Buffer> {
199
211
  let alg = resolve_algorithm(algorithm.as_deref())?;
212
+ validate_nonce(nonce.as_ref())?;
200
213
 
201
214
  let enc_for = |plaintext: &[u8], index: usize| -> Vec<u8> {
202
215
  let n = batch_nonce(nonce.as_ref(), index);
@@ -220,6 +233,7 @@ pub fn aead_decrypt_batch_packed(
220
233
  algorithm: Option<String>,
221
234
  ) -> Result<Buffer> {
222
235
  let alg = resolve_algorithm(algorithm.as_deref())?;
236
+ validate_nonce(nonce.as_ref())?;
223
237
 
224
238
  let dec_for = |ciphertext: &[u8], index: usize| -> Vec<u8> {
225
239
  let n = batch_nonce(nonce.as_ref(), index);
@@ -317,6 +331,36 @@ mod tests {
317
331
  }
318
332
  }
319
333
 
334
+ fn packed_one(bytes: &[u8]) -> Uint8Array {
335
+ let mut data = Vec::new();
336
+ data.extend_from_slice(&1u32.to_le_bytes());
337
+ data.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
338
+ data.extend_from_slice(bytes);
339
+ Uint8Array::new(data)
340
+ }
341
+
342
+ #[test]
343
+ fn batch_rejects_a_non_12_byte_nonce() {
344
+ // A wrong-length nonce used to panic inside `copy_from_slice` (and the
345
+ // napi sync trampoline unwinds through extern "C" → abort). It must be
346
+ // a normal JS error instead.
347
+ let key = key_bytes();
348
+ assert!(aead_encrypt_batch_packed(
349
+ packed_one(b"abc"),
350
+ Uint8Array::new(key.to_vec()),
351
+ Uint8Array::new(vec![0u8; 16]),
352
+ None,
353
+ )
354
+ .is_err());
355
+ assert!(aead_decrypt_batch_packed(
356
+ packed_one(b"abc"),
357
+ Uint8Array::new(key.to_vec()),
358
+ Uint8Array::new(vec![0u8; 8]),
359
+ None,
360
+ )
361
+ .is_err());
362
+ }
363
+
320
364
  #[test]
321
365
  fn batch_nonce_derived_encrypt_roundtrip() {
322
366
  // Each item encrypted with its derived nonce decrypts correctly, and a
@@ -30,8 +30,16 @@ pub struct PasswordHashOptions {
30
30
  pub out_len: Option<u32>,
31
31
  }
32
32
 
33
+ /// Sanity caps for caller-supplied argon2 params: a hostile or mis-set config
34
+ /// must not force a multi-GiB allocation or a multi-minute CPU burn. Generous
35
+ /// enough for any real deployment (defaults are 19 MiB / 2 / 1 / 32).
36
+ pub const ARGON2_MAX_M_COST: u32 = 1_048_576; // 1 GiB, in KiB
37
+ pub const ARGON2_MAX_T_COST: u32 = 64;
38
+ pub const ARGON2_MAX_P_COST: u32 = 64;
39
+ pub const ARGON2_MAX_OUT_LEN: u32 = 1024;
40
+
33
41
  fn resolve_opts(o: Option<&PasswordHashOptions>) -> (u32, u32, u32, u32) {
34
- match o {
42
+ let (m, t, p, out) = match o {
35
43
  Some(o) => (
36
44
  o.m_cost.unwrap_or(19_456),
37
45
  o.t_cost.unwrap_or(2),
@@ -39,7 +47,13 @@ fn resolve_opts(o: Option<&PasswordHashOptions>) -> (u32, u32, u32, u32) {
39
47
  o.out_len.unwrap_or(32),
40
48
  ),
41
49
  None => (19_456, 2, 1, 32),
42
- }
50
+ };
51
+ (
52
+ m.min(ARGON2_MAX_M_COST),
53
+ t.min(ARGON2_MAX_T_COST),
54
+ p.min(ARGON2_MAX_P_COST),
55
+ out.min(ARGON2_MAX_OUT_LEN),
56
+ )
43
57
  }
44
58
 
45
59
  // ── Pure-Rust core ─────────────────────────────────────────────
@@ -230,6 +244,22 @@ mod tests {
230
244
  .is_err());
231
245
  }
232
246
 
247
+ #[test]
248
+ fn argon2_params_are_capped() {
249
+ let (m, t, p, out) = resolve_opts(Some(&PasswordHashOptions {
250
+ m_cost: Some(u32::MAX),
251
+ t_cost: Some(u32::MAX),
252
+ p_cost: Some(u32::MAX),
253
+ out_len: Some(u32::MAX),
254
+ }));
255
+ assert_eq!(m, ARGON2_MAX_M_COST);
256
+ assert_eq!(t, ARGON2_MAX_T_COST);
257
+ assert_eq!(p, ARGON2_MAX_P_COST);
258
+ assert_eq!(out, ARGON2_MAX_OUT_LEN);
259
+ // Sane defaults pass through untouched.
260
+ assert_eq!(resolve_opts(None), (19_456, 2, 1, 32));
261
+ }
262
+
233
263
  #[test]
234
264
  fn verify_rejects_wrong_password() {
235
265
  let phc = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
@@ -76,6 +76,25 @@ fn verify_token_enforces_iat_leeway() {
76
76
  assert!(verify_token(&near, SECRET, now).is_some());
77
77
  }
78
78
 
79
+ #[test]
80
+ fn verify_token_rejects_non_integer_or_non_numeric_exp() {
81
+ let now = 1_000_000i64;
82
+ // A fractional exp in the PAST must reject. Pre-fix `as_i64()` returned
83
+ // None for a float, so the expiry check was skipped and the token verified
84
+ // as never-expiring.
85
+ let frac_past = token(&serde_json::json!({ "sub": "1", "exp": 999_999.5 }), SECRET);
86
+ assert!(verify_token(&frac_past, SECRET, now).is_none());
87
+ // Present-but-non-numeric exp is malformed → reject.
88
+ let string_exp = token(&serde_json::json!({ "sub": "1", "exp": "999999" }), SECRET);
89
+ assert!(verify_token(&string_exp, SECRET, now).is_none());
90
+ // A fractional exp in the FUTURE is a valid RFC 7519 NumericDate → accept.
91
+ let frac_future = token(
92
+ &serde_json::json!({ "sub": "1", "exp": 1_000_000.5 }),
93
+ SECRET,
94
+ );
95
+ assert!(verify_token(&frac_future, SECRET, now).is_some());
96
+ }
97
+
79
98
  #[test]
80
99
  fn verify_token_accepts_valid_claims() {
81
100
  let claims = serde_json::json!({ "sub": "123", "role": "admin" });
@@ -113,19 +113,6 @@ pub fn build_token_with_key(
113
113
  signing_input
114
114
  }
115
115
 
116
- /// Constant-time byte comparison (no early exit on length mismatch beyond the
117
- /// required guard — lengths differ → false).
118
- fn ct_eq(a: &[u8], b: &[u8]) -> bool {
119
- if a.len() != b.len() {
120
- return false;
121
- }
122
- let mut diff = 0u8;
123
- for (x, y) in a.iter().zip(b.iter()) {
124
- diff |= x ^ y;
125
- }
126
- diff == 0
127
- }
128
-
129
116
  /// Verify an HS256 signature (constant-time). Does NOT check claims/expiry.
130
117
  ///
131
118
  /// Test-only convenience over the fresh-key derivation: production code uses
@@ -148,10 +135,11 @@ pub fn verify_signature_with_key(token: &[u8], key: &aws_lc_rs::hmac::Key) -> bo
148
135
  signing_input.push(b'.');
149
136
  signing_input.extend_from_slice(parts.payload_b64);
150
137
 
151
- let sig = hmac_sha256_with_key(key, &signing_input);
152
-
153
138
  match base64url_decode_bytes(parts.sig_b64) {
154
- Some(provided) => ct_eq(&sig, &provided),
139
+ // Audited constant-time comparison (aws-lc-rs), matching every other
140
+ // MAC path in the crate (cookie sign / CSRF / hmac_sha256) instead of a
141
+ // hand-rolled loop the compiler could optimize into an early exit.
142
+ Some(provided) => aws_lc_rs::hmac::verify(key, &signing_input, &provided).is_ok(),
155
143
  None => false,
156
144
  }
157
145
  }
@@ -239,31 +227,58 @@ pub(crate) fn verify_time_claims(payload_b64: &[u8], now_seconds: i64) -> Option
239
227
  let payload = base64url_decode_bytes(payload_b64)?;
240
228
  let value: sonic_rs::Value = sonic_rs::from_slice(&payload).ok()?;
241
229
 
230
+ // RFC 7519 NumericDate may be non-integer, so parse as f64 — `as_i64()`
231
+ // returns None for a float, which previously SKIPPED the check entirely and
232
+ // made a token with a fractional `exp` verify as never-expiring. A claim
233
+ // that is present but not a finite number is malformed → reject.
234
+ let now = now_seconds as f64;
235
+
242
236
  // `exp`: reject when now >= exp.
243
- if let Some(exp) = value.get("exp").and_then(|v| v.as_i64()) {
244
- if now_seconds >= exp {
245
- return None;
246
- }
237
+ match time_claim(&value, "exp") {
238
+ TimeClaim::Absent => {}
239
+ TimeClaim::Invalid => return None,
240
+ TimeClaim::Value(exp) if now >= exp => return None,
241
+ TimeClaim::Value(_) => {}
247
242
  }
248
243
 
249
244
  // `nbf` (not before): reject when the token is not yet valid.
250
- if let Some(nbf) = value.get("nbf").and_then(|v| v.as_i64()) {
251
- if now_seconds < nbf {
252
- return None;
253
- }
245
+ match time_claim(&value, "nbf") {
246
+ TimeClaim::Absent => {}
247
+ TimeClaim::Invalid => return None,
248
+ TimeClaim::Value(nbf) if now < nbf => return None,
249
+ TimeClaim::Value(_) => {}
254
250
  }
255
251
 
256
252
  // `iat` (issued at): reject tokens issued in the future beyond a small
257
253
  // clock-skew leeway.
258
- if let Some(iat) = value.get("iat").and_then(|v| v.as_i64()) {
259
- if now_seconds < iat.saturating_sub(CLOCK_SKEW_LEEWAY_SECS) {
260
- return None;
261
- }
254
+ match time_claim(&value, "iat") {
255
+ TimeClaim::Absent => {}
256
+ TimeClaim::Invalid => return None,
257
+ TimeClaim::Value(iat) if now < iat - CLOCK_SKEW_LEEWAY_SECS as f64 => return None,
258
+ TimeClaim::Value(_) => {}
262
259
  }
263
260
 
264
261
  Some(payload)
265
262
  }
266
263
 
264
+ /// Parsed state of a JWT time claim: absent, a finite numeric value, or present
265
+ /// but malformed (string / null / non-finite).
266
+ enum TimeClaim {
267
+ Absent,
268
+ Value(f64),
269
+ Invalid,
270
+ }
271
+
272
+ fn time_claim(value: &sonic_rs::Value, key: &str) -> TimeClaim {
273
+ match value.get(key) {
274
+ None => TimeClaim::Absent,
275
+ Some(v) => match v.as_f64() {
276
+ Some(n) if n.is_finite() => TimeClaim::Value(n),
277
+ _ => TimeClaim::Invalid,
278
+ },
279
+ }
280
+ }
281
+
267
282
  /// Verify with a PRE-COMPILED key (no per-call key derivation). Time-claim
268
283
  /// checks run over a compact `sonic_rs::Value` (zero per-key heap `String`);
269
284
  /// the decoded payload bytes are returned verbatim.
@@ -41,6 +41,12 @@ pub fn seal_core(id: &[u8], data_json: &[u8], exp_secs: i64, secret: &[u8]) -> O
41
41
  /// Verify a sealed token and extract the envelope fields into the packed
42
42
  /// layout: `[u8 ok=1][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`.
43
43
  /// Returns `None` on bad signature / malformed envelope.
44
+ ///
45
+ /// The returned `exp` is **advisory**: this function does NOT compare it to the
46
+ /// current time, so a correctly signed but expired envelope still opens. The
47
+ /// caller MUST reject it (`exp != 0 && now >= exp`). (Enforcing it here was
48
+ /// considered and rejected: the wire contract is signature + extraction, and
49
+ /// the bind-time self-test seals with fixed historical timestamps.)
44
50
  pub fn open_core(token: &[u8], secret: &[u8]) -> Option<(i64, Vec<u8>, Vec<u8>)> {
45
51
  if secret.is_empty() {
46
52
  return None;
@@ -83,13 +89,10 @@ pub fn open_core(token: &[u8], secret: &[u8]) -> Option<(i64, Vec<u8>, Vec<u8>)>
83
89
  if bytes.get(pos) != Some(&b'"') {
84
90
  return None;
85
91
  }
86
- let vs = pos + 1;
87
- let mut ve = vs;
88
- while ve < bytes.len() && bytes[ve] != b'"' {
89
- ve += 1;
90
- }
91
- id = Some(bytes[vs..ve].to_vec());
92
- pos = ve + 1;
92
+ let body = pos + 1;
93
+ let end = scan_json_string_end(bytes, body)?;
94
+ id = Some(unescape_json_id(&bytes[body..end]));
95
+ pos = end + 1;
93
96
  }
94
97
  b"exp" => {
95
98
  let vs = pos;
@@ -174,6 +177,108 @@ fn skip_json_value(bytes: &[u8], pos: usize) -> Option<usize> {
174
177
  }
175
178
  }
176
179
 
180
+ /// Index of the `"` that TERMINATES a JSON string whose body starts at `start`
181
+ /// (just past the opening quote).
182
+ ///
183
+ /// Escape-aware: the id used to be captured by scanning for the next `"`, which
184
+ /// stops at the quote inside an escaped `\"` — truncating the id AND leaving
185
+ /// `pos` in the middle of the string, so the rest of the envelope was then read
186
+ /// as structure (a quote-carrying id could make `open` fail outright).
187
+ fn scan_json_string_end(bytes: &[u8], start: usize) -> Option<usize> {
188
+ let mut i = start;
189
+ while i < bytes.len() {
190
+ match bytes[i] {
191
+ b'\\' => i += 2,
192
+ b'"' => return Some(i),
193
+ _ => i += 1,
194
+ }
195
+ }
196
+ None
197
+ }
198
+
199
+ /// Decode 4 hex digits at `at` (the `\uXXXX` payload).
200
+ fn hex4(bytes: &[u8], at: usize) -> Option<u32> {
201
+ let mut v = 0u32;
202
+ for k in 0..4 {
203
+ v = (v << 4) | (*bytes.get(at + k)? as char).to_digit(16)?;
204
+ }
205
+ Some(v)
206
+ }
207
+
208
+ /// Inverse of `json_ser::write_json_escaped` for the id: decodes `\"`, `\\`,
209
+ /// `\/`, `\b`, `\f`, `\n`, `\r`, `\t` and `\uXXXX`.
210
+ ///
211
+ /// `\u00XX` maps back to the single BYTE `XX`. That is deliberate and load
212
+ /// bearing: the escaper emits that form for control characters AND — in its
213
+ /// invalid-UTF-8 mode — for every byte of binary input, so routing it through a
214
+ /// code point and re-encoding as UTF-8 would expand bytes >= 0x80 into two
215
+ /// bytes and destroy the byte-exact round trip the id is supposed to have.
216
+ /// (A valid-UTF-8 id is written RAW by the escaper, so `\u00XX` cannot be its
217
+ /// origin.)
218
+ ///
219
+ /// `\uXXXX` above 0xFF can only come from a different producer (e.g. JS
220
+ /// `JSON.stringify` of a non-ASCII id), so it is decoded as a code point with
221
+ /// surrogate-pair combining. Unknown escapes and a trailing backslash are kept
222
+ /// verbatim — never silently dropped.
223
+ fn unescape_json_id(escaped: &[u8]) -> Vec<u8> {
224
+ let mut out = Vec::with_capacity(escaped.len());
225
+ let mut i = 0usize;
226
+ while i < escaped.len() {
227
+ if escaped[i] != b'\\' {
228
+ out.push(escaped[i]);
229
+ i += 1;
230
+ continue;
231
+ }
232
+ let Some(&e) = escaped.get(i + 1) else {
233
+ out.push(b'\\');
234
+ break;
235
+ };
236
+ i += 2;
237
+ match e {
238
+ b'"' => out.push(b'"'),
239
+ b'\\' => out.push(b'\\'),
240
+ b'/' => out.push(b'/'),
241
+ b'b' => out.push(0x08),
242
+ b'f' => out.push(0x0c),
243
+ b'n' => out.push(b'\n'),
244
+ b'r' => out.push(b'\r'),
245
+ b't' => out.push(b'\t'),
246
+ b'u' => {
247
+ let Some(cp) = hex4(escaped, i) else {
248
+ out.push(b'\\');
249
+ out.push(b'u');
250
+ continue;
251
+ };
252
+ i += 4;
253
+ if cp <= 0xFF {
254
+ out.push(cp as u8);
255
+ continue;
256
+ }
257
+ let mut code = cp;
258
+ if (0xD800..=0xDBFF).contains(&cp)
259
+ && escaped.get(i) == Some(&b'\\')
260
+ && escaped.get(i + 1) == Some(&b'u')
261
+ {
262
+ if let Some(lo) = hex4(escaped, i + 2) {
263
+ if (0xDC00..=0xDFFF).contains(&lo) {
264
+ code = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
265
+ i += 6;
266
+ }
267
+ }
268
+ }
269
+ let mut buf = [0u8; 4];
270
+ let ch = char::from_u32(code).unwrap_or('\u{FFFD}');
271
+ out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
272
+ }
273
+ other => {
274
+ out.push(b'\\');
275
+ out.push(other);
276
+ }
277
+ }
278
+ }
279
+ out
280
+ }
281
+
177
282
  #[cfg(test)]
178
283
  mod tests {
179
284
  use super::*;
@@ -209,6 +314,64 @@ mod tests {
209
314
  assert!(seal_core(b"", b"{}", 0, SECRET).is_none());
210
315
  }
211
316
 
317
+ #[test]
318
+ fn id_round_trips_byte_exactly_through_escapes() {
319
+ // A `"` used to terminate the id scan early, and the id came back
320
+ // escaped. Both are the same root cause: an escape-unaware scan with no
321
+ // decode. Every id here must survive the envelope byte-for-byte.
322
+ for id in [
323
+ &b"plain-id"[..],
324
+ &b"sess\"quote"[..],
325
+ &b"back\\slash"[..],
326
+ &b"new\nline"[..],
327
+ &b"tab\there"[..],
328
+ &b"cr\rhere"[..],
329
+ &b"nul\0byte"[..],
330
+ &b"binary\xff\xfe"[..],
331
+ &b"trailing\\"[..],
332
+ ] {
333
+ let token = seal_core(id, br#"{"a":1}"#, 42, SECRET).expect("seal");
334
+ let (exp, got, data) = open_core(&token, SECRET).expect("open");
335
+ assert_eq!(got, id, "id must round-trip byte-exactly");
336
+ assert_eq!(exp, 42);
337
+ assert_eq!(data, br#"{"a":1}"#);
338
+ }
339
+ }
340
+
341
+ #[test]
342
+ fn unescape_json_id_matches_the_escaper() {
343
+ assert_eq!(unescape_json_id(br#"a\"b"#), b"a\"b");
344
+ assert_eq!(unescape_json_id(br#"a\\b"#), b"a\\b");
345
+ assert_eq!(unescape_json_id(br#"a\/b"#), b"a/b");
346
+ assert_eq!(unescape_json_id(br#"a\nb"#), b"a\nb");
347
+ assert_eq!(unescape_json_id(br#"a\rb"#), b"a\rb");
348
+ assert_eq!(unescape_json_id(br#"a\tb"#), b"a\tb");
349
+ assert_eq!(unescape_json_id(br#"a\bb"#), b"a\x08b");
350
+ assert_eq!(unescape_json_id(br#"a\fb"#), b"a\x0cb");
351
+ // `\u00XX` is a BYTE (the escaper's binary mode), not a code point.
352
+ assert_eq!(unescape_json_id(br#"a\u00e9b"#), b"a\xe9b");
353
+ // Above 0xFF is a real code point → UTF-8.
354
+ assert_eq!(unescape_json_id(br#"a\u20acb"#), "a\u{20ac}b".as_bytes());
355
+ // A surrogate pair combines into one code point.
356
+ assert_eq!(unescape_json_id(br#"\ud83d\ude00"#), "\u{1f600}".as_bytes());
357
+ // Unknown escapes and a trailing backslash stay verbatim.
358
+ assert_eq!(unescape_json_id(br#"a\qb"#), br#"a\qb"#);
359
+ assert_eq!(unescape_json_id(b"a\\"), b"a\\");
360
+ }
361
+
362
+ #[test]
363
+ fn scan_json_string_end_skips_escaped_quotes() {
364
+ // `a\"b"` (a, \, ", b, ") — the escaped quote does NOT terminate; the
365
+ // plain quote at index 4 does.
366
+ assert_eq!(scan_json_string_end(b"a\\\"b\"", 0), Some(4));
367
+ // No escape at all: the first quote terminates.
368
+ assert_eq!(scan_json_string_end(b"ab\"cd", 0), Some(2));
369
+ // Unterminated: the trailing backslash swallows the rest.
370
+ assert_eq!(scan_json_string_end(b"ab\\\"", 0), None);
371
+ // Escaped BACKSLASH then a terminator: `a\\"` = a, \, \, " → Some(3).
372
+ assert_eq!(scan_json_string_end(b"a\\\\\"", 0), Some(3));
373
+ }
374
+
212
375
  #[test]
213
376
  fn wire_format_matches_sign_cookie() {
214
377
  // The sealed token must be verifiable by the EXISTING cookie verify