castrum 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,265 @@
1
+ // rust/crypto/argon2.rs — argon2id password hashing.
2
+ //
3
+ // Backend-framework feature: password storage hashing. argon2id is the OWASP
4
+ // recommendation for new hashes; it is deliberately CPU/memory-heavy, which
5
+ // makes it an ideal candidate to offload to Rust (and parallelize with rayon).
6
+ // The `argon2` crate (RustCrypto) is pure Rust; `SaltString::encode_b64` lets us
7
+ // feed a caller-supplied salt so outputs are deterministic (unit-testable).
8
+ //
9
+ // Pure-Rust core (no napi types) stays unit-testable; only the entry points
10
+ // use napi types.
11
+
12
+ use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
13
+ use argon2::{Algorithm, Argon2, Params, Version};
14
+ use napi::bindgen_prelude::*;
15
+ use napi_derive::napi;
16
+
17
+ // ── Options ────────────────────────────────────────────────────
18
+
19
+ /// argon2id parameters (defaults follow OWASP: 19 MiB, 2 iterations, 1 lane,
20
+ /// 32-byte output). Callers may lower the memory cost for fast benchmarks.
21
+ #[napi(object)]
22
+ pub struct PasswordHashOptions {
23
+ /// Memory cost in KiB (m). Default: 19_456 (19 MiB).
24
+ pub m_cost: Option<u32>,
25
+ /// Iterations (t). Default: 2.
26
+ pub t_cost: Option<u32>,
27
+ /// Parallelism (p). Default: 1.
28
+ pub p_cost: Option<u32>,
29
+ /// Output length in bytes. Default: 32.
30
+ pub out_len: Option<u32>,
31
+ }
32
+
33
+ fn resolve_opts(o: Option<&PasswordHashOptions>) -> (u32, u32, u32, u32) {
34
+ match o {
35
+ Some(o) => (
36
+ o.m_cost.unwrap_or(19_456),
37
+ o.t_cost.unwrap_or(2),
38
+ o.p_cost.unwrap_or(1),
39
+ o.out_len.unwrap_or(32),
40
+ ),
41
+ None => (19_456, 2, 1, 32),
42
+ }
43
+ }
44
+
45
+ // ── Pure-Rust core ─────────────────────────────────────────────
46
+
47
+ /// Hash `password` with argon2id using the given raw salt bytes. Returns the
48
+ /// PHC string (`$argon2id$v=19$m=...,t=...,p=...$<b64 salt>$<b64 hash>`).
49
+ pub fn hash_password(
50
+ password: &[u8],
51
+ salt: &[u8],
52
+ m_cost: u32,
53
+ t_cost: u32,
54
+ p_cost: u32,
55
+ out_len: u32,
56
+ ) -> std::result::Result<String, argon2::password_hash::Error> {
57
+ let params = Params::new(m_cost, t_cost, p_cost, Some(out_len as usize))?;
58
+ let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
59
+ let salt = SaltString::encode_b64(salt)?;
60
+ Ok(argon2.hash_password(password, &salt)?.to_string())
61
+ }
62
+
63
+ /// Verify a password against a PHC string (constant-time internally).
64
+ pub fn verify_password(password: &[u8], phc: &[u8]) -> bool {
65
+ let Ok(phc_str) = std::str::from_utf8(phc) else {
66
+ return false;
67
+ };
68
+ let Ok(parsed) = PasswordHash::new(phc_str) else {
69
+ return false;
70
+ };
71
+ Argon2::default().verify_password(password, &parsed).is_ok()
72
+ }
73
+
74
+ // ── NAPI entry points ──────────────────────────────────────────
75
+
76
+ /// Hash a password with argon2id. `salt` must be at least 8 bytes; output is a
77
+ /// PHC string. Deterministic given the same inputs.
78
+ #[napi]
79
+ pub fn password_hash(
80
+ password: Uint8Array,
81
+ salt: Uint8Array,
82
+ options: Option<PasswordHashOptions>,
83
+ ) -> Result<Buffer> {
84
+ let (m, t, p, out_len) = resolve_opts(options.as_ref());
85
+ let phc = hash_password(password.as_ref(), salt.as_ref(), m, t, p, out_len)
86
+ .map_err(|e| Error::from_reason(format!("argon2 hash failed: {e}")))?;
87
+ Ok(Buffer::from(phc.into_bytes()))
88
+ }
89
+
90
+ /// Verify a password against a PHC string. Returns false on any parse failure.
91
+ #[napi]
92
+ pub fn password_verify(password: Uint8Array, phc: Buffer) -> bool {
93
+ verify_password(password.as_ref(), phc.as_ref())
94
+ }
95
+
96
+ /// Packed password-verify batch (two packed lists: passwords + PHC strings,
97
+ /// zipped) → bitset of valid. Items with malformed PHC strings yield `false`.
98
+ #[napi]
99
+ pub fn password_verify_batch_packed(passwords: Uint8Array, phcs: Uint8Array) -> Result<Buffer> {
100
+ use crate::util::packed::PackedIter;
101
+ let pws = passwords.as_ref();
102
+ let ph = phcs.as_ref();
103
+ let count = PackedIter::new(pws)?.len().min(PackedIter::new(ph)?.len());
104
+ let mut out = Vec::with_capacity(4 + count.div_ceil(8));
105
+ out.extend_from_slice(&(count as u32).to_le_bytes());
106
+ out.resize(4 + count.div_ceil(8), 0);
107
+ for (i, (pw, phc)) in PackedIter::new(pws)?.zip(PackedIter::new(ph)?).enumerate() {
108
+ if verify_password(pw, phc) {
109
+ out[4 + (i >> 3)] |= 1 << (i & 7);
110
+ }
111
+ }
112
+ Ok(Buffer::from(out))
113
+ }
114
+
115
+ /// Higher-order instance: precompiles the argon2id `Params`/`Argon2` once at
116
+ /// construction (fixed m/t/p/out_len), so every `hash` skips the per-call
117
+ /// parameter construction — the CPU/memory cost of the hash itself is
118
+ /// unchanged, only the setup is amortized.
119
+ #[napi]
120
+ pub struct Argon2Hasher {
121
+ argon2: Argon2<'static>,
122
+ }
123
+
124
+ #[napi]
125
+ impl Argon2Hasher {
126
+ #[napi(constructor)]
127
+ pub fn new(options: Option<PasswordHashOptions>) -> Result<Self> {
128
+ let (m, t, p, out_len) = resolve_opts(options.as_ref());
129
+ let params = Params::new(m, t, p, Some(out_len as usize))
130
+ .map_err(|e| Error::from_reason(format!("invalid argon2 params: {e}")))?;
131
+ Ok(Self {
132
+ argon2: Argon2::new(Algorithm::Argon2id, Version::V0x13, params),
133
+ })
134
+ }
135
+
136
+ /// Hash a password with the precompiled params → PHC string bytes.
137
+ #[napi]
138
+ pub fn hash(&self, password: Uint8Array, salt: Uint8Array) -> Result<Buffer> {
139
+ let salt = SaltString::encode_b64(salt.as_ref())
140
+ .map_err(|e| Error::from_reason(format!("invalid salt: {e}")))?;
141
+ let phc = self
142
+ .argon2
143
+ .hash_password(password.as_ref(), &salt)
144
+ .map_err(|e| Error::from_reason(format!("argon2 hash failed: {e}")))?;
145
+ Ok(Buffer::from(phc.to_string().into_bytes()))
146
+ }
147
+
148
+ /// Verify a password against a PHC string. Returns false on any failure.
149
+ #[napi]
150
+ pub fn verify(&self, password: Uint8Array, phc: Buffer) -> bool {
151
+ verify_password(password.as_ref(), phc.as_ref())
152
+ }
153
+ }
154
+
155
+ /// Parallel argon2id batch: packed `[u32 count]{[u32 len][password]}` in →
156
+ /// packed `[u32 count]{[u32 len][phc]}` out, all hashed with the SAME salt.
157
+ #[napi]
158
+ pub fn password_hash_batch_packed(
159
+ data: Uint8Array,
160
+ salt: Uint8Array,
161
+ options: Option<PasswordHashOptions>,
162
+ ) -> Result<Buffer> {
163
+ let (m, t, p, out_len) = resolve_opts(options.as_ref());
164
+
165
+ let phc_for = |password: &[u8]| -> Vec<u8> {
166
+ hash_password(password, salt.as_ref(), m, t, p, out_len)
167
+ .map(|s| s.into_bytes())
168
+ .unwrap_or_default()
169
+ };
170
+
171
+ crate::util::run_packed_batch(data.as_ref(), phc_for).map(Buffer::from)
172
+ }
173
+
174
+ #[cfg(test)]
175
+ mod tests {
176
+ use super::*;
177
+
178
+ const PASSWORD: &[u8] = b"correct horse battery staple";
179
+ const SALT: &[u8] = b"0123456789abcdef";
180
+
181
+ #[test]
182
+ fn hash_then_verify_roundtrip() {
183
+ let phc = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
184
+ assert!(phc.starts_with("$argon2id$v=19$"));
185
+ assert!(verify_password(PASSWORD, phc.as_bytes()));
186
+ }
187
+
188
+ #[test]
189
+ fn argon2_hasher_instance_roundtrips() {
190
+ // Precompiled-params instance: hash/verify with no per-call Params
191
+ // construction. Output must match the scalar core for the same params.
192
+ let hasher = Argon2Hasher::new(Some(PasswordHashOptions {
193
+ m_cost: Some(4096),
194
+ t_cost: Some(2),
195
+ p_cost: Some(1),
196
+ out_len: Some(32),
197
+ }))
198
+ .unwrap();
199
+
200
+ let phc = hasher
201
+ .hash(
202
+ Uint8Array::new(PASSWORD.to_vec()),
203
+ Uint8Array::new(SALT.to_vec()),
204
+ )
205
+ .unwrap();
206
+ let phc_str = String::from_utf8(phc.to_vec()).unwrap();
207
+ assert!(phc_str.starts_with("$argon2id$v=19$"));
208
+
209
+ // The instance output matches the scalar core byte-for-byte.
210
+ let scalar = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
211
+ assert_eq!(phc_str, scalar);
212
+
213
+ // Verify: correct password true, wrong password false.
214
+ assert!(hasher.verify(
215
+ Uint8Array::new(PASSWORD.to_vec()),
216
+ Buffer::from(phc_str.clone().into_bytes())
217
+ ));
218
+ assert!(!hasher.verify(
219
+ Uint8Array::new(b"wrong-password".to_vec()),
220
+ Buffer::from(phc_str.into_bytes())
221
+ ));
222
+
223
+ // Invalid options → construction error.
224
+ assert!(Argon2Hasher::new(Some(PasswordHashOptions {
225
+ m_cost: Some(0),
226
+ t_cost: Some(2),
227
+ p_cost: Some(1),
228
+ out_len: Some(32),
229
+ }))
230
+ .is_err());
231
+ }
232
+
233
+ #[test]
234
+ fn verify_rejects_wrong_password() {
235
+ let phc = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
236
+ assert!(!verify_password(b"wrong password", phc.as_bytes()));
237
+ }
238
+
239
+ #[test]
240
+ fn verify_is_deterministic_given_salt() {
241
+ let a = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
242
+ let b = hash_password(PASSWORD, SALT, 4096, 2, 1, 32).unwrap();
243
+ assert_eq!(a, b);
244
+ }
245
+
246
+ #[test]
247
+ fn different_salt_different_hash() {
248
+ let a = hash_password(PASSWORD, b"aaaaaaaaaaaaaaaa", 4096, 2, 1, 32).unwrap();
249
+ let b = hash_password(PASSWORD, b"bbbbbbbbbbbbbbbb", 4096, 2, 1, 32).unwrap();
250
+ assert_ne!(a, b);
251
+ }
252
+
253
+ #[test]
254
+ fn verify_rejects_garbage() {
255
+ assert!(!verify_password(PASSWORD, b"not-a-phc-string"));
256
+ assert!(!verify_password(PASSWORD, b""));
257
+ }
258
+
259
+ #[test]
260
+ fn params_validation() {
261
+ assert!(hash_password(PASSWORD, SALT, 0, 2, 1, 32).is_err());
262
+ // Salt too short (argon2 requires >= 8 bytes).
263
+ assert!(hash_password(PASSWORD, b"123", 4096, 2, 1, 32).is_err());
264
+ }
265
+ }
@@ -0,0 +1,420 @@
1
+ // rust/crypto/base64.rs — base64 (standard/url-safe) + hex encode/decode.
2
+ //
3
+ // Uses the `base64` crate engines (Cargo dep already present). The
4
+ // `Base64Codec` higher-order instance precompiles the alphabet/decoding
5
+ // configuration once in its constructor and reuses it across calls.
6
+
7
+ use napi::bindgen_prelude::*;
8
+ use napi_derive::napi;
9
+
10
+ use crate::util::bytes::{hex_val, HEX_LOWER};
11
+ use base64::Engine as _;
12
+
13
+ fn engine(url_safe: bool, padding: bool) -> base64::engine::general_purpose::GeneralPurpose {
14
+ use base64::engine::general_purpose::*;
15
+ match (url_safe, padding) {
16
+ (false, true) => STANDARD,
17
+ (false, false) => STANDARD_NO_PAD,
18
+ (true, true) => URL_SAFE,
19
+ (true, false) => URL_SAFE_NO_PAD,
20
+ }
21
+ }
22
+
23
+ #[napi]
24
+ pub fn base64_encode(input: Uint8Array, url_safe: Option<bool>, padding: Option<bool>) -> Buffer {
25
+ let out = engine(url_safe.unwrap_or(false), padding.unwrap_or(true)).encode(input.as_ref());
26
+ Buffer::from(out.into_bytes())
27
+ }
28
+
29
+ #[napi]
30
+ pub fn base64_decode(
31
+ input: Uint8Array,
32
+ url_safe: Option<bool>,
33
+ padding: Option<bool>,
34
+ ) -> Result<Buffer> {
35
+ engine(url_safe.unwrap_or(false), padding.unwrap_or(true))
36
+ .decode(input.as_ref())
37
+ .map(Buffer::from)
38
+ .map_err(|e| Error::from_reason(e.to_string()))
39
+ }
40
+
41
+ #[napi]
42
+ pub fn base64url_encode(input: Uint8Array) -> Buffer {
43
+ Buffer::from(
44
+ base64::engine::general_purpose::URL_SAFE_NO_PAD
45
+ .encode(input.as_ref())
46
+ .into_bytes(),
47
+ )
48
+ }
49
+
50
+ #[napi]
51
+ pub fn base64url_decode(input: Uint8Array) -> Result<Buffer> {
52
+ base64::engine::general_purpose::URL_SAFE_NO_PAD
53
+ .decode(input.as_ref())
54
+ .map(Buffer::from)
55
+ .map_err(|e| Error::from_reason(e.to_string()))
56
+ }
57
+
58
+ /// Base64-encode to raw bytes (no Buffer wrapping). Shared by the scalar and
59
+ /// packed-batch paths.
60
+ pub fn base64_encode_bytes(input: &[u8], url_safe: bool, padding: bool) -> Vec<u8> {
61
+ engine(url_safe, padding).encode(input).into_bytes()
62
+ }
63
+
64
+ /// Base64-decode from raw bytes. Errors on invalid input. Shared by the scalar
65
+ /// and packed-batch paths.
66
+ pub fn base64_decode_bytes(input: &[u8], url_safe: bool, padding: bool) -> Result<Vec<u8>> {
67
+ engine(url_safe, padding)
68
+ .decode(input)
69
+ .map_err(|e| Error::from_reason(e.to_string()))
70
+ }
71
+
72
+ /// Base64url (RFC 7515 §2 — URL-safe alphabet, NO padding) encode to raw
73
+ /// bytes. The single shared source of truth for JWT segment encoding
74
+ /// (`crate::crypto::jwt`) and Ed25519 keypair serialization
75
+ /// (`crate::crypto::ed25519`) — keeps the url-safe alphabet defined in exactly
76
+ /// one place.
77
+ #[inline]
78
+ pub fn base64url_encode_bytes(data: &[u8]) -> Vec<u8> {
79
+ base64::engine::general_purpose::URL_SAFE_NO_PAD
80
+ .encode(data)
81
+ .into_bytes()
82
+ }
83
+
84
+ /// Base64url (RFC 7515 §2 — URL-safe alphabet, NO padding) decode from raw
85
+ /// bytes. Returns `None` on invalid UTF-8 or non-base64url input.
86
+ #[inline]
87
+ pub fn base64url_decode_bytes(data: &[u8]) -> Option<Vec<u8>> {
88
+ let text = std::str::from_utf8(data).ok()?;
89
+ base64::engine::general_purpose::URL_SAFE_NO_PAD
90
+ .decode(text)
91
+ .ok()
92
+ }
93
+
94
+ // ── hex ──────────────────────────────────────────────────────────
95
+
96
+ /// Encode bytes as lowercase hex (uses the shared `HEX_LOWER` table — direct
97
+ /// byte writes, no per-nibble `char` push).
98
+ ///
99
+ /// # Safety
100
+ ///
101
+ /// Hex output is always ASCII (`HEX_LOWER` is a byte string of `0-9a-f`), so
102
+ /// the built `Vec<u8>` is valid UTF-8 by construction; `from_utf8_unchecked`
103
+ /// skips a redundant validation pass on the allocating hot path.
104
+ pub fn hex_encode_bytes(input: &[u8]) -> String {
105
+ let mut out = Vec::with_capacity(input.len() * 2);
106
+ for &b in input {
107
+ out.push(HEX_LOWER[(b >> 4) as usize]);
108
+ out.push(HEX_LOWER[(b & 0x0f) as usize]);
109
+ }
110
+ // SAFETY: every byte pushed is an ASCII hex digit from `HEX_LOWER`.
111
+ unsafe { String::from_utf8_unchecked(out) }
112
+ }
113
+
114
+ /// Decode lowercase/uppercase hex to bytes.
115
+ pub fn hex_decode_bytes(input: &[u8]) -> std::result::Result<Vec<u8>, &'static str> {
116
+ if !input.len().is_multiple_of(2) {
117
+ return Err("odd hex length");
118
+ }
119
+ let mut out = Vec::with_capacity(input.len() / 2);
120
+ for i in (0..input.len()).step_by(2) {
121
+ let hi = hex_val(input[i]).ok_or("invalid hex digit")?;
122
+ let lo = hex_val(input[i + 1]).ok_or("invalid hex digit")?;
123
+ out.push((hi << 4) | lo);
124
+ }
125
+ Ok(out)
126
+ }
127
+
128
+ #[napi]
129
+ pub fn hex_encode(input: Uint8Array) -> Buffer {
130
+ Buffer::from(hex_encode_bytes(input.as_ref()).into_bytes())
131
+ }
132
+
133
+ #[napi]
134
+ pub fn hex_decode(input: Uint8Array) -> Result<Buffer> {
135
+ hex_decode_bytes(input.as_ref())
136
+ .map(Buffer::from)
137
+ .map_err(|e| Error::from_reason(e.to_string()))
138
+ }
139
+
140
+ // ── Reusable-output (_into) variants ────────────────────────────
141
+ //
142
+ // These write into a caller-provided output buffer and return the number of
143
+ // bytes written (u32), so hot loops can pool the buffer instead of allocating
144
+ // a fresh Vec + napi Buffer per call. They error on a buffer that is too small;
145
+ // input/output overlap is handled by `crate::util::run_packed_into`.
146
+
147
+ /// Lowercase-hex encode directly into `out`. Returns bytes written
148
+ /// (`input.len() * 2`). Errors if `out` is too small.
149
+ #[inline]
150
+ pub fn hex_encode_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
151
+ let needed = input
152
+ .len()
153
+ .checked_mul(2)
154
+ .ok_or_else(|| Error::from_reason("hex encode: input too large (length overflow)"))?;
155
+ if out.len() < needed {
156
+ return Err(Error::from_reason("hex encode: output buffer too small"));
157
+ }
158
+ let mut pos = 0usize;
159
+ for &b in input {
160
+ out[pos] = HEX_LOWER[(b >> 4) as usize];
161
+ out[pos + 1] = HEX_LOWER[(b & 0x0f) as usize];
162
+ pos += 2;
163
+ }
164
+ Ok(pos)
165
+ }
166
+
167
+ #[napi]
168
+ pub fn hex_encode_into(input: Uint8Array, mut output: Uint8Array) -> Result<u32> {
169
+ crate::util::run_packed_into(&input, &mut output, hex_encode_into_slice)
170
+ }
171
+
172
+ /// Hex-decode directly into `out`. Returns bytes written (`input.len() / 2`).
173
+ /// Errors on odd length, invalid digits, or an output buffer that is too small.
174
+ #[inline]
175
+ pub fn hex_decode_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
176
+ if !input.len().is_multiple_of(2) {
177
+ return Err(Error::from_reason("odd hex length"));
178
+ }
179
+ let needed = input.len() / 2;
180
+ if out.len() < needed {
181
+ return Err(Error::from_reason("hex decode: output buffer too small"));
182
+ }
183
+ let mut pos = 0usize;
184
+ for i in (0..input.len()).step_by(2) {
185
+ let hi = hex_val(input[i]).ok_or_else(|| Error::from_reason("invalid hex digit"))?;
186
+ let lo = hex_val(input[i + 1]).ok_or_else(|| Error::from_reason("invalid hex digit"))?;
187
+ out[pos] = (hi << 4) | lo;
188
+ pos += 1;
189
+ }
190
+ Ok(pos)
191
+ }
192
+
193
+ #[napi]
194
+ pub fn hex_decode_into(input: Uint8Array, mut output: Uint8Array) -> Result<u32> {
195
+ crate::util::run_packed_into(&input, &mut output, hex_decode_into_slice)
196
+ }
197
+
198
+ /// Pure core: base64-encode `input` into `out` (zero-alloc `encode_slice`).
199
+ /// Returns bytes written; errors if `out` is too small. Shared by the `_into`
200
+ /// napi path and the pure-core unit tests.
201
+ pub fn base64_encode_into_slice(
202
+ input: &[u8],
203
+ out: &mut [u8],
204
+ url_safe: bool,
205
+ padding: bool,
206
+ ) -> Result<usize> {
207
+ engine(url_safe, padding)
208
+ .encode_slice(input, out)
209
+ .map_err(|e| Error::from_reason(format!("base64 encode: {e}")))
210
+ }
211
+
212
+ /// Base64-encode into a caller-provided output buffer (zero-alloc via
213
+ /// `Engine::encode_slice`). Returns bytes written; errors if `output` is too
214
+ /// small.
215
+ #[napi]
216
+ pub fn base64_encode_into(
217
+ input: Uint8Array,
218
+ mut output: Uint8Array,
219
+ url_safe: Option<bool>,
220
+ padding: Option<bool>,
221
+ ) -> Result<u32> {
222
+ crate::util::run_packed_into(&input, &mut output, move |inp, out| {
223
+ base64_encode_into_slice(inp, out, url_safe.unwrap_or(false), padding.unwrap_or(true))
224
+ })
225
+ }
226
+
227
+ /// Pure core: base64-decode `input` into `out` (zero-alloc `decode_slice`).
228
+ /// Returns bytes written; errors on invalid input or a too-small output
229
+ /// buffer. Shared by the `_into` napi path and the pure-core unit tests.
230
+ pub fn base64_decode_into_slice(
231
+ input: &[u8],
232
+ out: &mut [u8],
233
+ url_safe: bool,
234
+ padding: bool,
235
+ ) -> Result<usize> {
236
+ engine(url_safe, padding)
237
+ .decode_slice(input, out)
238
+ .map_err(|e| Error::from_reason(format!("base64 decode: {e}")))
239
+ }
240
+
241
+ /// Base64-decode into a caller-provided output buffer (zero-alloc via
242
+ /// `Engine::decode_slice`). Returns bytes written; errors on invalid input or
243
+ /// an output buffer that is too small.
244
+ #[napi]
245
+ pub fn base64_decode_into(
246
+ input: Uint8Array,
247
+ mut output: Uint8Array,
248
+ url_safe: Option<bool>,
249
+ padding: Option<bool>,
250
+ ) -> Result<u32> {
251
+ crate::util::run_packed_into(&input, &mut output, move |inp, out| {
252
+ base64_decode_into_slice(inp, out, url_safe.unwrap_or(false), padding.unwrap_or(true))
253
+ })
254
+ }
255
+
256
+ /// Higher-order instance: the alphabet/padding engine is selected ONCE at
257
+ /// construction and stored concretely, so `encode`/`decode` never re-run the
258
+ /// 4-way engine match on the per-call path.
259
+ #[napi]
260
+ pub struct Base64Codec {
261
+ engine: base64::engine::general_purpose::GeneralPurpose,
262
+ }
263
+
264
+ #[napi]
265
+ impl Base64Codec {
266
+ #[napi(constructor)]
267
+ pub fn new(url_safe: Option<bool>, padding: Option<bool>) -> Self {
268
+ Self {
269
+ engine: engine(url_safe.unwrap_or(false), padding.unwrap_or(true)),
270
+ }
271
+ }
272
+
273
+ #[napi]
274
+ pub fn encode(&self, input: Uint8Array) -> Buffer {
275
+ Buffer::from(self.engine.encode(input.as_ref()).into_bytes())
276
+ }
277
+
278
+ #[napi]
279
+ pub fn decode(&self, input: Uint8Array) -> Result<Buffer> {
280
+ self.engine
281
+ .decode(input.as_ref())
282
+ .map(Buffer::from)
283
+ .map_err(|e| Error::from_reason(e.to_string()))
284
+ }
285
+ }
286
+
287
+ #[cfg(test)]
288
+ mod tests {
289
+ use super::*;
290
+
291
+ #[test]
292
+ fn base64_roundtrip_standard() {
293
+ let data = b"hello world!";
294
+ let enc = base64::engine::general_purpose::STANDARD.encode(data);
295
+ assert_eq!(
296
+ base64_decode(Uint8Array::new(enc.into_bytes()), None, None)
297
+ .unwrap()
298
+ .as_ref(),
299
+ data
300
+ );
301
+ }
302
+
303
+ #[test]
304
+ fn base64url_no_padding() {
305
+ // 0xfb → 111110 11 → "-w" (url-safe, no padding)
306
+ let enc = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xfb]);
307
+ assert_eq!(enc, "-w");
308
+ let dec = base64url_decode(Uint8Array::new(enc.into_bytes())).unwrap();
309
+ assert_eq!(dec.as_ref(), [0xfb]);
310
+ }
311
+
312
+ #[test]
313
+ fn base64_decode_rejects_invalid() {
314
+ assert!(base64_decode(Uint8Array::new(b"!!!".to_vec()), None, None).is_err());
315
+ }
316
+
317
+ #[test]
318
+ fn hex_roundtrip() {
319
+ let data = b"\x00\x01\xfe\xffAB";
320
+ let enc = hex_encode_bytes(data);
321
+ assert_eq!(enc, "0001feff4142");
322
+ assert_eq!(hex_decode_bytes(enc.as_bytes()).unwrap(), data);
323
+ }
324
+
325
+ #[test]
326
+ fn hex_rejects_odd_and_bad() {
327
+ assert!(hex_decode_bytes(b"abc").is_err());
328
+ assert!(hex_decode_bytes(b"zz").is_err());
329
+ }
330
+
331
+ #[test]
332
+ fn codec_instance_encodes_urlsafe() {
333
+ let c = Base64Codec::new(Some(true), Some(false));
334
+ let enc = c.encode(Uint8Array::new(b"\xfb".to_vec()));
335
+ assert_eq!(enc.as_ref(), b"-w");
336
+ }
337
+
338
+ // ── reusable-output (_into) variants ──
339
+
340
+ #[test]
341
+ fn hex_encode_into_slice_matches_allocating() {
342
+ let data = b"\x00\x01\xfe\xffAB";
343
+ let expected = hex_encode_bytes(data);
344
+ let mut out = [0u8; 32];
345
+ let n = hex_encode_into_slice(data, &mut out).unwrap();
346
+ assert_eq!(n, expected.len());
347
+ assert_eq!(&out[..n], expected.as_bytes());
348
+ }
349
+
350
+ #[test]
351
+ fn hex_encode_into_reports_length() {
352
+ let data = b"hello";
353
+ let out = Uint8Array::new(vec![0u8; 16]);
354
+ let n = hex_encode_into(Uint8Array::new(data.to_vec()), out).unwrap();
355
+ assert_eq!(n as usize, data.len() * 2);
356
+ }
357
+
358
+ #[test]
359
+ fn hex_encode_into_small_buffer_errors() {
360
+ let mut out = [0u8; 2];
361
+ assert!(hex_encode_into_slice(b"abc", &mut out).is_err());
362
+ let out2 = Uint8Array::new(vec![0u8; 4]);
363
+ assert!(hex_encode_into(Uint8Array::new(b"abcdef".to_vec()), out2).is_err());
364
+ }
365
+
366
+ #[test]
367
+ fn hex_decode_into_roundtrips() {
368
+ let enc = hex_encode_bytes(b"\x00\x01\xfe\xffAB");
369
+ let mut out = [0u8; 16];
370
+ let n = hex_decode_into_slice(enc.as_bytes(), &mut out).unwrap();
371
+ assert_eq!(&out[..n], b"\x00\x01\xfe\xffAB");
372
+ }
373
+
374
+ #[test]
375
+ fn hex_decode_into_rejects_bad_input() {
376
+ let mut out = [0u8; 4];
377
+ assert!(hex_decode_into_slice(b"abc", &mut out).is_err());
378
+ assert!(hex_decode_into_slice(b"zz", &mut out).is_err());
379
+ }
380
+
381
+ #[test]
382
+ fn hex_decode_into_small_buffer_errors() {
383
+ let mut out = [0u8; 2];
384
+ assert!(hex_decode_into_slice(b"0001020304", &mut out).is_err());
385
+ }
386
+
387
+ #[test]
388
+ fn base64_encode_into_reports_length() {
389
+ let data = b"hello world!";
390
+ let out = Uint8Array::new(vec![0u8; 64]);
391
+ let n = base64_encode_into(Uint8Array::new(data.to_vec()), out, None, None).unwrap();
392
+ assert_eq!(
393
+ n as usize,
394
+ base64::engine::general_purpose::STANDARD.encode(data).len()
395
+ );
396
+ }
397
+
398
+ #[test]
399
+ fn base64_encode_into_small_buffer_errors() {
400
+ let out = Uint8Array::new(vec![0u8; 4]);
401
+ assert!(
402
+ base64_encode_into(Uint8Array::new(b"hello world!".to_vec()), out, None, None).is_err()
403
+ );
404
+ }
405
+
406
+ #[test]
407
+ fn base64_decode_into_roundtrips() {
408
+ let data = b"hello world!";
409
+ let enc = base64::engine::general_purpose::STANDARD.encode(data);
410
+ let out = Uint8Array::new(vec![0u8; 32]);
411
+ let n = base64_decode_into(Uint8Array::new(enc.into_bytes()), out, None, None).unwrap();
412
+ assert_eq!(n as usize, data.len());
413
+ }
414
+
415
+ #[test]
416
+ fn base64_decode_into_rejects_invalid() {
417
+ let out = Uint8Array::new(vec![0u8; 32]);
418
+ assert!(base64_decode_into(Uint8Array::new(b"!!!".to_vec()), out, None, None).is_err());
419
+ }
420
+ }