castrum 0.9.9 → 0.9.11

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.
package/rust/util/text.rs CHANGED
@@ -14,23 +14,50 @@ use napi_derive::napi;
14
14
  /// [`regex_escape_write`].
15
15
  const REGEX_META: &[u8; 14] = b"\\.*+?^${}()|[]";
16
16
 
17
+ /// 256-entry membership lookup for [`REGEX_META`] — one table load per byte.
18
+ /// The previous per-byte `REGEX_META.contains(&c)` did a linear 14-element
19
+ /// scan PER byte (O(n·14)); a 16KB payload took ~100µs (~160MB/s) and the
20
+ /// win margin vs JS regex-replace collapsed with size. The LUT makes the hot
21
+ /// loop branch-predictable (~1 load/byte), and safe runs are copied in bulk
22
+ /// (memcpy) instead of one push per byte.
23
+ const REGEX_META_LUT: [u8; 256] = {
24
+ let mut t = [0u8; 256];
25
+ let mut i = 0usize;
26
+ while i < REGEX_META.len() {
27
+ t[REGEX_META[i] as usize] = 1;
28
+ i += 1;
29
+ }
30
+ t
31
+ };
32
+
33
+ #[inline(always)]
34
+ fn is_meta(b: u8) -> bool {
35
+ REGEX_META_LUT[b as usize] != 0
36
+ }
37
+
17
38
  /// Append the RegExp-escaped form of `input` to `out`. ASCII-only transform
18
39
  /// (backslash before metachar bytes) — multi-byte UTF-8 sequences pass
19
40
  /// through byte-exact because no UTF-8 continuation byte is a metachar.
