jsecmon: add Bytes-based ASCII string toolkit (strbytes)

Jaime Fournier <jaimef@linbsd.org>

8d2c62b03c887bca249f71a2e98d5ebac608280b

diff --git a/README.md b/README.md
index 38c6cd9..2202a57 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,8 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `dga::max_consonant_run` | `typed/dga.ss`     | ✅ ported, vectors pass         |
 | `dga::shannon_entropy`   | `typed/dga.ss`     | ✅ ported, vectors pass         |
 | `dga::score_domain` (scoring core) | `typed/dga.ss` | ✅ `score-label` 0..100 headline, vectors pass |
-| `dga::score_domain` (wrapper) | —             | ⏳ split('.') + benign-suffix + reasons list (string ops) |
+| `dga::score_domain` (wrapper) | —             | ⏳ assembles via `strbytes` below; reasons list pending |
+| `&str` ops (lowercase/ends_with/starts_with/contains/split) | `typed/strbytes.ss` | ✅ Bytes toolkit, vectors pass — shared by dga/lolbin/sigma |
 | `lolbin`, `sigma`, `triage` | —               | ⏳ pure logic, queued           |
 | `psk::constant_time_eq`  | `typed/psk.ss`     | ✅ ported, vectors pass         |
 | `psk::from_hex` (hex codec) | `typed/psk.ss`  | ✅ hex-encode ported; decode + length check queued |
