castrum 0.9.4 → 0.9.7

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 (61) hide show
  1. package/Cargo.lock +28 -44
  2. package/Cargo.toml +21 -4
  3. package/README.md +8 -6
  4. package/castrum.darwin-arm64.node +0 -0
  5. package/castrum.darwin-x64.node +0 -0
  6. package/castrum.linux-arm64-gnu.node +0 -0
  7. package/castrum.linux-arm64-musl.node +0 -0
  8. package/castrum.linux-x64-gnu.node +0 -0
  9. package/castrum.linux-x64-musl.node +0 -0
  10. package/castrum.linux-x64-v3-gnu.node +0 -0
  11. package/castrum.win32-x64-msvc.node +0 -0
  12. package/dist/index.d.ts +164 -0
  13. package/dist/index.js +4460 -3648
  14. package/docs/API.md +1 -0
  15. package/docs/ARCHITECTURE.md +1 -1
  16. package/docs/CASE_STUDY.md +4 -2
  17. package/docs/ENVIRONMENT.md +9 -2
  18. package/docs/FFI_BUN_GUIDE.md +166 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +34 -0
  20. package/docs/REPO_MAP.md +16 -5
  21. package/docs/RND-CONCURRENCY.md +772 -0
  22. package/docs/bun-builtins-decision-matrix.md +4 -1
  23. package/index.ts +5 -0
  24. package/package.json +172 -163
  25. package/rust/crypto/pbkdf2.rs +9 -0
  26. package/rust/crypto/session.rs +164 -7
  27. package/rust/ffi/crypto.rs +119 -29
  28. package/rust/ffi/mod.rs +1 -0
  29. package/rust/ffi/task.rs +172 -0
  30. package/rust/ffi/tests.rs +173 -0
  31. package/rust/http/form.rs +6 -2
  32. package/rust/http/query_parser.rs +53 -22
  33. package/rust/ingress/native_route.rs +131 -140
  34. package/rust/ingress/pipeline.rs +3 -3
  35. package/rust/ingress/tests.rs +5 -4
  36. package/rust/json/json_ser.rs +13 -11
  37. package/rust/lib.rs +1 -0
  38. package/rust/task/completion.rs +119 -0
  39. package/rust/task/mod.rs +65 -0
  40. package/rust/task/ops.rs +503 -0
  41. package/rust/task/runtime.rs +220 -0
  42. package/rust/task/tests.rs +231 -0
  43. package/rust/util/bytes.rs +238 -51
  44. package/src/ingress/health.ts +1 -2
  45. package/src/native/ffi/build/codecs.ts +29 -7
  46. package/src/native/ffi/build/compress.ts +8 -0
  47. package/src/native/ffi/build/instances.ts +54 -2
  48. package/src/native/ffi/build/metrics.ts +23 -7
  49. package/src/native/ffi/build/task.ts +79 -0
  50. package/src/native/ffi/build/util.ts +18 -0
  51. package/src/native/ffi/build.ts +2 -0
  52. package/src/native/ffi/selftest.ts +3 -1
  53. package/src/native/ffi/types.ts +57 -0
  54. package/src/native/ffi.ts +85 -14
  55. package/src/native/loader.ts +53 -3
  56. package/src/rust-ffi/scalar/http.ts +54 -16
  57. package/src/rust-ffi/text.ts +25 -3
  58. package/src/shared/bytes.ts +19 -0
  59. package/src/task/index.ts +24 -0
  60. package/src/task/op.ts +171 -0
  61. package/src/task/runtime.ts +568 -0
@@ -7,7 +7,10 @@
7
7
 
8
8
  ## Methodology
9
9
 
10
- - **Runtime**: Bun **1.4.0** (the primary target).
10
+ - **Runtime**: Bun **1.4.0** (the measurement anchor for the rows below; the
11
+ local toolchain has since moved to **1.4.2**, and these TIMING rows have not
12
+ been re-measured — only re-verified for parity, which
13
+ `test/unit/contract/delegation.test.ts` does on every run).
11
14
  - **Addon**: castrum **release** build (`bun run build`, baseline CPU — the
12
15
  shipped profile).
13
16
  - **Driver**: `bun run check` (CPU benchmark → `bench/results/cpu/latest.json`).
package/index.ts CHANGED
@@ -20,6 +20,11 @@ export * from './src/integration'
20
20
  // dispatch single items, packed batches, and load() coalescing. See README.