41
+ /// Safe runs are bulk-copied (memcpy) between metachars, so throughput scales
42
+ /// with memory bandwidth, not the metachar-set size.
20
43
  pub fn regex_escape_into(input: &[u8], out: &mut Vec<u8>) {
21
- for &c in input {
22
- if REGEX_META.contains(&c) {
44
+ let mut run_start = 0usize;
45
+ for (i, &c) in input.iter().enumerate() {
46
+ if is_meta(c) {
47
+ out.extend_from_slice(&input[run_start..i]);
23
48
  out.push(b'\\');
49
+ out.push(c);
50
+ run_start = i + 1;
24
51
  }
25
- out.push(c);
26
52
  }
53
+ out.extend_from_slice(&input[run_start..]);
27
54
  }
28
55
 
29
56
  /// Exact escaped length (metachar count + input length) — lets callers size
30
57
  /// output buffers without running the escape twice.
31
58
  #[inline]
32
59
  pub fn regex_escape_len(input: &[u8]) -> usize {
33
- input.len() + input.iter().filter(|b| REGEX_META.contains(b)).count()
60
+ input.len() + input.iter().filter(|&&b| is_meta(b)).count()
34
61
  }
35
62
 
36
63
  /// Write the escaped form of `input` into `out` (which must be at least
@@ -38,15 +65,21 @@ pub fn regex_escape_len(input: &[u8]) -> usize {
38
65
  /// written.
39
66
  pub fn regex_escape_write(input: &[u8], out: &mut [u8]) -> usize {
40
67
  let mut pos = 0usize;
41
- for &c in input {
42
- if REGEX_META.contains(&c) {
68
+ let mut run_start = 0usize;
69
+ for (i, &c) in input.iter().enumerate() {
70
+ if is_meta(c) {
71
+ let run = &input[run_start..i];
72
+ out[pos..pos + run.len()].copy_from_slice(run);
73
+ pos += run.len();
43
74
  out[pos] = b'\\';
44
- pos += 1;
75
+ out[pos + 1] = c;
76
+ pos += 2;
77
+ run_start = i + 1;
45
78
  }
46
- out[pos] = c;
47
- pos += 1;
48
79
  }
49
- pos
80
+ let tail = &input[run_start..];
81
+ out[pos..pos + tail.len()].copy_from_slice(tail);
82
+ pos + tail.len()
50
83
  }
51
84
 
52
85
  /// Escape JS RegExp metacharacters in a string → a pattern that matches the
@@ -127,4 +160,73 @@ mod tests {
127
160
  let escaped = String::from_utf8(out).expect("utf8");
128
161
  assert_eq!(escaped, r"a\.c");
129
162
  }
163
+
164
+ /// Reference escape (the previous per-byte `REGEX_META.contains` loop).
165
+ fn ref_escape(input: &[u8]) -> Vec<u8> {
166
+ const META: &[u8] = b"\\.*+?^${}()|[]";
167
+ let mut out = Vec::new();
168
+ for &c in input {
169
+ if META.contains(&c) {
170
+ out.push(b'\\');
171
+ }
172
+ out.push(c);
173
+ }
174
+ out
175
+ }
176
+
177
+ #[test]
178
+ fn regex_escape_matches_reference_for_all_lengths() {
179
+ // Every length 0..=96 plus larger payloads: the LUT + run-copy core
180
+ // (bulk memcpy between metachars) must be byte-identical to the naive
181
+ // per-byte reference. Lengths 15/16/17… straddle no SIMD chunk here
182
+ // (the transform is scalar), but the sweep locks positional behavior:
183
+ // a run-copy off-by-one (dropping or duplicating a safe byte) surfaces.
184
+ let mut lens: Vec<usize> = (0..=96).collect();
185
+ lens.extend([128, 512, 16 * 1024, 256 * 1024]);
186
+ for n in lens {
187
+ let input: Vec<u8> = (0..n).map(|i| (i.wrapping_mul(31) + 5) as u8).collect();
188
+ let expect = ref_escape(&input);
189
+ // allocating path
190
+ let mut out = Vec::new();
191
+ regex_escape_into(&input, &mut out);
192
+ assert_eq!(out, expect, "len {n}");
193
+ // len formula
194
+ assert_eq!(regex_escape_len(&input), expect.len(), "len {n}");
195
+ // write path (exact-size buffer, like the C-ABI caller)
196
+ let mut buf = vec![0u8; expect.len()];
197
+ let written = regex_escape_write(&input, &mut buf);
198
+ assert_eq!(written, expect.len(), "len {n}");
199
+ assert_eq!(&buf[..written], &expect[..], "len {n}");
200
+ }
201
+ }
202
+
203
+ #[test]
204
+ fn regex_escape_large_payload_edges() {
205
+ // Three 256 KiB extremes: every byte a metachar (no bulk runs — the
206
+ // worst case for the run-copy core), no metachars at all (pure bulk
207
+ // copy), and a dense alternating pattern. All must match the reference
208
+ // and the exact-length contract.
209
+ let all_meta: Vec<u8> = (0..256 * 1024)
210
+ .map(|i| b"\\.*+?^${}()|[]"[i % 14])
211
+ .collect();
212
+ let none: Vec<u8> = (0..256 * 1024).map(|i| b"abcXYZ0123_-/"[i % 13]).collect();
213
+ let dense: Vec<u8> = (0..256 * 1024)
214
+ .map(|i| if i % 2 == 0 { b'.' } else { b'a' })
215
+ .collect();
216
+ for input in [all_meta, none, dense] {
217
+ let expect = ref_escape(&input);
218
+ let mut out = Vec::new();
219
+ regex_escape_into(&input, &mut out);
220
+ assert_eq!(out, expect);
221
+ assert_eq!(regex_escape_len(&input), expect.len());
222
+ let mut buf = vec![0u8; expect.len()];
223
+ let written = regex_escape_write(&input, &mut buf);
224
+ assert_eq!(written, expect.len());
225
+ assert_eq!(&buf[..written], &expect[..]);
226
+ }
227
+ // All-metachar bound: escaped length is exactly 2x (every byte gets a
228
+ // backslash); all-safe bound: escaped length equals input length.
229
+ assert_eq!(regex_escape_len(&vec![b'.'; 1024]), 2048);
230
+ assert_eq!(regex_escape_len(&vec![b'a'; 1024]), 1024);
231
+ }
130
232
  }
@@ -317,6 +317,40 @@ mod tests {
317
317
  assert!(!validate_uuid_bytes(b"550e8400e29b41d4a716446655440000")); // no dashes
318
318
  }
319
319
 
320
+ /// Byte-for-byte pins the accept set: replacing ANY single hex position
321
+ /// (0..7, 9..12, 15..17, 20..22, 24..35) with a non-hex digit must reject,
322
+ /// and every one of the 32 hex positions must accept uppercase + lowercase
323
+ /// digits. Guards against a future rewrite (e.g. SIMD/run-split) silently
324
+ /// dropping or mis-scoping a position — measured perf-neutral, kept as a
325
+ /// spec-lock regression net.
326
+ #[test]
327
+ fn validate_uuid_hex_run_coverage() {
328
+ let dash = |i: usize| matches!(i, 8 | 13 | 18 | 23);
329
+ for pos in 0..36 {
330
+ let valid = b"550e8400-e29b-41d4-a716-446655440000";
331
+ if dash(pos) {
332
+ continue; // dash positions are pinned by their own check
333
+ }
334
+ // every non-dash position is a hex position (version/variant cols
335
+ // included — 14 must stay '4', 19 must stay in 8/9/a/b set)
336
+ if valid[pos].is_ascii_hexdigit() {
337
+ let mut upper = *valid;
338
+ upper[pos] = valid[pos].to_ascii_uppercase();
339
+ assert!(validate_uuid_bytes(&upper), "uppercase at {pos}");
340
+ }
341
+ let mut bad = *valid;
342
+ bad[pos] = b'g'; // 'g' is not a hex digit
343
+ assert!(!validate_uuid_bytes(&bad), "g at {pos}");
344
+ }
345
+ // and the version / variant columns still reject wrong values
346
+ let mut v = *b"550e8400-e29b-41d4-a716-446655440000";
347
+ v[14] = b'5';
348
+ assert!(!validate_uuid_bytes(&v));
349
+ v = *b"550e8400-e29b-41d4-a716-446655440000";
350
+ v[19] = b'c';
351
+ assert!(!validate_uuid_bytes(&v));
352
+ }
353
+
320
354
  #[test]
321
355
  fn validate_ipv4_cases() {
322
356
  assert!(validate_ipv4_bytes(b"192.168.0.1"));