diff --git a/tests/strbytes_vectors.rs b/tests/strbytes_vectors.rs
new file mode 100644
index 0000000..84ea154
--- /dev/null
+++ b/tests/strbytes_vectors.rs
@@ -0,0 +1,58 @@
+//! Vectors for the Bytes-based ASCII string toolkit, checked against the exact
+//! cases secmon's detectors rely on (dga::score_domain's benign-suffix
+//! suppression and leftmost-label split).
+
+use jerboa_typed_generated::jsecmon_strbytes::{
+    ascii_lower_bytes, bytes_contains_p, bytes_prefix_p, bytes_suffix_p, first_label_len,
+    index_of_byte,
+};
+
+fn b(s: &str) -> Vec<u8> {
+    s.as_bytes().to_vec()
+}
+
+#[test]
+fn suffix_matches_benign_cdn_check() {
+    // dga::score_domain bails when the trimmed domain ends with a benign
+    // high-entropy CDN suffix.
+    assert!(bytes_suffix_p(b("d2hk78xq2k.cloudfront.net"), b(".cloudfront.net")));
+    assert!(bytes_suffix_p(b("bucket.s3.amazonaws.com"), b(".amazonaws.com")));
+    // a normal domain matches none of them
+    assert!(!bytes_suffix_p(b("mail.google.com"), b(".cloudfront.net")));
+    // ends_with self, and the empty needle, are true (Rust semantics)
+    assert!(bytes_suffix_p(b("github.io"), b("github.io")));
+    assert!(bytes_suffix_p(b("anything"), b("")));
+    // needle longer than haystack is false
+    assert!(!bytes_suffix_p(b("io"), b(".github.io")));
+}
+
+#[test]
+fn prefix_and_contains() {
+    assert!(bytes_prefix_p(b("cloudfront.net"), b("cloud")));
+    assert!(!bytes_prefix_p(b("cloudfront.net"), b("front")));
+    assert!(bytes_contains_p(b("x.s3.amazonaws.com"), b(".amazonaws.com")));
+    assert!(bytes_contains_p(b("abcdef"), b("cde")));
+    assert!(!bytes_contains_p(b("abcdef"), b("xyz")));
+    assert!(bytes_contains_p(b("abc"), b(""))); // empty needle is everywhere
+}
+
+#[test]
+fn label_split_matches_secmon() {
+    // split('.').next() length: "kxq8z23nplkdq.example.com" → "kxq8z23nplkdq" (13)
+    assert_eq!(first_label_len(b("kxq8z23nplkdq.example.com")), 13);
+    // no dot → the whole string is one label
+    assert_eq!(first_label_len(b("localhost")), 9);
+    // leading dot → empty first label (length 0)
+    assert_eq!(first_label_len(b(".hidden")), 0);
+    // index_of_byte finds the first '.'; absent → length
+    assert_eq!(index_of_byte(b("a.b.c"), b'.' as u64), 1);
+    assert_eq!(index_of_byte(b("nodot"), b'.' as u64), 5);
+}
+
+#[test]
+fn lowercase_is_ascii_faithful() {
+    assert_eq!(ascii_lower_bytes("GOOGLE.COM".to_string()), b("google.com"));
+    // digits, dots, and already-lowercase bytes are untouched
+    assert_eq!(ascii_lower_bytes("a1B2.Net".to_string()), b("a1b2.net"));
+    assert_eq!(ascii_lower_bytes("".to_string()), b(""));
+}
diff --git a/typed/strbytes.ss b/typed/strbytes.ss
new file mode 100644
index 0000000..5e1f1ff
--- /dev/null
+++ b/typed/strbytes.ss
@@ -0,0 +1,70 @@
+;;; jsecmon — Bytes-based ASCII string matching toolkit.
+;;;
+;;; secmon's detection modules (dga::score_domain, lolbin, sigma) lean on
+;;; `&str` methods: to_ascii_lowercase, ends_with, starts_with, contains,
+;;; split('.').next(). The Jerboa typed backend has no Rust `str` API, but the
+;;; inputs here are all ASCII, so every one of those is expressible over UTF-8
+;;; bytes using only buffer indexing, recursion, and bytes-build. This module
+;;; is the shared primitive layer those ports build on.
+
+(typed-library (jsecmon strbytes)
+  (export ascii-lower-bytes bytes-suffix? bytes-prefix? bytes-contains?
+          index-of-byte first-label-len)
+
+  ;; ASCII lowercase one byte: map A-Z (65..90) into a-z by adding 0x20.
+  (def (lower1 (b : Nat)) : Nat
+    (if (and (>= b 65) (<= b 90)) (+ b 32) b))
+
+  ;; Lowercase a whole string into a fresh Bytes buffer (no mutation).
+  ;; Mirrors str::to_ascii_lowercase for the ASCII domain we operate in.
+  (def (ascii-lower-bytes (s : String)) : Bytes
+    (let ((bs (string->utf8 s)))
+      (bytes-build (bytevector-length bs) (i (lower1 (bytevector-u8-ref bs i))))))
+
+  ;; #t iff hay[off..off+m] equals need[0..m] (m = length of need).
+  (def (match-at? (hay : Bytes) (off : Nat) (need : Bytes) (i : Nat) (m : Nat)) : Bool
+    (if (>= i m)
+        #t
+        (and (= (bytevector-u8-ref hay (+ off i)) (bytevector-u8-ref need i))
+             (match-at? hay off need (+ i 1) m))))
+
+  ;; str::ends_with — does hay end with need?
+  (def (bytes-suffix? (hay : Bytes) (need : Bytes)) : Bool
+    (let ((hn (bytevector-length hay))
+          (nn (bytevector-length need)))
+      (if (> nn hn) #f (match-at? hay (- hn nn) need 0 nn))))
+
+  ;; str::starts_with — does hay start with need?
+  (def (bytes-prefix? (hay : Bytes) (need : Bytes)) : Bool
+    (let ((hn (bytevector-length hay))
+          (nn (bytevector-length need)))
+      (if (> nn hn) #f (match-at? hay 0 need 0 nn))))
+
+  ;; naive substring search from `start`; #t iff need occurs in hay at/after it.
+  (def (search-from (hay : Bytes) (hn : Nat) (need : Bytes) (nn : Nat) (start : Nat)) : Bool
+    (if (> (+ start nn) hn)
+        #f
+        (if (match-at? hay start need 0 nn)
+            #t
+            (search-from hay hn need nn (+ start 1)))))
+
+  ;; str::contains — does need occur anywhere in hay? The empty needle is
+  ;; contained everywhere (matching Rust).
+  (def (bytes-contains? (hay : Bytes) (need : Bytes)) : Bool
+    (search-from hay (bytevector-length hay) need (bytevector-length need) 0))
+
+  ;; index of the first byte == v scanning [i, n), or n if none.
+  (def (scan-byte (bs : Bytes) (v : Nat) (i : Nat) (n : Nat)) : Nat
+    (if (>= i n)
+        n
+        (if (= (bytevector-u8-ref bs i) v) i (scan-byte bs v (+ i 1) n))))
+
+  ;; index of the first byte equal to v, or the length if absent. The Bytes
+  ;; analogue of str::find(c) (returning len rather than None).
+  (def (index-of-byte (bs : Bytes) (v : Nat)) : Nat
+    (scan-byte bs v 0 (bytevector-length bs)))
+
+  ;; length of the leftmost dot-delimited label: split('.').next(). The label
+  ;; is bs[0..first-label-len]; with no dot the whole buffer is one label.
+  (def (first-label-len (bs : Bytes)) : Nat
+    (index-of-byte bs 46)))