21
21
  export * from './src/loader'
22
22
  export * from './src/rust-ffi'
23
+ // Off-thread native task runtime ("castrum Tasks"): submit a CPU-bound native
24
+ // op to the Rust pool and `await` it without ever blocking the JS thread.
25
+ // Bun-only offload today; Node runs the op synchronously. See
26
+ // docs/RND-CONCURRENCY.md.
27
+ export * from './src/task'
23
28
  export type { OpDecision, OpImpl } from './src/selection'
24
29
  // Native-vs-JS selection (OWNED HERE, not by consumers): `opImpl(op)` is the
25
30
  // benchmark-driven recommendation for which operations should use the Rust
package/package.json CHANGED
@@ -1,168 +1,177 @@
1
1
  {
2
- "name": "castrum",
3
- "version": "0.9.4",
4
- "private": false,
5
- "description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
6
- "type": "module",
7
- "main": "dist/index.js",
8
- "module": "dist/index.js",
9
- "types": "dist/index.d.ts",
10
- "license": "MIT",
11
- "keywords": [
12
- "bun",
13
- "rust",
14
- "napi",
15
- "ffi",
16
- "ingress",
17
- "http",
18
- "validation",
19
- "hashing",
20
- "json",
21
- "performance",
22
- "benchmark"
23
- ],
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/Axylabs/flux-rs.git"
2
+ "name": "castrum",
3
+ "version": "0.9.7",
4
+ "private": false,
5
+ "description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "module": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "bun",
13
+ "rust",
14
+ "napi",
15
+ "ffi",
16
+ "ingress",
17
+ "http",
18
+ "validation",
19
+ "hashing",
20
+ "json",
21
+ "performance",
22
+ "benchmark"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/Axylabs/castrum.git"
27
+ },
28
+ "homepage": "https://github.com/Axylabs/castrum#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/Axylabs/castrum/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "bun": "./index.ts",
39
+ "node": "./dist/index.js",
40
+ "default": "./dist/index.js"
27
41
  },
