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,267 @@
1
+ // rust/http/headers.rs — HeaderRefs: zero-alloc packed header parser
2
+ // Uses the shared word-at-a-time comparator from `bytes.rs`.
3
+ //
4
+ // Pure core (no napi types): errors are plain `String` messages; the napi
5
+ // boundary maps them to JS errors.
6
+
7
+ use crate::util::bytes::ascii_eq_ignore_case;
8
+
9
+ // ── Header presence flags ─────────────────────────────────────────
10
+ const HAS_ORIGIN: u8 = 1 << 0;
11
+ const HAS_XFF: u8 = 1 << 1;
12
+ const HAS_X_REAL_IP: u8 = 1 << 2;
13
+ const HAS_XFP: u8 = 1 << 3;
14
+ const HAS_ACRM: u8 = 1 << 4;
15
+
16
+ /// Zero-alloc reference-based representation of interesting headers.
17
+ #[derive(Clone, Copy)]
18
+ pub struct HeaderRefs<'a> {
19
+ origin: Option<&'a [u8]>,
20
+ cookie: Option<&'a [u8]>,
21
+ xff: Option<&'a [u8]>,
22
+ x_real_ip: Option<&'a [u8]>,
23
+ x_forwarded_proto: Option<&'a [u8]>,
24
+ acrm: Option<&'a [u8]>,
25
+ acrh: Option<&'a [u8]>,
26
+ flags: u8,
27
+ }
28
+
29
+ impl<'a> HeaderRefs<'a> {
30
+ #[inline(always)]
31
+ pub fn empty() -> Self {
32
+ Self {
33
+ origin: None,
34
+ cookie: None,
35
+ xff: None,
36
+ x_real_ip: None,
37
+ x_forwarded_proto: None,
38
+ acrm: None,
39
+ acrh: None,
40
+ flags: 0,
41
+ }
42
+ }
43
+
44
+ /// Parse packed headers from the input buffer.
45
+ /// Format: [u16 count] repeated { [u16 name_len] [name] [u32 value_len] [value] }
46
+ #[inline]
47
+ pub fn parse(
48
+ packed: &'a [u8],
49
+ is_options: bool,
50
+ max_headers: usize,
51
+ ) -> std::result::Result<Self, String> {
52
+ let mut h = Self::empty();
53
+
54
+ if packed.len() < 2 {
55
+ return Ok(h);
56
+ }
57
+
58
+ let count = u16::from_le_bytes([packed[0], packed[1]]) as usize;
59
+ if count > max_headers {
60
+ return Err("too many headers".to_string());
61
+ }
62
+
63
+ let mut pos = 2usize;
64
+
65
+ for _ in 0..count {
66
+ if pos + 2 > packed.len() {
67
+ return Err("malformed packed headers".to_string());
68
+ }
69
+
70
+ let name_len = u16::from_le_bytes([packed[pos], packed[pos + 1]]) as usize;
71
+ pos += 2;
72
+
73
+ if pos + name_len > packed.len() {
74
+ return Err("malformed packed header name".to_string());
75
+ }
76
+
77
+ let name = &packed[pos..pos + name_len];
78
+ pos += name_len;
79
+
80
+ if pos + 4 > packed.len() {
81
+ return Err("malformed packed header value length".to_string());
82
+ }
83
+
84
+ let value_len = u32::from_le_bytes([
85
+ packed[pos],
86
+ packed[pos + 1],
87
+ packed[pos + 2],
88
+ packed[pos + 3],
89
+ ]) as usize;
90
+ pos += 4;
91
+
92
+ if pos + value_len > packed.len() {
93
+ return Err("malformed packed header value".to_string());
94
+ }
95
+
96
+ let value = &packed[pos..pos + value_len];
97
+ pos += value_len;
98
+
99
+ if name.is_empty() {
100
+ continue;
101
+ }
102
+
103
+ let first = name[0] | 0x20;
104
+
105
+ // Fast path: dispatch based on first character + name length
106
+ if first == b'o' && name.len() == 6 {
107
+ if ascii_eq_ignore_case(name, b"origin") {
108
+ h.flags |= HAS_ORIGIN;
109
+ h.origin = Some(value);
110
+ }
111
+ } else if first == b'c' && name.len() == 6 {
112
+ if ascii_eq_ignore_case(name, b"cookie") {
113
+ h.cookie = Some(value);
114
+ }
115
+ } else if first == b'x' {
116
+ let name_len = name.len();
117
+ if name_len == 15 && ascii_eq_ignore_case(name, b"x-forwarded-for") {
118
+ h.flags |= HAS_XFF;
119
+ h.xff = Some(value);
120
+ } else if name_len == 9 && ascii_eq_ignore_case(name, b"x-real-ip") {
121
+ h.flags |= HAS_X_REAL_IP;
122
+ h.x_real_ip = Some(value);
123
+ } else if name_len == 17 && ascii_eq_ignore_case(name, b"x-forwarded-proto") {
124
+ h.flags |= HAS_XFP;
125
+ h.x_forwarded_proto = Some(value);
126
+ }
127
+ } else if first == b'a' && is_options {
128
+ if name.len() == 29 && ascii_eq_ignore_case(name, b"access-control-request-method")
129
+ {
130
+ h.flags |= HAS_ACRM;
131
+ h.acrm = Some(value);
132
+ } else if name.len() == 30
133
+ && ascii_eq_ignore_case(name, b"access-control-request-headers")
134
+ {
135
+ h.acrh = Some(value);
136
+ }
137
+ }
138
+ }
139
+
140
+ Ok(h)
141
+ }
142
+
143
+ #[inline(always)]
144
+ pub fn origin(&self) -> Option<&[u8]> {
145
+ self.origin
146
+ }
147
+
148
+ #[inline(always)]
149
+ pub fn cookie(&self) -> Option<&[u8]> {
150
+ self.cookie
151
+ }
152
+
153
+ #[inline(always)]
154
+ pub fn xff(&self) -> Option<&[u8]> {
155
+ self.xff
156
+ }
157
+
158
+ #[inline(always)]
159
+ pub fn x_real_ip(&self) -> Option<&[u8]> {
160
+ self.x_real_ip
161
+ }
162
+
163
+ #[inline(always)]
164
+ pub fn x_forwarded_proto(&self) -> Option<&[u8]> {
165
+ self.x_forwarded_proto
166
+ }
167
+
168
+ #[inline(always)]
169
+ pub fn acrm(&self) -> Option<&[u8]> {
170
+ self.acrm
171
+ }
172
+
173
+ #[inline(always)]
174
+ pub fn acrh(&self) -> Option<&[u8]> {
175
+ self.acrh
176
+ }
177
+
178
+ #[inline(always)]
179
+ pub fn has_origin(&self) -> bool {
180
+ (self.flags & HAS_ORIGIN) != 0
181
+ }
182
+
183
+ #[inline(always)]
184
+ pub fn has_xfp(&self) -> bool {
185
+ (self.flags & HAS_XFP) != 0
186
+ }
187
+
188
+ #[inline(always)]
189
+ pub fn has_acrm(&self) -> bool {
190
+ (self.flags & HAS_ACRM) != 0
191
+ }
192
+ }
193
+
194
+ #[cfg(test)]
195
+ mod tests {
196
+ use super::HeaderRefs;
197
+ use crate::test_support::pack_headers;
198
+
199
+ #[test]
200
+ fn headers_parse_empty_ok() {
201
+ let h = HeaderRefs::parse(b"", false, 100).unwrap();
202
+ assert!(h.origin().is_none());
203
+ assert!(h.cookie().is_none());
204
+ }
205
+
206
+ #[test]
207
+ fn headers_parse_presence_and_values() {
208
+ let packed = pack_headers([
209
+ ("Origin", "https://example.com"),
210
+ ("Cookie", "session=abc"),
211
+ ("X-Forwarded-For", "1.2.3.4"),
212
+ ]);
213
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
214
+ assert_eq!(h.origin(), Some(&b"https://example.com"[..]));
215
+ assert_eq!(h.cookie(), Some(&b"session=abc"[..]));
216
+ assert_eq!(h.xff(), Some(&b"1.2.3.4"[..]));
217
+ }
218
+
219
+ #[test]
220
+ fn headers_parse_case_insensitive_names() {
221
+ let packed = pack_headers([("ORIGIN", "https://x.io")]);
222
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
223
+ assert_eq!(h.origin(), Some(&b"https://x.io"[..]));
224
+ }
225
+
226
+ #[test]
227
+ fn headers_parse_acrm_only_for_options() {
228
+ let packed = pack_headers([
229
+ ("access-control-request-method", "POST"),
230
+ ("access-control-request-headers", "Content-Type"),
231
+ ]);
232
+ // Non-OPTIONS: ACRM/ACRH should not be captured.
233
+ let h = HeaderRefs::parse(&packed, false, 100).unwrap();
234
+ assert!(!h.has_acrm());
235
+ // OPTIONS: they should be captured.
236
+ let h2 = HeaderRefs::parse(&packed, true, 100).unwrap();
237
+ assert!(h2.has_acrm());
238
+ // ACRH value is captured on OPTIONS (no dedicated flag/method).
239
+ assert_eq!(h2.acrh(), Some(&b"Content-Type"[..]));
240
+ }
241
+
242
+ #[test]
243
+ fn headers_parse_too_many_headers_rejected() {
244
+ let mut pairs: Vec<(String, String)> = Vec::new();
245
+ for i in 0..10 {
246
+ pairs.push(("x".to_string(), format!("v{i}")));
247
+ }
248
+
249
+ let mut packed = Vec::new();
250
+ packed.extend_from_slice(&(pairs.len() as u16).to_le_bytes());
251
+ for (name, value) in &pairs {
252
+ packed.extend_from_slice(&(name.len() as u16).to_le_bytes());
253
+ packed.extend_from_slice(name.as_bytes());
254
+ packed.extend_from_slice(&(value.len() as u32).to_le_bytes());
255
+ packed.extend_from_slice(value.as_bytes());
256
+ }
257
+
258
+ assert!(HeaderRefs::parse(&packed, false, 5).is_err());
259
+ }
260
+
261
+ #[test]
262
+ fn headers_parse_malformed_rejected() {
263
+ // Declares a name_len that runs past the end of the buffer.
264
+ let packed = [2u8, 0, 0xFF, 0xFF, b'x'];
265
+ assert!(HeaderRefs::parse(&packed, false, 100).is_err());
266
+ }
267
+ }
@@ -0,0 +1,286 @@
1
+ // rust/http/http_date.rs — HTTP-date (IMF-fixdate) formatting + parsing.
2
+ //
3
+ // `Sun, 06 Nov 1994 08:49:37 GMT` → unix seconds and back, with a zero-alloc
4
+ // fixed-width writer for hot loops. The date arithmetic uses Howard Hinnant's
5
+ // days↔civil algorithms (epoch-correct). Extracted from `etag.rs` so the
6
+ // HTTP cache semantics file owns only ETags + conditional requests.
7
+
8
+ use napi::bindgen_prelude::*;
9
+ use napi_derive::napi;
10
+
11
+ const DAY_NAMES: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
12
+ const MONTH_NAMES: [&str; 12] = [
13
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
14
+ ];
15
+
16
+ /// Days since epoch → civil (year, month, day) — Hinnant's algorithm.
17
+ fn civil_from_days(z: i64) -> (i64, u32, u32) {
18
+ let z = z + 719_468;
19
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
20
+ let doe = (z - era * 146_097) as u64;
21
+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
22
+ let y = yoe as i64 + era * 400;
23
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
24
+ let mp = (5 * doy + 2) / 153;
25
+ let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
26
+ let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
27
+ let y = if m <= 2 { y + 1 } else { y };
28
+ (y, m, d)
29
+ }
30
+
31
+ /// Civil date → days since epoch — Hinnant's inverse algorithm.
32
+ fn days_from_civil(y: i64, m: u32, d: u32) -> Option<i64> {
33
+ if !(1..=12).contains(&m) || d == 0 || d > 31 {
34
+ return None;
35
+ }
36
+ let y = if m <= 2 { y - 1 } else { y };
37
+ let era = if y >= 0 { y } else { y - 399 } / 400;
38
+ let yoe = (y - era * 400) as u64;
39
+ let mp = ((m as i64 + 9) % 12) as u64;
40
+ let doy = (153 * mp + 2) / 5 + (d - 1) as u64;
41
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
42
+ Some(era * 146_097 + doe as i64 - 719_468)
43
+ }
44
+
45
+ /// Format `value` as a zero-padded decimal of exactly `width` digits into
46
+ /// `out`. `value` must fit in `width` digits.
47
+ #[inline]
48
+ fn write_padded(out: &mut [u8], mut value: u32, width: usize) {
49
+ for i in (0..width).rev() {
50
+ out[i] = b'0' + (value % 10) as u8;
51
+ value /= 10;
52
+ }
53
+ }
54
+
55
+ /// Parsed http-date components (computed once, shared by the allocating and
56
+ /// pooled-output formatters).
57
+ struct HttpDateParts {
58
+ y: i64,
59
+ m: u32,
60
+ d: u32,
61
+ hh: i64,
62
+ mm: i64,
63
+ ss: i64,
64
+ wd: usize,
65
+ }
66
+
67
+ fn http_date_parts(secs: i64) -> HttpDateParts {
68
+ let days = secs.div_euclid(86_400);
69
+ let rem = secs.rem_euclid(86_400);
70
+ let hh = rem / 3600;
71
+ let mm = (rem % 3600) / 60;
72
+ let ss = rem % 60;
73
+ let (y, m, d) = civil_from_days(days);
74
+ // 1970-01-01 was a Thursday; weekday Mon=0 → (days+3) mod 7.
75
+ let wd = ((days.rem_euclid(7) + 3) % 7) as usize;
76
+ HttpDateParts {
77
+ y,
78
+ m,
79
+ d,
80
+ hh,
81
+ mm,
82
+ ss,
83
+ wd,
84
+ }
85
+ }
86
+
87
+ /// Write the fixed-width `ddd, dd mmm yyyy hh:mm:ss GMT` layout into `out`
88
+ /// (29 bytes, no heap allocation). `Err` only for years outside 0..=9999
89
+ /// (where the fixed-width layout is undefined) or a too-small buffer.
90
+ fn write_http_date_parts(
91
+ p: &HttpDateParts,
92
+ out: &mut [u8],
93
+ ) -> std::result::Result<usize, &'static str> {
94
+ // 3 + 2 (", ") + 2 + 1 (" ") + 3 + 1 (" ") + 4 + 1 (" ") + 2 + 1 (":") + 2
95
+ // + 1 (":") + 2 + 1 (" ") + 3 = 29
96
+ if out.len() < 29 {
97
+ return Err("http-date: output buffer too small");
98
+ }
99
+ if !(0..=9999).contains(&p.y) {
100
+ return Err("http-date: year out of range for fixed-width layout");
101
+ }
102
+ let mut i = 0usize;
103
+ out[i..i + 3].copy_from_slice(DAY_NAMES[p.wd].as_bytes());
104
+ i += 3;
105
+ out[i] = b',';
106
+ i += 1;
107
+ out[i] = b' ';
108
+ i += 1;
109
+ write_padded(&mut out[i..i + 2], p.d, 2);
110
+ i += 2;
111
+ out[i] = b' ';
112
+ i += 1;
113
+ out[i..i + 3].copy_from_slice(MONTH_NAMES[(p.m - 1) as usize].as_bytes());
114
+ i += 3;
115
+ out[i] = b' ';
116
+ i += 1;
117
+ write_padded(&mut out[i..i + 4], p.y as u32, 4);
118
+ i += 4;
119
+ out[i] = b' ';
120
+ i += 1;
121
+ write_padded(&mut out[i..i + 2], p.hh as u32, 2);
122
+ i += 2;
123
+ out[i] = b':';
124
+ i += 1;
125
+ write_padded(&mut out[i..i + 2], p.mm as u32, 2);
126
+ i += 2;
127
+ out[i] = b':';
128
+ i += 1;
129
+ write_padded(&mut out[i..i + 2], p.ss as u32, 2);
130
+ i += 2;
131
+ out[i] = b' ';
132
+ i += 1;
133
+ out[i..i + 3].copy_from_slice(b"GMT");
134
+ i += 3;
135
+ Ok(i)
136
+ }
137
+
138
+ /// Format a unix timestamp (seconds) as `Sun, 06 Nov 1994 08:49:37 GMT`.
139
+ /// Written into a fixed 32-byte stack buffer — no `format!`/heap allocation.
140
+ pub fn http_date_from_secs(secs: i64) -> String {
141
+ let p = http_date_parts(secs);
142
+ let mut buf = [0u8; 32];
143
+ match write_http_date_parts(&p, &mut buf) {
144
+ Ok(n) => String::from_utf8(buf[..n].to_vec()).expect("http-date is ASCII"),
145
+ Err(_) => {
146
+ // Year outside 0..=9999: HTTP-date is only well-defined for
147
+ // 0000-9999, but keep the exact `format!` fallback so behavior
148
+ // never changes from before the refactor.
149
+ format!(
150
+ "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
151
+ DAY_NAMES[p.wd],
152
+ p.d,
153
+ MONTH_NAMES[(p.m - 1) as usize],
154
+ p.y,
155
+ p.hh,
156
+ p.mm,
157
+ p.ss
158
+ )
159
+ }
160
+ }
161
+ }
162
+
163
+ /// Write an http-date directly into `out` (fixed 29 bytes, no heap alloc).
164
+ /// Returns bytes written; errors on a too-small buffer or a year outside
165
+ /// 0..=9999 (use the allocating `http_date` for that fallback).
166
+ pub fn http_date_into_slice(secs: i64, out: &mut [u8]) -> Result<usize> {
167
+ let p = http_date_parts(secs);
168
+ write_http_date_parts(&p, out).map_err(|e| Error::from_reason(e.to_string()))
169
+ }
170
+
171
+ /// Parse `Sun, 06 Nov 1994 08:49:37 GMT` back to unix seconds.
172
+ pub fn parse_http_date_secs(input: &[u8]) -> Option<i64> {
173
+ let s = std::str::from_utf8(input).ok()?.trim();
174
+ let comma = s.find(',')?;
175
+ let rest = s[comma + 1..].trim();
176
+ let mut parts = rest.split_whitespace();
177
+ let day: u32 = parts.next()?.parse().ok()?;
178
+ let mon = parts.next()?;
179
+ let year: i64 = parts.next()?.parse().ok()?;
180
+ let time = parts.next()?;
181
+ let month = MONTH_NAMES
182
+ .iter()
183
+ .position(|m| m.eq_ignore_ascii_case(mon))?
184
+ + 1;
185
+ let mut tp = time.split(':');
186
+ let hh: u32 = tp.next()?.parse().ok()?;
187
+ let mm: u32 = tp.next()?.parse().ok()?;
188
+ let ss: u32 = tp.next()?.parse().ok()?;
189
+ if hh > 23 || mm > 59 || ss > 60 {
190
+ return None;
191
+ }
192
+ let days = days_from_civil(year, month as u32, day)?;
193
+ Some(days * 86_400 + i64::from(hh) * 3600 + i64::from(mm) * 60 + i64::from(ss))
194
+ }
195
+
196
+ #[napi]
197
+ pub fn http_date(secs: Option<f64>) -> Buffer {
198
+ let s = secs.map(|v| v as i64).unwrap_or(0);
199
+ Buffer::from(http_date_from_secs(s).into_bytes())
200
+ }
201
+
202
+ /// Pooled-output variant: writes the http-date into `output` and returns bytes
203
+ /// written, so hot loops can reuse a buffer instead of allocating a fresh
204
+ /// Buffer per call. Errors if `output` is too small or the year is outside the
205
+ /// fixed-width range (use the allocating `http_date` for that fallback).
206
+ #[napi]
207
+ pub fn http_date_into(secs: Option<f64>, mut output: Uint8Array) -> Result<u32> {
208
+ let s = secs.map(|v| v as i64).unwrap_or(0);
209
+ // SAFETY: `http_date_into_slice` is capacity-checked (returns Err on a
210
+ // too-small buffer before writing), and no other reference to `output` is
211
+ // live while it runs.
212
+ let written = unsafe { http_date_into_slice(s, output.as_mut())? };
213
+ Ok(written as u32)
214
+ }
215
+
216
+ #[napi]
217
+ pub fn parse_http_date(input: Uint8Array) -> Option<BigInt> {
218
+ parse_http_date_secs(input.as_ref()).map(BigInt::from)
219
+ }
220
+
221
+ #[cfg(test)]
222
+ mod tests {
223
+ use super::*;
224
+
225
+ #[test]
226
+ fn http_date_epoch() {
227
+ assert_eq!(http_date_from_secs(0), "Thu, 01 Jan 1970 00:00:00 GMT");
228
+ assert_eq!(
229
+ http_date_from_secs(978_307_200),
230
+ "Mon, 01 Jan 2001 00:00:00 GMT"
231
+ );
232
+ assert_eq!(
233
+ http_date_from_secs(784_111_777),
234
+ "Sun, 06 Nov 1994 08:49:37 GMT"
235
+ );
236
+ }
237
+
238
+ #[test]
239
+ fn http_date_roundtrip() {
240
+ for secs in [0i64, 1, 946_684_800, 784_111_777, 1_700_000_000] {
241
+ let s = http_date_from_secs(secs);
242
+ assert_eq!(parse_http_date_secs(s.as_bytes()), Some(secs));
243
+ }
244
+ }
245
+
246
+ #[test]
247
+ fn http_date_into_matches_allocating() {
248
+ for secs in [
249
+ 0i64,
250
+ 1,
251
+ 946_684_800,
252
+ 784_111_777,
253
+ 1_700_000_000,
254
+ -1,
255
+ -86_400,
256
+ ] {
257
+ let expected = http_date_from_secs(secs);
258
+ let mut out = [0u8; 32];
259
+ let n = http_date_into_slice(secs, &mut out).unwrap();
260
+ assert_eq!(&out[..n], expected.as_bytes(), "secs: {secs}");
261
+ }
262
+ }
263
+
264
+ #[test]
265
+ fn http_date_into_rejects_small_buffer() {
266
+ let mut out = [0u8; 28];
267
+ assert!(http_date_into_slice(0, &mut out).is_err());
268
+ }
269
+
270
+ #[test]
271
+ fn http_date_into_out_of_range_year_falls_back() {
272
+ // Year 10000 (253_402_300_800s): the fixed-width pooled writer errors;
273
+ // the allocating path keeps the exact format! fallback.
274
+ let big = 253_402_300_800i64; // 10000-01-01T00:00:00Z
275
+ let mut out = [0u8; 32];
276
+ assert!(http_date_into_slice(big, &mut out).is_err());
277
+ assert!(http_date_from_secs(big).contains("01 Jan 10000"));
278
+ }
279
+
280
+ #[test]
281
+ fn parse_http_date_rejects_malformed() {
282
+ assert!(parse_http_date_secs(b"not a date").is_none());
283
+ assert!(parse_http_date_secs(b"Sun, 99 Nov 1994 08:49:37 GMT").is_none());
284
+ assert!(parse_http_date_secs(b"Sun, 06 Nov 1994 25:49:37 GMT").is_none());
285
+ }
286
+ }