castrum 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
@@ -0,0 +1,19 @@
1
+ //! JSON & schema (zero-DOM validate/sum + DOM parse + fast schema engine).
2
+ //
3
+ // - json_ops.rs zero-DOM validate/sum + DOM parse
4
+ // - json_ser.rs zero-alloc JSON escaping + cookie/query → JSON writers
5
+ // - patch/ RFC 6902 JSON patch (pointer/ops/engine/api)
6
+ // - json_schema.rs SchemaValidator napi class (fast + fallback)
7
+ // - napi_marshal.rs sonic_rs::Value → JS marshaling + sonic values_equal
8
+ // (serde_json::Value DOM swap; jsonschema-compatible
9
+ // equality for fast_schema enum/const/uniqueItems)
10
+ // - fast_schema/ zero-DOM JSON Schema fast path (compiles the common
11
+ // keyword subset into a FastNode AST; unsupported
12
+ // keywords fall back to the jsonschema crate DOM path)
13
+
14
+ pub mod fast_schema;
15
+ pub mod json_ops;
16
+ pub mod json_schema;
17
+ pub mod json_ser;
18
+ pub mod napi_marshal;
19
+ pub mod patch;
@@ -0,0 +1,318 @@
1
+ //! sonic-rs → JS value marshaling + jsonschema-compatible equality.
2
+ //!
3
+ //! `serde_json::Value` DOMs are being replaced with `sonic_rs::Value` (compact
4
+ //! 16-byte tagged nodes, borrowed/inline strings, zero per-key heap `String`)
5
+ //! across the crate. The napi `serde-json` feature only marshals
6
+ //! `serde_json::Value` to JS, so this module supplies the hand-written
7
+ //! equivalent for `sonic_rs::Value`:
8
+ //!
9
+ //! - [`sonic_value_to_js`] walks a `sonic_rs::Value` directly into a JS value
10
+ //! (no `serde_json::Value` intermediate, no generic serde trait dispatch).
11
+ //! Number handling is byte-parity with the napi `serde-json` `ToNapiValue
12
+ //! for serde_json::Number`: `PosInt` u64 ≤ u32::MAX → **Number**, larger →
13
+ //! **BigInt**; `NegInt` i64 within ±2^53-1 → **Number**, outside → **BigInt**;
14
+ //! `Float` → **Number** (see [`sonic_number_to_js`]).
15
+ //! - [`sonic_values_equal`] replicates jsonschema's `cmp::equal` (exact
16
+ //! cross-type numeric equality via `num_cmp`, insertion-order object keys)
17
+ //! for the fast_schema enum/const/uniqueItems paths.
18
+ //!
19
+ //! This module is napi-type-laden by design (it bridges to JS); pure numeric /
20
+ //! equality logic stays in the small private fns below and is unit-tested.
21
+
22
+ use napi::bindgen_prelude::{
23
+ BigInt, Env, FromNapiValue, Null, Object, Result, ToNapiValue, Unknown,
24
+ };
25
+ use sonic_rs::JsonNumberTrait;
26
+
27
+ /// Wrap a `ToNapiValue` as an unconstrained-lifetime `Unknown`.
28
+ ///
29
+ /// `Unknown<'env>` cannot borrow from the `Env` function parameter (that local
30
+ /// borrow can't escape the call), so we build the raw `napi_value` and re-wrap
31
+ /// via `Unknown::from_napi_value(env.raw(), …)` — the raw `napi_env`/`napi_value`
32
+ /// are Copy pointers rooted by the caller's handle scope, so the `'static`
33
+ /// phantom is nominal (the same pattern napi-rs uses for
34
+ /// `#[napi] fn(env: Env) -> Result<Unknown>`).
35
+ #[inline]
36
+ fn to_unknown(env: &Env, v: impl ToNapiValue) -> Result<Unknown<'static>> {
37
+ // Safety: `env.raw()` is a valid napi_env for the call, and `v`'s JS value
38
+ // is rooted in the caller's handle scope.
39
+ let raw = unsafe { ToNapiValue::to_napi_value(env.raw(), v) }?;
40
+ // Safety: same env + the just-created value are valid.
41
+ unsafe { Unknown::from_napi_value(env.raw(), raw) }
42
+ }
43
+
44
+ /// Marshal a `sonic_rs::Value` into a JS value.
45
+ ///
46
+ /// The returned JS value is fully independent of `value` (napi builds new
47
+ /// primitives/containers), so `value` may borrow from a caller buffer that is
48
+ /// released after the call.
49
+ pub(crate) fn sonic_value_to_js(env: &Env, value: &sonic_rs::Value) -> Result<Unknown<'static>> {
50
+ match value.as_ref() {
51
+ sonic_rs::value::ValueRef::Null => to_unknown(env, Null),
52
+ sonic_rs::value::ValueRef::Bool(b) => to_unknown(env, b),
53
+ sonic_rs::value::ValueRef::Number(n) => sonic_number_to_js(env, &n),
54
+ sonic_rs::value::ValueRef::String(s) => to_unknown(env, env.create_string(s)?),
55
+ sonic_rs::value::ValueRef::Array(arr) => {
56
+ let mut out = env.create_array(arr.len() as u32)?;
57
+ for (i, item) in arr.into_iter().enumerate() {
58
+ out.set(i as u32, sonic_value_to_js(env, item)?)?;
59
+ }
60
+ to_unknown(env, out)
61
+ }
62
+ sonic_rs::value::ValueRef::Object(obj) => {
63
+ let mut out = Object::new(env)?;
64
+ for (k, v) in obj.iter() {
65
+ out.set(k, sonic_value_to_js(env, v)?)?;
66
+ }
67
+ to_unknown(env, out)
68
+ }
69
+ }
70
+ }
71
+
72
+ /// Marshal a sonic `Number` to JS with **exact** parity to the napi
73
+ /// `serde-json` `ToNapiValue for serde_json::Number` (the behavior the old
74
+ /// `serde_json::Value` returns produced):
75
+ /// - PosInt (u64): `> u32::MAX` → **BigInt**, else → **Number** (the napi
76
+ /// serde-json impl routes u64 ≤ u32::MAX through `u32::to_napi_value`).
77
+ /// - NegInt (i64): outside ±2^53-1 (`MAX_SAFE_INT`) → **BigInt**, else →
78
+ /// **Number**.
79
+ /// - Float: **Number**.
80
+ ///
81
+ /// `as_u64` is checked first so `PosInt` mirrors serde_json's u64-backed
82
+ /// `Number` (a positive integer that also fits i64 must still take the u64
83
+ /// branch — e.g. `3_000_000_000` is BigInt in serde_json, not Number).
84
+ fn sonic_number_to_js(env: &Env, n: &sonic_rs::Number) -> Result<Unknown<'static>> {
85
+ const MAX_SAFE_INT: i64 = 9007199254740991; // 2^53 - 1 (napi `napi6` guard)
86
+ if let Some(u) = n.as_u64() {
87
+ if u > u32::MAX as u64 {
88
+ to_unknown(env, BigInt::from(u))
89
+ } else {
90
+ to_unknown(env, u as u32)
91
+ }
92
+ } else if let Some(i) = n.as_i64() {
93
+ if !(-MAX_SAFE_INT..=MAX_SAFE_INT).contains(&i) {
94
+ to_unknown(env, BigInt::from(i))
95
+ } else {
96
+ to_unknown(env, i)
97
+ }
98
+ } else if let Some(f) = n.as_f64() {
99
+ to_unknown(env, f)
100
+ } else {
101
+ // Unreachable for a parsed JSON number (all three are mutually
102
+ // exhaustive over PosInt/NegInt/Float); defensive fallback.
103
+ to_unknown(env, Null)
104
+ }
105
+ }
106
+
107
+ /// JSON value equality replicating jsonschema's `cmp::equal` (exact across
108
+ /// u64/i64/f64 via `num_cmp`, insertion-order object keys). This is the sonic
109
+ /// `Value` twin of the serde_json `values_equal` in fast_schema/validate.rs —
110
+ /// keep the two semantically identical (the fast path must stay byte-parity
111
+ /// with the jsonschema crate).
112
+ pub(crate) fn sonic_values_equal(a: &sonic_rs::Value, b: &sonic_rs::Value) -> bool {
113
+ use sonic_rs::value::ValueRef;
114
+ match (a.as_ref(), b.as_ref()) {
115
+ (ValueRef::Null, ValueRef::Null) => true,
116
+ (ValueRef::Bool(x), ValueRef::Bool(y)) => x == y,
117
+ (ValueRef::Number(x), ValueRef::Number(y)) => sonic_numbers_equal(&x, &y),
118
+ (ValueRef::String(x), ValueRef::String(y)) => x == y,
119
+ (ValueRef::Array(x), ValueRef::Array(y)) => {
120
+ x.len() == y.len()
121
+ && x.iter()
122
+ .zip(y.iter())
123
+ .all(|(a, b)| sonic_values_equal(a, b))
124
+ }
125
+ (ValueRef::Object(x), ValueRef::Object(y)) => {
126
+ // Insertion-order comparison, replicating jsonschema's cmp::equal
127
+ // (key order is SIGNIFICANT in the crate's object equality).
128
+ x.len() == y.len()
129
+ && x.iter()
130
+ .zip(y.iter())
131
+ .all(|((ka, va), (kb, vb))| ka == kb && sonic_values_equal(va, vb))
132
+ }
133
+ _ => false,
134
+ }
135
+ }
136
+
137
+ /// Exact cross-representation number equality (num_cmp semantics, matching
138
+ /// jsonschema's `equal_numbers` without arbitrary-precision).
139
+ fn sonic_numbers_equal(a: &sonic_rs::Number, b: &sonic_rs::Number) -> bool {
140
+ use num_cmp::NumCmp;
141
+ if let Some(x) = a.as_u64() {
142
+ if let Some(y) = b.as_u64() {
143
+ return x == y;
144
+ }
145
+ if let Some(y) = b.as_i64() {
146
+ return y >= 0 && x == y as u64;
147
+ }
148
+ return x.num_eq(b.as_f64().unwrap_or(f64::NAN));
149
+ }
150
+ if let Some(x) = a.as_i64() {
151
+ if let Some(y) = b.as_i64() {
152
+ return x == y;
153
+ }
154
+ if let Some(y) = b.as_u64() {
155
+ return x >= 0 && y == x as u64;
156
+ }
157
+ return x.num_eq(b.as_f64().unwrap_or(f64::NAN));
158
+ }
159
+ if let Some(x) = a.as_f64() {
160
+ if let Some(y) = b.as_f64() {
161
+ return x == y;
162
+ }
163
+ if let Some(y) = b.as_u64() {
164
+ return x.num_eq(y);
165
+ }
166
+ if let Some(y) = b.as_i64() {
167
+ return x.num_eq(y);
168
+ }
169
+ }
170
+ false
171
+ }
172
+
173
+ #[cfg(test)]
174
+ mod tests {
175
+ use super::*;
176
+ use serde_json::json;
177
+
178
+ /// Round-trip a serde_json Value through JSON bytes into a sonic Value.
179
+ fn to_sonic(sv: &serde_json::Value) -> sonic_rs::Value {
180
+ sonic_rs::from_slice::<sonic_rs::Value>(&serde_json::to_vec(sv).unwrap()).unwrap()
181
+ }
182
+
183
+ /// The serde_json reference implementation this port must match.
184
+ fn serde_values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
185
+ match (a, b) {
186
+ (serde_json::Value::Null, serde_json::Value::Null) => true,
187
+ (serde_json::Value::Bool(x), serde_json::Value::Bool(y)) => x == y,
188
+ (serde_json::Value::Number(x), serde_json::Value::Number(y)) => {
189
+ serde_numbers_equal(x, y)
190
+ }
191
+ (serde_json::Value::String(x), serde_json::Value::String(y)) => x == y,
192
+ (serde_json::Value::Array(x), serde_json::Value::Array(y)) => {
193
+ x.len() == y.len()
194
+ && x.iter()
195
+ .zip(y.iter())
196
+ .all(|(a, b)| serde_values_equal(a, b))
197
+ }
198
+ (serde_json::Value::Object(x), serde_json::Value::Object(y)) => {
199
+ x.len() == y.len()
200
+ && x.iter()
201
+ .zip(y.iter())
202
+ .all(|((ka, va), (kb, vb))| ka == kb && serde_values_equal(va, vb))
203
+ }
204
+ _ => false,
205
+ }
206
+ }
207
+
208
+ fn serde_numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
209
+ use num_cmp::NumCmp;
210
+ if let Some(x) = a.as_u64() {
211
+ if let Some(y) = b.as_u64() {
212
+ return x == y;
213
+ }
214
+ if let Some(y) = b.as_i64() {
215
+ return y >= 0 && x == y as u64;
216
+ }
217
+ return x.num_eq(b.as_f64().unwrap_or(f64::NAN));
218
+ }
219
+ if let Some(x) = a.as_i64() {
220
+ if let Some(y) = b.as_i64() {
221
+ return x == y;
222
+ }
223
+ if let Some(y) = b.as_u64() {
224
+ return x >= 0 && y == x as u64;
225
+ }
226
+ return x.num_eq(b.as_f64().unwrap_or(f64::NAN));
227
+ }
228
+ if let Some(x) = a.as_f64() {
229
+ if let Some(y) = b.as_f64() {
230
+ return x == y;
231
+ }
232
+ if let Some(y) = b.as_u64() {
233
+ return x.num_eq(y);
234
+ }
235
+ if let Some(y) = b.as_i64() {
236
+ return x.num_eq(y);
237
+ }
238
+ }
239
+ false
240
+ }
241
+
242
+ fn corpus() -> Vec<serde_json::Value> {
243
+ vec![
244
+ json!(null),
245
+ json!(true),
246
+ json!(false),
247
+ json!(0),
248
+ json!(1),
249
+ json!(-1),
250
+ json!(i64::MAX),
251
+ json!(i64::MIN),
252
+ json!(u64::MAX),
253
+ json!(1.5),
254
+ json!(-2.25),
255
+ json!(0.0),
256
+ json!(1e300),
257
+ json!(""),
258
+ json!("hello"),
259
+ json!("héllo wörld 😀"),
260
+ json!(r#"escaped "quotes" \n \u0041"#),
261
+ json!([]),
262
+ json!([1, "two", true, null, [3.5], {"k": "v"}]),
263
+ json!({"a": 1, "b": [1, 2, 3], "c": {"d": null, "e": "x"}}),
264
+ ]
265
+ }
266
+
267
+ #[test]
268
+ fn sonic_values_equal_matches_serde_reference() {
269
+ let c = corpus();
270
+ for a in &c {
271
+ for b in &c {
272
+ let sa = to_sonic(a);
273
+ let sb = to_sonic(b);
274
+ let sonic = sonic_values_equal(&sa, &sb);
275
+ let serde = serde_values_equal(a, b);
276
+ assert_eq!(
277
+ sonic, serde,
278
+ "mismatch: {a} vs {b} (sonic={sonic}, serde={serde})"
279
+ );
280
+ }
281
+ }
282
+ }
283
+
284
+ #[test]
285
+ fn numeric_cross_type_equality() {
286
+ // 1 == 1.0 across representations (jsonschema cmp::equal).
287
+ let one_int: serde_json::Value = json!(1);
288
+ let one_float: serde_json::Value = json!(1.0);
289
+ assert!(sonic_values_equal(
290
+ &to_sonic(&one_int),
291
+ &to_sonic(&one_float)
292
+ ));
293
+
294
+ // u64 that exceeds i64::MAX is NOT equal to its wrapped i64.
295
+ let big: serde_json::Value = json!(u64::MAX);
296
+ let neg: serde_json::Value = json!(-1);
297
+ assert!(!sonic_values_equal(&to_sonic(&big), &to_sonic(&neg)));
298
+
299
+ // Exact fractional equality (1.5 != 1.5000000000000001 differs).
300
+ let a: serde_json::Value = json!(1.5);
301
+ let b: serde_json::Value = json!(1.5000000000000002);
302
+ assert!(!sonic_values_equal(&to_sonic(&a), &to_sonic(&b)));
303
+
304
+ // String vs number never equal.
305
+ assert!(!sonic_values_equal(
306
+ &to_sonic(&json!("1")),
307
+ &to_sonic(&json!(1))
308
+ ));
309
+ }
310
+
311
+ #[test]
312
+ fn object_key_order_is_significant() {
313
+ let a = json!({"a": 1, "b": 2});
314
+ let b = json!({"b": 2, "a": 1});
315
+ assert!(sonic_values_equal(&to_sonic(&a), &to_sonic(&a)));
316
+ assert!(!sonic_values_equal(&to_sonic(&a), &to_sonic(&b)));
317
+ }
318
+ }
@@ -0,0 +1,60 @@
1
+ // rust/json/patch/api.rs — napi boundary for RFC 6902 JSON Patch.
2
+ //
3
+ // Thin `#[napi]` wrappers over the pure core in `engine.rs` that map
4
+ // `JsonPatchError` → napi `Error` with stable context.
5
+
6
+ use napi::bindgen_prelude::*;
7
+ use napi_derive::napi;
8
+
9
+ use crate::util::unpack;
10
+
11
+ use super::engine::{apply_json_patch_bytes, run_json_patch_batch};
12
+
13
+ /// Apply an RFC 6902 JSON Patch to a JSON document.
14
+ ///
15
+ /// `doc` and `patch` are raw JSON byte buffers; the patched document is
16
+ /// returned as JSON bytes. Throws on invalid JSON or an inapplicable patch.
17
+ #[napi]
18
+ pub fn json_patch(doc: Uint8Array, patch: Uint8Array) -> Result<Buffer> {
19
+ apply_json_patch_bytes(doc.as_ref(), patch.as_ref())
20
+ .map(Buffer::from)
21
+ .map_err(|e| Error::from_reason(e.to_string()))
22
+ }
23
+
24
+ /// Apply a batch of RFC 6902 patches (packed in → packed out).
25
+ ///
26
+ /// `docs` and `patches` are packed buffers (`[u32 count] { [u32 len] [bytes] }`)
27
+ /// of equal length, zipped per index. Returns packed results
28
+ /// (`[u32 count] { [u32 len] [bytes] }`). **Fail-fast**: an invalid item aborts
29
+ /// the whole batch with an index-bearing error — unlike the bitset-style batch
30
+ /// helpers, silently dropping a patch would risk silent data loss.
31
+ #[napi]
32
+ pub fn json_patch_batch_packed(docs: Uint8Array, patches: Uint8Array) -> Result<Buffer> {
33
+ let doc_items = unpack(docs.as_ref()).map_err(|e| Error::from_reason(e.to_string()))?;
34
+ let patch_items = unpack(patches.as_ref()).map_err(|e| Error::from_reason(e.to_string()))?;
35
+
36
+ if doc_items.len() != patch_items.len() {
37
+ return Err(Error::from_reason(format!(
38
+ "json patch batch: docs count {} != patches count {}",
39
+ doc_items.len(),
40
+ patch_items.len()
41
+ )));
42
+ }
43
+
44
+ if doc_items.is_empty() {
45
+ // Packed empty result: `[u32 count = 0]`.
46
+ return Ok(Buffer::from(vec![0u8; 4]));
47
+ }
48
+
49
+ let results = run_json_patch_batch(&doc_items, &patch_items)
50
+ .map_err(|e| Error::from_reason(e.to_string()))?;
51
+
52
+ let body: usize = results.iter().map(|result| result.len() + 4).sum();
53
+ let mut out = Vec::with_capacity(4 + body);
54
+ out.extend_from_slice(&(results.len() as u32).to_le_bytes());
55
+ for result in &results {
56
+ out.extend_from_slice(&(result.len() as u32).to_le_bytes());
57
+ out.extend_from_slice(result);
58
+ }
59
+ Ok(Buffer::from(out))
60
+ }
@@ -0,0 +1,153 @@
1
+ // rust/json/patch/engine.rs — bounded RFC 6902 apply + parallel packed batch.
2
+ //
3
+ // The napi-free, unit-testable core: `apply_json_patch_bytes` (bounded scalar
4
+ // apply) and `run_json_patch_batch` (serial/rayon batch). The op semantics
5
+ // live in `ops.rs`; the napi boundary is in `api.rs`.
6
+
7
+ use crate::util::{should_parallelize, total_bytes};
8
+
9
+ use super::ops::{apply_patch, parse_patch};
10
+
11
+ /// Maximum accepted size of a single JSON document or patch (defense in depth
12
+ /// before parsing). Generous; only adversarial inputs trip it.
13
+ const MAX_JSON_PATCH_INPUT: usize = 64 * 1024 * 1024;
14
+
15
+ /// Maximum serialized output size. A result guard: an oversized (e.g.
16
+ /// `copy`-amplified) result is rejected rather than returned to the caller.
17
+ const MAX_JSON_PATCH_OUTPUT: usize = 128 * 1024 * 1024;
18
+
19
+ /// JSON Patch errors, rendered with stable, human-readable context.
20
+ #[derive(Debug)]
21
+ pub enum JsonPatchError {
22
+ /// The document bytes are not valid JSON.
23
+ InvalidDoc(String),
24
+ /// The patch bytes are not a valid RFC 6902 patch document.
25
+ InvalidPatch(String),
26
+ /// The patch could not be applied (test mismatch, missing path, type clash).
27
+ ApplyFailed(String),
28
+ /// An input or the serialized output exceeded the bounded-size guard.
29
+ TooLarge(&'static str, usize),
30
+ /// Output serialization failed (cannot happen for a valid `Value`).
31
+ Serialize(String),
32
+ /// Batch item `index` failed to apply.
33
+ BatchItem {
34
+ index: usize,
35
+ source: Box<JsonPatchError>,
36
+ },
37
+ }
38
+
39
+ impl std::fmt::Display for JsonPatchError {
40
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41
+ match self {
42
+ Self::InvalidDoc(e) => write!(f, "json patch: invalid document: {e}"),
43
+ Self::InvalidPatch(e) => write!(f, "json patch: invalid patch: {e}"),
44
+ Self::ApplyFailed(e) => write!(f, "json patch: apply failed: {e}"),
45
+ Self::TooLarge(kind, n) => {
46
+ write!(f, "json patch: {kind} size {n} exceeds maximum")
47
+ }
48
+ Self::Serialize(e) => write!(f, "json patch: serialize failed: {e}"),
49
+ Self::BatchItem { index, source } => {
50
+ write!(f, "json patch batch: item {index} failed: {source}")
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ /// Apply `patch` to `doc` (RFC 6902) and return the serialized result.
57
+ ///
58
+ /// Pure-Rust core — no napi types, fully unit-testable. Tests exercise the size
59
+ /// guards via [`apply_json_patch_bytes_limited`] with small limits.
60
+ pub fn apply_json_patch_bytes(
61
+ doc: &[u8],
62
+ patch: &[u8],
63
+ ) -> std::result::Result<Vec<u8>, JsonPatchError> {
64
+ apply_json_patch_bytes_limited(doc, patch, MAX_JSON_PATCH_INPUT, MAX_JSON_PATCH_OUTPUT)
65
+ }
66
+
67
+ pub(crate) fn apply_json_patch_bytes_limited(
68
+ doc: &[u8],
69
+ patch: &[u8],
70
+ max_input: usize,
71
+ max_output: usize,
72
+ ) -> std::result::Result<Vec<u8>, JsonPatchError> {
73
+ if doc.len() > max_input {
74
+ return Err(JsonPatchError::TooLarge("document", doc.len()));
75
+ }
76
+ if patch.len() > max_input {
77
+ return Err(JsonPatchError::TooLarge("patch", patch.len()));
78
+ }
79
+
80
+ let mut doc_val: sonic_rs::Value =
81
+ sonic_rs::from_slice(doc).map_err(|e| JsonPatchError::InvalidDoc(e.to_string()))?;
82
+
83
+ let ops = parse_patch(patch)?;
84
+ apply_patch(&mut doc_val, &ops).map_err(JsonPatchError::ApplyFailed)?;
85
+
86
+ // The overwhelmingly common case is an in-place field/array edit, which
87
+ // keeps the result ≈ the document size; grow only if the patch expands it.
88
+ // The output is then bounded as a *result guard*: the sonic DOM has
89
+ // already been (potentially `copy`-amplified), so refusing oversized
90
+ // output prevents handing a giant buffer back to the caller.
91
+ let mut out = Vec::with_capacity(doc.len().min(max_output));
92
+ sonic_rs::to_writer(&mut out, &doc_val)
93
+ .map_err(|e| JsonPatchError::Serialize(e.to_string()))?;
94
+
95
+ if out.len() > max_output {
96
+ return Err(JsonPatchError::TooLarge("output", out.len()));
97
+ }
98
+ Ok(out)
99
+ }
100
+
101
+ /// The first failure encountered while processing a batch.
102
+ struct BatchFailure {
103
+ index: usize,
104
+ source: JsonPatchError,
105
+ }
106
+
107
+ /// Pure-Rust batch core: zip `docs` with `patches` and apply each pair.
108
+ ///
109
+ /// Parallel via rayon when the workload justifies it; otherwise serial and
110
+ /// deterministic (first error wins). Errors are index-bearing
111
+ /// ([`JsonPatchError::BatchItem`]). Successful results are order-preserving.
112
+ pub fn run_json_patch_batch(
113
+ docs: &[&[u8]],
114
+ patches: &[&[u8]],
115
+ ) -> std::result::Result<Vec<Vec<u8>>, JsonPatchError> {
116
+ debug_assert_eq!(docs.len(), patches.len());
117
+
118
+ if should_parallelize(docs.len(), total_bytes(docs)) {
119
+ use rayon::prelude::*;
120
+ let results: std::result::Result<Vec<Vec<u8>>, BatchFailure> = docs
121
+ .par_iter()
122
+ .zip(patches.par_iter())
123
+ .enumerate()
124
+ .map(
125
+ |(index, (doc, patch))| match apply_json_patch_bytes(doc, patch) {
126
+ Ok(result) => Ok(result),
127
+ Err(source) => Err(BatchFailure { index, source }),
128
+ },
129
+ )
130
+ .collect();
131
+ match results {
132
+ Ok(results) => Ok(results),
133
+ Err(BatchFailure { index, source }) => Err(JsonPatchError::BatchItem {
134
+ index,
135
+ source: Box::new(source),
136
+ }),
137
+ }
138
+ } else {
139
+ let mut results = Vec::with_capacity(docs.len());
140
+ for (index, (doc, patch)) in docs.iter().zip(patches.iter()).enumerate() {
141
+ match apply_json_patch_bytes(doc, patch) {
142
+ Ok(result) => results.push(result),
143
+ Err(source) => {
144
+ return Err(JsonPatchError::BatchItem {
145
+ index,
146
+ source: Box::new(source),
147
+ })
148
+ }
149
+ }
150
+ }
151
+ Ok(results)
152
+ }
153
+ }
@@ -0,0 +1,47 @@
1
+ //! RFC 6902 JSON Patch — bounded scalar apply + parallel packed batch.
2
+ //!
3
+ //! Pure-Rust core (`apply_json_patch_bytes`, `run_json_patch_batch`) is
4
+ //! napi-free and unit-testable; the `#[napi]` entry points (`json_patch`,
5
+ //! `json_patch_batch_packed`) are thin boundaries that map `JsonPatchError` →
6
+ //! napi `Error` with stable context.
7
+ //!
8
+ //! Folder layout:
9
+ //! - `pointer.rs` — RFC 6901 JSON Pointer parsing (escape rules).
10
+ //! - `ops.rs` — patch document parsing + RFC 6902 apply over sonic Value.
11
+ //! - `engine.rs` — bounded scalar apply + serial/rayon packed batch.
12
+ //! - `api.rs` — napi boundary.
13
+ //! - `tests.rs` — unit tests.
14
+ //!
15
+ //! **Sonic engine**: the `json-patch` crate is hardwired to `serde_json::Value`
16
+ //! (a per-key heap `String` DOM). This module implements RFC 6902 directly over
17
+ //! `sonic_rs::Value` (compact tagged nodes, no per-key heap `String`), so the
18
+ //! patch path no longer builds a `serde_json` DOM. RFC 6901 pointer escapes
19
+ //! (`~0`→`~`, `~1`→`/`) are validated eagerly at patch parse (bad escapes →
20
+ //! `InvalidPatch`, matching the crate's eager deserialization). The `test` op
21
+ //! uses sonic's `Value ==`, which has serde_json-identical number semantics
22
+ //! (`1 != 1.0`), preserving the crate's behavior.
23
+ //!
24
+ //! Hardening / efficiency notes:
25
+ //! - **Bounded sizes** — inputs are capped (`MAX_JSON_PATCH_INPUT`) and the
26
+ //! result is capped (`MAX_JSON_PATCH_OUTPUT`). A malicious chain of
27
+ //! RFC 6902 `copy` ops duplicates subtrees and can grow the output
28
+ //! exponentially relative to the input; the cap refuses to hand such output
29
+ //! back to the caller.
30
+ //! - **Capacity** — the common in-place field/array edit keeps the result ≈ the
31
+ //! document size, so serialization starts at `doc.len()` and grows only when
32
+ //! a patch genuinely expands the document (no 2× over-allocation for
33
+ //! `test`/`remove`-heavy patches).
34
+ //! - **Scalability** — `json_patch_batch_packed` zips two packed buffers and
35
+ //! applies patches in parallel via rayon when the workload justifies it
36
+ //! (`should_parallelize`), staying serial and deterministic otherwise.
37
+
38
+ mod api;
39
+ mod engine;
40
+ mod ops;
41
+ mod pointer;
42
+
43
+ #[cfg(test)]
44
+ mod tests;
45
+
46
+ pub use self::api::{json_patch, json_patch_batch_packed};
47
+ pub use self::engine::{apply_json_patch_bytes, run_json_patch_batch, JsonPatchError};