28
- "homepage": "https://github.com/Axylabs/flux-rs#readme",
29
- "bugs": {
30
- "url": "https://github.com/Axylabs/flux-rs/issues"
31
- },
32
- "publishConfig": {
33
- "access": "public"
34
- },
35
- "exports": {
36
- ".": {
37
- "types": "./dist/index.d.ts",
38
- "bun": "./index.ts",
39
- "node": "./dist/index.js",
40
- "default": "./dist/index.js"
41
- },
42
- "./package.json": "./package.json"
43
- },
44
- "files": [
45
- "index.ts",
46
- "dist",
47
- "src",
48
- "docs",
49
- "README.md",
50
- "*.node",
51
- "scripts/postinstall.mjs",
52
- "rust",
53
- "Cargo.toml",
54
- "Cargo.lock",
55
- "build.rs",
56
- "rust-toolchain.toml",
57
- "!src/bench",
58
- "!src/baseline",
59
- "!src/data"
60
- ],
61
- "scripts": {
62
- "postinstall": "node scripts/postinstall.mjs",
63
- "build": "napi build --release --platform",
64
- "build:perf": "bash scripts/build-perf.sh",
65
- "build:v3": "bash scripts/build-v3.sh",
66
- "build:all": "bun run build && bun run build:v3",
67
- "build:js": "bun run build:js:bundle && bun run build:js:types",
68
- "build:js:bundle": "bun build ./index.ts --target=node --format=esm --outdir=dist",
69
- "build:js:types": "dts-bundle-generator -o dist/index.d.ts index.ts",
70
- "bench": "bun run build && bun bench.ts",
71
- "check": "bun bench.ts",
72
- "test": "bun test",
73
- "test:all": "bun test && bun run typecheck && bun run typecheck:test && bun run lint:ci && bun run check:clean && cargo test --lib",
74
- "test:coverage": "node scripts/check-coverage.mjs",
75
- "setup": "bun install && bun run build",
76
- "test:node": "node --test test/integration/node-smoke.test.mjs test/integration/node-enterprise.test.mjs",
77
- "verify:install": "node scripts/verify-install.mjs",
78
- "build:debug": "napi build --platform",
79
- "lint": "bunx biome check .",
80
- "lint:fix": "bunx biome check --write .",
81
- "lint:ci": "bunx biome check . --formatter-enabled=false --assist-enabled=false --error-on-warnings",
82
- "format": "bunx biome format --write .",
83
- "typecheck": "bunx tsc --noEmit",
84
- "typecheck:test": "bunx tsc --noEmit -p tsconfig.test.json",
85
- "test:rust": "cargo test",
86
- "deny": "cargo deny check",
87
- "check:version": "bun scripts/check-version.mjs",
88
- "check:jsdoc": "bun scripts/check-jsdoc.ts",
89
- "check:selection": "bun scripts/select-native.ts --check",
90
- "check:clean": "bun scripts/check-clean.ts",
91
- "gen:ai-map": "bun scripts/gen-ai-map.ts",
92
- "audit": "bun audit",
93
- "publish:manual": "node scripts/publish-manual.mjs",
94
- "publish:manual:dry": "node scripts/publish-manual.mjs --dry-run",
95
- "prepublishOnly": "bun run build:js && node scripts/prepublish.mjs",
96
- "bench:http": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts",
97
- "bench:http:smoke": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts smoke",
98
- "bench:http:stress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts stress",
99
- "bench:http:heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts heavy-json",
100
- "bench:http:crud": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts crud",
101
- "bench:http:spike": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts spike",
102
- "bench:http:soak": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts soak",
103
- "bench:http:storm": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts storm",
104
- "bench:http:boundary": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts boundary",
105
- "bench:http:elysia": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' elysia",
106
- "bench:http:ingress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' ingress",
107
- "bench:http:bun-only": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' bun",
108
- "bench:http:all-heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts 1",
109
- "bench:http:ac": "bun bench/autocannon-stress.mjs",
110
- "bench:http:stress:ac": "SCENARIO=03-stress bun bench/autocannon-stress.mjs",
111
- "bench:startup": "bun run bench/startup.ts",
112
- "bench:load": "bun run bench/http/load-phase.ts",
113
- "bench:ffi": "bun run bench/ffi/ffi-all.ts",
42
+ "./package.json": "./package.json"
43
+ },
44
+ "files": [
45
+ "index.ts",
46
+ "dist",
47
+ "src",
48
+ "docs",
49
+ "README.md",
50
+ "*.node",
51
+ "scripts/postinstall.mjs",
52
+ "rust",
53
+ "Cargo.toml",
54
+ "Cargo.lock",
55
+ "build.rs",
56
+ "rust-toolchain.toml",
57
+ "!src/bench",
58
+ "!src/baseline",
59
+ "!src/data"
60
+ ],
61
+ "scripts": {
62
+ "postinstall": "node scripts/postinstall.mjs",
63
+ "build": "napi build --release --platform",
64
+ "build:perf": "bash scripts/build-perf.sh",
65
+ "build:v3": "bash scripts/build-v3.sh",
66
+ "build:all": "bun run build && bun run build:v3",
67
+ "build:js": "bun run build:js:bundle && bun run build:js:types",
68
+ "build:js:bundle": "bun build ./index.ts --target=node --format=esm --outdir=dist",
69
+ "build:js:types": "dts-bundle-generator -o dist/index.d.ts index.ts",
70
+ "bench": "bun run build && bun bench.ts",
71
+ "check": "bun bench.ts",
72
+ "test": "bun test test/unit test/property test/compat",
73
+ "test:all": "bun test && bun run typecheck && bun run typecheck:test && bun run lint:ci && bun run check:clean && cargo test --lib",
74
+ "test:coverage": "node scripts/check-coverage.mjs",
75
+ "setup": "bun install && bun run build",
76
+ "test:node": "node --test test/integration/node-smoke.test.mjs test/integration/node-enterprise.test.mjs",
77
+ "verify:install": "node scripts/verify-install.mjs",
78
+ "build:debug": "napi build --platform",
79
+ "lint": "bunx biome check .",
80
+ "lint:fix": "bunx biome check --write .",
81
+ "lint:ci": "bunx biome check . --formatter-enabled=false --assist-enabled=false --error-on-warnings",
82
+ "format": "bunx biome format --write .",
83
+ "typecheck": "bunx tsc --noEmit",
84
+ "typecheck:test": "bunx tsc --noEmit -p tsconfig.test.json",
85
+ "test:rust": "cargo test",
86
+ "deny": "cargo deny check",
87
+ "check:version": "bun scripts/check-version.mjs",
88
+ "check:jsdoc": "bun scripts/check-jsdoc.ts",
89
+ "check:selection": "bun scripts/select-native.ts --check",
90
+ "check:clean": "bun scripts/check-clean.ts",
91
+ "gen:ai-map": "bun scripts/gen-ai-map.ts",
92
+ "audit": "bun audit",
93
+ "release": "bun scripts/release.ts",
94
+ "release:dry": "bun scripts/release.ts --dry-run",
95
+ "release:manual": "bun scripts/release.ts --publish",
96
+ "prepublishOnly": "bun run build:js && node scripts/prepublish.mjs",
97
+ "bench:http": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts",
98
+ "bench:http:smoke": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts smoke",
99
+ "bench:http:stress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts stress",
100
+ "bench:http:heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts heavy-json",
101
+ "bench:http:crud": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts crud",
102
+ "bench:http:spike": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts spike",
103
+ "bench:http:soak": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts soak",
104
+ "bench:http:storm": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts storm",
105
+ "bench:http:boundary": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts boundary",
106
+ "bench:http:elysia": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' elysia",
107
+ "bench:http:ingress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' ingress",
108
+ "bench:http:bun-only": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' bun",
109
+ "bench:http:all-heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts 1",
110
+ "bench:http:ac": "bun bench/autocannon-stress.mjs",
111
+ "bench:http:stress:ac": "SCENARIO=03-stress bun bench/autocannon-stress.mjs",
112
+ "bench:startup": "bun run bench/startup.ts",
113
+ "bench:load": "bun run bench/http/load-phase.ts",
114
+ "bench:ffi": "bun run bench/ffi/ffi-all.ts",
114
115
  "bench:newops": "bun bench/cost/rust-vs-js-new-ops.ts",
115
- "bench:ffi:load": "bun run bench/ffi/ffi-load.ts",
116
- "bench:ffi:public": "bun run bench/ffi/ffi-public.ts",
117
- "bench:ffi:workers": "bun run bench/ffi/ffi-workers.ts",
118
- "bench:margin": "bun run bench/ffi/ffi-margin.ts",
119
- "bench:router": "bun run bench/cost/router-cost.ts",
120
- "bench:ingress-cost": "bun run bench/cost/ingress-cost.ts",
121
- "bench:ingress-cost:post": "bun run bench/cost/ingress-cost-post.ts",
122
- "verify:native:batch": "bun run scripts/verify-native-batch.ts"
123
- },
124
- "dependencies": {},
125
- "peerDependencies": {
126
- "@types/node": "^26.1.2"
127
- },
128
- "peerDependenciesMeta": {
129
- "@types/node": {
130
- "optional": true
131
- }
132
- },
133
- "devDependencies": {
134
- "@elysia/cors": "^1.4.2",
135
- "@napi-rs/cli": "^3.8.6",
136
- "@types/bun": "^1.3.14",
137
- "@types/mime-types": "^3.0.1",
138
- "autocannon": "^8.0.0",
139
- "@types/validator": "^13.15.10",
140
- "ajv": "^8.20.0",
141
- "crc-32": "^1.2.2",
142
- "dts-bundle-generator": "^9.5.1",
143
- "elysia": "^1.4.29",
144
- "fast-json-patch": "^3.1.1",
145
- "mime-types": "^3.0.2",
146
- "typescript": "5.9.3",
147
- "validator": "^13.15.35"
148
- },
149
- "overrides": {
150
- "typescript": "5.9.3"
116
+ "bench:ffi:load": "bun run bench/ffi/ffi-load.ts",
117
+ "bench:ffi:public": "bun run bench/ffi/ffi-public.ts",
118
+ "bench:ffi:workers": "bun run bench/ffi/ffi-workers.ts",
119
+ "bench:margin": "bun run bench/ffi/ffi-margin.ts",
120
+ "bench:router": "bun run bench/cost/router-cost.ts",
121
+ "bench:task": "bun run bench/cost/task-offload.ts",
122
+ "bench:ingress-cost": "bun run bench/cost/ingress-cost.ts",
123
+ "bench:ingress-cost:post": "bun run bench/cost/ingress-cost-post.ts",
124
+ "verify:native:batch": "bun run scripts/verify-native-batch.ts"
125
+ },
126
+ "dependencies": {},
127
+ "peerDependencies": {
128
+ "@types/node": "^26.1.2"
129
+ },
130
+ "peerDependenciesMeta": {
131
+ "@types/node": {
132
+ "optional": true
133
+ }
134
+ },
135
+ "devDependencies": {
136
+ "@elysia/cors": "^1.4.2",
137
+ "@napi-rs/cli": "^3.8.6",
138
+ "@types/bun": "^1.3.14",
139
+ "@types/mime-types": "^3.0.1",
140
+ "autocannon": "^8.0.0",
141
+ "@types/validator": "^13.15.10",
142
+ "ajv": "^8.20.0",
143
+ "crc-32": "^1.2.2",
144
+ "dts-bundle-generator": "^9.5.1",
145
+ "elysia": "^1.4.29",
146
+ "fast-json-patch": "^3.1.1",
147
+ "mime-types": "^3.0.2",
148
+ "typescript": "7.0.2",
149
+ "validator": "^13.15.35"
150
+ },
151
+ "overrides": {
152
+ "typescript": "7.0.2",
153
+ "dts-bundle-generator": {
154
+ "typescript": "5.9.3"
151
155
  },
152
- "engines": {
153
- "bun": ">=1.1.0",
154
- "node": ">=20.3"
156
+ "@napi-rs/cli": {
157
+ "typescript": "6.0.3"
155
158
  },
156
- "napi": {
157
- "binaryName": "castrum",
158
- "targets": [
159
- "aarch64-apple-darwin",
160
- "aarch64-unknown-linux-gnu",
161
- "aarch64-unknown-linux-musl",
162
- "x86_64-apple-darwin",
163
- "x86_64-pc-windows-msvc",
164
- "x86_64-unknown-linux-gnu",
165
- "x86_64-unknown-linux-musl"
166
- ]
167
- }
168
- }
159
+ "hyperid": "^4.0.0"
160
+ },
161
+ "engines": {
162
+ "bun": ">=1.1.0",
163
+ "node": ">=20.3"
164
+ },
165
+ "napi": {
166
+ "binaryName": "castrum",
167
+ "targets": [
168
+ "aarch64-apple-darwin",
169
+ "aarch64-unknown-linux-gnu",
170
+ "aarch64-unknown-linux-musl",
171
+ "x86_64-apple-darwin",
172
+ "x86_64-pc-windows-msvc",
173
+ "x86_64-unknown-linux-gnu",
174
+ "x86_64-unknown-linux-musl"
175
+ ]
176
+ }
177
+ }
@@ -17,6 +17,15 @@ pub const PBKDF2_MIN_LEN: u32 = 1;
17
17
  /// Cap derived-key length so a hostile caller can't request a huge allocation.
18
18
  pub const PBKDF2_MAX_LEN: u32 = 1024 * 1024;
19
19
 
20
+ /// Pure core (no napi types) for [`pbkdf2_sha256`] and the off-thread task
21
+ /// runtime: derive `dk_len` bytes, clamping the length to the documented range.
22
+ pub fn pbkdf2_sha256_into(password: &[u8], salt: &[u8], rounds: u32, dk_len: u32) -> Vec<u8> {
23
+ let dk_len = dk_len.clamp(PBKDF2_MIN_LEN, PBKDF2_MAX_LEN) as usize;
24
+ let mut out = vec![0u8; dk_len];
25
+ pbkdf2_hmac::<Sha256>(password, salt, rounds.max(1), &mut out);
26
+ out
27
+ }
28
+
20
29
  /// Derive a PBKDF2-HMAC-SHA256 key: `out = PBKDF2(password, salt, rounds, dkLen)`.
21
30
  #[napi]
22
31
  pub fn pbkdf2_sha256(
@@ -83,13 +83,10 @@ pub fn open_core(token: &[u8], secret: &[u8]) -> Option<(i64, Vec<u8>, Vec<u8>)>
83
83
  if bytes.get(pos) != Some(&b'"') {
84
84
  return None;
85
85
  }
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;
86
+ let body = pos + 1;
87
+ let end = scan_json_string_end(bytes, body)?;
88
+ id = Some(unescape_json_id(&bytes[body..end]));
89
+ pos = end + 1;
93
90
  }
94
91
  b"exp" => {
95
92
  let vs = pos;
@@ -174,6 +171,108 @@ fn skip_json_value(bytes: &[u8], pos: usize) -> Option<usize> {
174
171
  }
175
172
  }
176
173
 
174
+ /// Index of the `"` that TERMINATES a JSON string whose body starts at `start`
175
+ /// (just past the opening quote).
176
+ ///
177
+ /// Escape-aware: the id used to be captured by scanning for the next `"`, which
178
+ /// stops at the quote inside an escaped `\"` — truncating the id AND leaving
179
+ /// `pos` in the middle of the string, so the rest of the envelope was then read
180
+ /// as structure (a quote-carrying id could make `open` fail outright).
181
+ fn scan_json_string_end(bytes: &[u8], start: usize) -> Option<usize> {
182
+ let mut i = start;
183
+ while i < bytes.len() {
184
+ match bytes[i] {
185
+ b'\\' => i += 2,
186
+ b'"' => return Some(i),
187
+ _ => i += 1,
188
+ }
189
+ }
190
+ None
191
+ }
192
+
193
+ /// Decode 4 hex digits at `at` (the `\uXXXX` payload).
194
+ fn hex4(bytes: &[u8], at: usize) -> Option<u32> {
195
+ let mut v = 0u32;
196
+ for k in 0..4 {
197
+ v = (v << 4) | (*bytes.get(at + k)? as char).to_digit(16)?;
198
+ }
199
+ Some(v)
200
+ }
201
+
202
+ /// Inverse of `json_ser::write_json_escaped` for the id: decodes `\"`, `\\`,
203
+ /// `\/`, `\b`, `\f`, `\n`, `\r`, `\t` and `\uXXXX`.
204
+ ///
205
+ /// `\u00XX` maps back to the single BYTE `XX`. That is deliberate and load
206
+ /// bearing: the escaper emits that form for control characters AND — in its
207
+ /// invalid-UTF-8 mode — for every byte of binary input, so routing it through a
208
+ /// code point and re-encoding as UTF-8 would expand bytes >= 0x80 into two
209
+ /// bytes and destroy the byte-exact round trip the id is supposed to have.
210
+ /// (A valid-UTF-8 id is written RAW by the escaper, so `\u00XX` cannot be its
211
+ /// origin.)
212
+ ///
213
+ /// `\uXXXX` above 0xFF can only come from a different producer (e.g. JS
214
+ /// `JSON.stringify` of a non-ASCII id), so it is decoded as a code point with
215
+ /// surrogate-pair combining. Unknown escapes and a trailing backslash are kept
216
+ /// verbatim — never silently dropped.
217
+ fn unescape_json_id(escaped: &[u8]) -> Vec<u8> {
218
+ let mut out = Vec::with_capacity(escaped.len());
219
+ let mut i = 0usize;
220
+ while i < escaped.len() {
221
+ if escaped[i] != b'\\' {
222
+ out.push(escaped[i]);
223
+ i += 1;
224
+ continue;
225
+ }
226
+ let Some(&e) = escaped.get(i + 1) else {
227
+ out.push(b'\\');
228
+ break;
229
+ };
230
+ i += 2;
231
+ match e {
232
+ b'"' => out.push(b'"'),
233
+ b'\\' => out.push(b'\\'),
234
+ b'/' => out.push(b'/'),
235
+ b'b' => out.push(0x08),
236
+ b'f' => out.push(0x0c),
237
+ b'n' => out.push(b'\n'),
238
+ b'r' => out.push(b'\r'),
239
+ b't' => out.push(b'\t'),
240
+ b'u' => {
241
+ let Some(cp) = hex4(escaped, i) else {
242
+ out.push(b'\\');
243
+ out.push(b'u');
244
+ continue;
245
+ };
246
+ i += 4;
247
+ if cp <= 0xFF {
248
+ out.push(cp as u8);
249
+ continue;
250
+ }
251
+ let mut code = cp;
252
+ if (0xD800..=0xDBFF).contains(&cp)
253
+ && escaped.get(i) == Some(&b'\\')
254
+ && escaped.get(i + 1) == Some(&b'u')
255
+ {
256
+ if let Some(lo) = hex4(escaped, i + 2) {
257
+ if (0xDC00..=0xDFFF).contains(&lo) {
258
+ code = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
259
+ i += 6;
260
+ }
261
+ }
262
+ }
263
+ let mut buf = [0u8; 4];
264
+ let ch = char::from_u32(code).unwrap_or('\u{FFFD}');
265
+ out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
266
+ }
267
+ other => {
268
+ out.push(b'\\');
269
+ out.push(other);
270
+ }
271
+ }
272
+ }
273
+ out
274
+ }
275
+
177
276
  #[cfg(test)]
178
277
  mod tests {
179
278
  use super::*;
@@ -209,6 +308,64 @@ mod tests {
209
308
  assert!(seal_core(b"", b"{}", 0, SECRET).is_none());
210
309
  }
211
310
 
311
+ #[test]
312
+ fn id_round_trips_byte_exactly_through_escapes() {
313
+ // A `"` used to terminate the id scan early, and the id came back
314
+ // escaped. Both are the same root cause: an escape-unaware scan with no
315
+ // decode. Every id here must survive the envelope byte-for-byte.
316
+ for id in [
317
+ &b"plain-id"[..],
318
+ &b"sess\"quote"[..],
319
+ &b"back\\slash"[..],
320
+ &b"new\nline"[..],
321
+ &b"tab\there"[..],
322
+ &b"cr\rhere"[..],
323
+ &b"nul\0byte"[..],
324
+ &b"binary\xff\xfe"[..],
325
+ &b"trailing\\"[..],
326
+ ] {
327
+ let token = seal_core(id, br#"{"a":1}"#, 42, SECRET).expect("seal");
328
+ let (exp, got, data) = open_core(&token, SECRET).expect("open");
329
+ assert_eq!(got, id, "id must round-trip byte-exactly");
330
+ assert_eq!(exp, 42);
331
+ assert_eq!(data, br#"{"a":1}"#);
332
+ }
333
+ }
334
+
335
+ #[test]
336
+ fn unescape_json_id_matches_the_escaper() {
337
+ assert_eq!(unescape_json_id(br#"a\"b"#), b"a\"b");
338
+ assert_eq!(unescape_json_id(br#"a\\b"#), b"a\\b");
339
+ assert_eq!(unescape_json_id(br#"a\/b"#), b"a/b");
340
+ assert_eq!(unescape_json_id(br#"a\nb"#), b"a\nb");
341
+ assert_eq!(unescape_json_id(br#"a\rb"#), b"a\rb");
342
+ assert_eq!(unescape_json_id(br#"a\tb"#), b"a\tb");
343
+ assert_eq!(unescape_json_id(br#"a\bb"#), b"a\x08b");
344
+ assert_eq!(unescape_json_id(br#"a\fb"#), b"a\x0cb");
345
+ // `\u00XX` is a BYTE (the escaper's binary mode), not a code point.
346
+ assert_eq!(unescape_json_id(br#"a\u00e9b"#), b"a\xe9b");
347
+ // Above 0xFF is a real code point → UTF-8.
348
+ assert_eq!(unescape_json_id(br#"a\u20acb"#), "a\u{20ac}b".as_bytes());
349
+ // A surrogate pair combines into one code point.
350
+ assert_eq!(unescape_json_id(br#"\ud83d\ude00"#), "\u{1f600}".as_bytes());
351
+ // Unknown escapes and a trailing backslash stay verbatim.
352
+ assert_eq!(unescape_json_id(br#"a\qb"#), br#"a\qb"#);
353
+ assert_eq!(unescape_json_id(b"a\\"), b"a\\");
354
+ }
355
+
356
+ #[test]
357
+ fn scan_json_string_end_skips_escaped_quotes() {
358
+ // `a\"b"` (a, \, ", b, ") — the escaped quote does NOT terminate; the
359
+ // plain quote at index 4 does.
360
+ assert_eq!(scan_json_string_end(b"a\\\"b\"", 0), Some(4));
361
+ // No escape at all: the first quote terminates.
362
+ assert_eq!(scan_json_string_end(b"ab\"cd", 0), Some(2));
363
+ // Unterminated: the trailing backslash swallows the rest.
364
+ assert_eq!(scan_json_string_end(b"ab\\\"", 0), None);
365
+ // Escaped BACKSLASH then a terminator: `a\\"` = a, \, \, " → Some(3).
366
+ assert_eq!(scan_json_string_end(b"a\\\\\"", 0), Some(3));
367
+ }
368
+
212
369
  #[test]
213
370
  fn wire_format_matches_sign_cookie() {
214
371
  // The sealed token must be verifiable by the EXISTING cookie verify