security: harden regex native ffi

ober

5271f35e9a49586f75fad3bfbe7350ec8f6ae158

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index a5c41ff..f5e0a8e 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -40,10 +40,10 @@ Latest summary from `tools/ffi-audit-report.ss`:
     (rust-file-count 29)
     (c-file-count 5)
     (no-mangle-exports 196)
-    (unsafe-sites 439)
-    (unsafe-sites-with-nearby-safety-comment 143)
-    (unsafe-sites-without-nearby-safety-comment 296)
-    (unsafe-comment-review-sites 296)
+    (unsafe-sites 432)
+    (unsafe-sites-with-nearby-safety-comment 151)
+    (unsafe-sites-without-nearby-safety-comment 281)
+    (unsafe-comment-review-sites 281)
     (export-review-sites 196))
   (vendor
     (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
@@ -95,8 +95,12 @@ rejects null nonempty plaintext and output-length overflow before creating FFI
 slices. `integrity.rs` now documents self-hash, signature-verification, file
 region hashing, and FreeBSD `sysctl` unsafe invariants, checks exclusion-region
 arithmetic before slicing, and streams caller-sized file regions instead of
-allocating a caller-controlled `u64` length. The generated inventory now reports
-143 annotated native unsafe sites and 296 remaining unsafe review sites.
+allocating a caller-controlled `u64` length. `regex_native.rs` now validates
+nullable byte buffers through a shared helper before creating Rust slices,
+documents output-slot and ovector invariants, checks capture slot arithmetic,
+and returns a normal FFI error if the regex handle store is poisoned. The
+generated inventory now reports 151 annotated native unsafe sites and 281
+remaining unsafe review sites.
 
 Remaining work before closing K3-P1-01:
 
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 512cd54..2cb6b10 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -639,10 +639,13 @@ not started."
   `integrity.rs` now documents self-hash, signature-verification, file-region
   hashing, and FreeBSD `sysctl` unsafe invariants, checks exclusion-region
   arithmetic before slicing, and streams caller-sized file regions instead of
-  allocating a caller-controlled `u64` length. The generated report now shows
-  143 annotated native unsafe sites and 296 remaining unsafe review sites.
-  Remaining work: continue unsafe invariant comments across the rest of
-  `jerboa-native-rs`.
+  allocating a caller-controlled `u64` length. `regex_native.rs` now validates
+  nullable byte buffers through a shared helper before creating Rust slices,
+  documents output-slot and ovector invariants, checks capture slot arithmetic,
+  and returns a normal FFI error if the regex handle store is poisoned. The
+  generated report now shows 151 annotated native unsafe sites and 281 remaining
+  unsafe review sites. Remaining work: continue unsafe invariant comments across
+  the rest of `jerboa-native-rs`.
 
 ### K3-P1-02 — TOCTOU-safe filesystem capability checks
 **Serves:** G2. **Effort:** 1 week.
@@ -1130,7 +1133,7 @@ Track these in `docs/status.md` per release:
 | Confined worker exists | facade, audit lifecycle, output caps, deadline, process-group kill, memory rlimit pre-exec path, explicit sandbox-axis refusal, egress proxy env wiring, and Linux syscall/ptrace seccomp pre-exec setup landed; Landlock path/net and Seatbelt/Capsicum parity pending | yes, tested (P0-02) |
 | Unclassified raw `read`/FASL/`load` sites | scanner-driven manifest classification gate closed 2026-07-27; trusted build, compiler, REPL/dev, and test surfaces are centrally justified by exact source line | 0 |
 | FFI bindings audited | scanner inventory and provisional verdicts in [ffi-audit.md](ffi-audit.md); native exports gated and justified | 100% with final verdicts |
-| Un-annotated Rust `unsafe` blocks | 296 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
+| Un-annotated Rust `unsafe` blocks | 281 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
 | Fuzz corpora / crash regressions | 0 / 0 | per-parser corpora + every crash a test |
 | Exploit-shaped regression tests | ~0 | ≥ 1 per historical finding |
 | Sandboxed-by-default app entry | no | yes (P0-06) |
diff --git a/docs/reviews/2026-07-27-native-export-review.sexp b/docs/reviews/2026-07-27-native-export-review.sexp
index c230f89..fd02453 100644
--- a/docs/reviews/2026-07-27-native-export-review.sexp
+++ b/docs/reviews/2026-07-27-native-export-review.sexp
@@ -100,14 +100,14 @@
     (export (symbol "jerboa_pg_nrows") (file "jerboa-native-rs/src/postgres_native.rs") (line 215) (scheme-callers 1 (caller (file "lib/std/db/postgresql-native.ss") (line 32))))
     (export (symbol "jerboa_pg_query") (file "jerboa-native-rs/src/postgres_native.rs") (line 172) (scheme-callers 1 (caller (file "lib/std/db/postgresql-native.ss") (line 30))))
     (export (symbol "jerboa_random_bytes") (file "jerboa-native-rs/src/crypto.rs") (line 285) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 98))))
-    (export (symbol "jerboa_regex_captures") (file "jerboa-native-rs/src/regex_native.rs") (line 274) (scheme-callers 2 (caller (file "lib/std/regex-native.ss") (line 40)) (caller (file "lib/std/regex.ss") (line 106))))
-    (export (symbol "jerboa_regex_compile") (file "jerboa-native-rs/src/regex_native.rs") (line 14) (scheme-callers 3 (caller (file "lib/std/regex-native.ss") (line 27)) (caller (file "lib/std/regex.ss") (line 81)) (caller (file "lib/std/regex.ss") (line 85))))
-    (export (symbol "jerboa_regex_find") (file "jerboa-native-rs/src/regex_native.rs") (line 75) (scheme-callers 4 (caller (file "lib/std/regex-native.ss") (line 33)) (caller (file "lib/std/regex.ss") (line 89)) (caller (file "lib/std/regex.ss") (line 97)) (caller (file "lib/std/regex.ss") (line 657))))
-    (export (symbol "jerboa_regex_find_at") (file "jerboa-native-rs/src/regex_native.rs") (line 228) (scheme-callers 3 (caller (file "lib/std/regex-native.ss") (line 33)) (caller (file "lib/std/regex.ss") (line 97)) (caller (file "lib/std/regex.ss") (line 657))))
-    (export (symbol "jerboa_regex_free") (file "jerboa-native-rs/src/regex_native.rs") (line 157) (scheme-callers 2 (caller (file "lib/std/regex-native.ss") (line 48)) (caller (file "lib/std/regex.ss") (line 93))))
-    (export (symbol "jerboa_regex_group_count") (file "jerboa-native-rs/src/regex_native.rs") (line 327) (scheme-callers 2 (caller (file "lib/std/regex-native.ss") (line 37)) (caller (file "lib/std/regex.ss") (line 102))))
-    (export (symbol "jerboa_regex_is_match") (file "jerboa-native-rs/src/regex_native.rs") (line 49) (scheme-callers 1 (caller (file "lib/std/regex-native.ss") (line 30))))
-    (export (symbol "jerboa_regex_replace_all") (file "jerboa-native-rs/src/regex_native.rs") (line 112) (scheme-callers 1 (caller (file "lib/std/regex-native.ss") (line 44))))
+    (export (symbol "jerboa_regex_captures") (file "jerboa-native-rs/src/regex_native.rs") (line 357) (scheme-callers 2 (caller (file "lib/std/regex-native.ss") (line 40)) (caller (file "lib/std/regex.ss") (line 106))))
+    (export (symbol "jerboa_regex_compile") (file "jerboa-native-rs/src/regex_native.rs") (line 38) (scheme-callers 3 (caller (file "lib/std/regex-native.ss") (line 27)) (caller (file "lib/std/regex.ss") (line 81)) (caller (file "lib/std/regex.ss") (line 85))))
+    (export (symbol "jerboa_regex_find") (file "jerboa-native-rs/src/regex_native.rs") (line 114) (scheme-callers 4 (caller (file "lib/std/regex-native.ss") (line 33)) (caller (file "lib/std/regex.ss") (line 89)) (caller (file "lib/std/regex.ss") (line 97)) (caller (file "lib/std/regex.ss") (line 657))))
+    (export (symbol "jerboa_regex_find_at") (file "jerboa-native-rs/src/regex_native.rs") (line 303) (scheme-callers 3 (caller (file "lib/std/regex-native.ss") (line 33)) (caller (file "lib/std/regex.ss") (line 97)) (caller (file "lib/std/regex.ss") (line 657))))
+    (export (symbol "jerboa_regex_free") (file "jerboa-native-rs/src/regex_native.rs") (line 219) (scheme-callers 2 (caller (file "lib/std/regex-native.ss") (line 48)) (caller (file "lib/std/regex.ss") (line 93))))
+    (export (symbol "jerboa_regex_group_count") (file "jerboa-native-rs/src/regex_native.rs") (line 431) (scheme-callers 2 (caller (file "lib/std/regex-native.ss") (line 37)) (caller (file "lib/std/regex.ss") (line 102))))
+    (export (symbol "jerboa_regex_is_match") (file "jerboa-native-rs/src/regex_native.rs") (line 82) (scheme-callers 1 (caller (file "lib/std/regex-native.ss") (line 30))))
+    (export (symbol "jerboa_regex_replace_all") (file "jerboa-native-rs/src/regex_native.rs") (line 159) (scheme-callers 1 (caller (file "lib/std/regex-native.ss") (line 44))))
     (export (symbol "jerboa_scrypt") (file "jerboa-native-rs/src/crypto.rs") (line 694) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 226))))
     (export (symbol "jerboa_seccomp_available") (file "jerboa-native-rs/src/seccomp.rs") (line 285) (scheme-callers 1 (caller (file "lib/std/os/seccomp.ss") (line 32))))
     (export (symbol "jerboa_seccomp_lock") (file "jerboa-native-rs/src/seccomp.rs") (line 179) (scheme-callers 2 (caller (file "lib/std/os/seccomp.ss") (line 34)) (caller (file "lib/std/os/seccomp.ss") (line 36))))
diff --git a/docs/status.md b/docs/status.md
index a232dad..5036ef7 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -25,7 +25,7 @@ release artifacts are built as Jerboa multicall binaries with `jerboa`,
 | Area | Current state | Remaining work |
 |---|---|---|
 | Kimi security handoff | [kimi3-security-recommmendations.md](kimi3-security-recommmendations.md) is the backlog. Dated evidence and review manifests live under [reviews/](reviews/). | Keep new security evidence in dated review records and summarize the current release state here. |
-| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, and integrity ABI now have nearby `SAFETY:` comments, with the generated unannotated unsafe-site count at 296. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. |
+| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, and regex-native ABI now have nearby `SAFETY:` comments, with the generated unannotated unsafe-site count at 281. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. |
 | Native Rust exports | The native export review now has 190 exported functions: 183 tracked Scheme references and 7 retained standalone C/binary helpers. The previous 35 no-Scheme-reference removal candidates no longer have C ABI export markers. | Re-run `make native-export-review-check` whenever adding or removing native exports. |
 | Confined worker | `(std security worker)` provides the facade, audit lifecycle, output caps, deadlines, process-group kill, memory rlimit pre-exec setup, Linux syscall/ptrace seccomp pre-exec setup for requested axes, explicit sandbox-axis refusal, and egress proxy env wiring. | Install native Landlock path/net rules in the worker pre-exec path and keep Linux/macOS/FreeBSD parity tests current. |
 | Safe surface | Direct scripts default to the safe prelude; raw access requires `--unsafe-prelude` or `(jerboa prelude unsafe)`. | Continue moving risky APIs behind explicit unsafe imports as new modules land. |
diff --git a/jerboa-native-rs/src/regex_native.rs b/jerboa-native-rs/src/regex_native.rs
index 8261a5f..7d7dc6f 100644
--- a/jerboa-native-rs/src/regex_native.rs
+++ b/jerboa-native-rs/src/regex_native.rs
@@ -4,12 +4,36 @@ use std::collections::HashMap;
 use std::sync::atomic::{AtomicU64, Ordering};
 use std::sync::LazyLock;
 use std::sync::Mutex;
+use std::sync::MutexGuard;
 
 // Opaque handle system
 static REGEX_STORE: LazyLock<Mutex<HashMap<u64, Regex>>> =
     LazyLock::new(|| Mutex::new(HashMap::new()));
 static NEXT_ID: AtomicU64 = AtomicU64::new(1);
 
+fn lock_regex_store() -> Result<MutexGuard<'static, HashMap<u64, Regex>>, i32> {
+    match REGEX_STORE.lock() {
+        Ok(guard) => Ok(guard),
+        Err(_) => {
+            set_last_error("regex store lock poisoned".to_string());
+            Err(-1)
+        }
+    }
+}
+
+fn ffi_bytes<'a>(ptr: *const u8, len: usize, name: &str) -> Result<&'a [u8], i32> {
+    if ptr.is_null() {
+        if len == 0 {
+            return Ok(&[]);
+        }
+        set_last_error(format!("null {} pointer", name));
+        return Err(-1);
+    }
+    // SAFETY: ptr is non-null above and the C ABI contract requires a readable
+    // len-byte buffer for this input parameter.
+    Ok(unsafe { std::slice::from_raw_parts(ptr, len) })
+}
+
 #[no_mangle]
 pub extern "C" fn jerboa_regex_compile(
     pattern: *const u8,
@@ -17,10 +41,13 @@ pub extern "C" fn jerboa_regex_compile(
     handle: *mut u64,
 ) -> i32 {
     ffi_wrap(|| {
-        if pattern.is_null() || handle.is_null() {
+        if handle.is_null() {
             return -1;
         }
-        let pat_bytes = unsafe { std::slice::from_raw_parts(pattern, pattern_len) };
+        let pat_bytes = match ffi_bytes(pattern, pattern_len, "pattern") {
+            Ok(bytes) => bytes,
+            Err(code) => return code,
+        };
         let pat = match std::str::from_utf8(pat_bytes) {
             Ok(s) => s,
             Err(_) => {
@@ -31,7 +58,13 @@ pub extern "C" fn jerboa_regex_compile(
         match Regex::new(pat) {
             Ok(re) => {
                 let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
-                REGEX_STORE.lock().unwrap().insert(id, re);
+                let mut store = match lock_regex_store() {
+                    Ok(store) => store,
+                    Err(code) => return code,
+                };
+                store.insert(id, re);
+                // SAFETY: handle is non-null above and points to a writable
+                // u64 result slot owned by the caller.
                 unsafe {
                     *handle = id;
                 }
@@ -48,7 +81,10 @@ pub extern "C" fn jerboa_regex_compile(
 #[no_mangle]
 pub extern "C" fn jerboa_regex_is_match(handle: u64, text: *const u8, text_len: usize) -> i32 {
     ffi_wrap(|| {
-        let store = REGEX_STORE.lock().unwrap();
+        let store = match lock_regex_store() {
+            Ok(store) => store,
+            Err(code) => return code,
+        };
         let re = match store.get(&handle) {
             Some(r) => r,
             None => {
@@ -56,7 +92,10 @@ pub extern "C" fn jerboa_regex_is_match(handle: u64, text: *const u8, text_len: 
                 return -1;
             }
         };
-        let text_bytes = unsafe { std::slice::from_raw_parts(text, text_len) };
+        let text_bytes = match ffi_bytes(text, text_len, "text") {
+            Ok(bytes) => bytes,
+            Err(code) => return code,
+        };
         let s = match std::str::from_utf8(text_bytes) {
             Ok(s) => s,
             Err(_) => return -1,
@@ -83,18 +122,26 @@ pub extern "C" fn jerboa_regex_find(
         if match_start.is_null() || match_end.is_null() {
             return -1;
         }
-        let store = REGEX_STORE.lock().unwrap();
+        let store = match lock_regex_store() {
+            Ok(store) => store,
+            Err(code) => return code,
+        };
         let re = match store.get(&handle) {
             Some(r) => r,
             None => return -1,
         };
-        let text_bytes = unsafe { std::slice::from_raw_parts(text, text_len) };
+        let text_bytes = match ffi_bytes(text, text_len, "text") {
+            Ok(bytes) => bytes,
+            Err(code) => return code,
+        };
         let s = match std::str::from_utf8(text_bytes) {
             Ok(s) => s,
             Err(_) => return -1,
         };
         match re.find(s) {
             Some(m) => {
+                // SAFETY: match_start and match_end are non-null above and
+                // point to writable usize result slots.
                 unsafe {
                     *match_start = m.start();
                     *match_end = m.end();
@@ -123,17 +170,26 @@ pub extern "C" fn jerboa_regex_replace_all(
         if output.is_null() || output_len.is_null() {
             return -1;
         }
-        let store = REGEX_STORE.lock().unwrap();
+        let store = match lock_regex_store() {
+            Ok(store) => store,
+            Err(code) => return code,
+        };
         let re = match store.get(&handle) {
             Some(r) => r,
             None => return -1,
         };
-        let text_bytes = unsafe { std::slice::from_raw_parts(text, text_len) };
+        let text_bytes = match ffi_bytes(text, text_len, "text") {
+            Ok(bytes) => bytes,
+            Err(code) => return code,
+        };
         let s = match std::str::from_utf8(text_bytes) {
             Ok(s) => s,
             Err(_) => return -1,
         };
-        let repl_bytes = unsafe { std::slice::from_raw_parts(replacement, repl_len) };
+        let repl_bytes = match ffi_bytes(replacement, repl_len, "replacement") {
+            Ok(bytes) => bytes,
+            Err(code) => return code,
+        };
         let r = match std::str::from_utf8(repl_bytes) {
             Ok(s) => s,
             Err(_) => return -1,
@@ -144,8 +200,14 @@ pub extern "C" fn jerboa_regex_replace_all(
             set_last_error("output buffer too small".to_string());
             return -1;
         }
-        let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) };
-        out[..result_bytes.len()].copy_from_slice(result_bytes);
+        if !result_bytes.is_empty() {
+            // SAFETY: output is non-null above, result length is <= output_max,
+            // and callers provide writable capacity for the result bytes.
+            let out = unsafe { std::slice::from_raw_parts_mut(output, result_bytes.len()) };
+            out.copy_from_slice(result_bytes);
+        }
+        // SAFETY: output_len is non-null above and points to a writable usize
+        // result slot.
         unsafe {
             *output_len = result_bytes.len();
         }
@@ -156,7 +218,11 @@ pub extern "C" fn jerboa_regex_replace_all(
 #[no_mangle]
 pub extern "C" fn jerboa_regex_free(handle: u64) -> i32 {
     ffi_wrap(|| {
-        REGEX_STORE.lock().unwrap().remove(&handle);
+        let mut store = match lock_regex_store() {
+            Ok(store) => store,
+            Err(code) => return code,
+        };
+        store.remove(&handle);
         0
     })
 }
@@ -173,10 +239,13 @@ pub extern "C" fn jerboa_regex_compile_ex(
     handle: *mut u64,
 ) -> i32 {
     ffi_wrap(|| {
-        if pattern.is_null() || handle.is_null() {
+        if handle.is_null() {
             return -1;
         }
-        let pat_bytes = unsafe { std::slice::from_raw_parts(pattern, pattern_len) };
+        let pat_bytes = match ffi_bytes(pattern, pattern_len, "pattern") {
+            Ok(bytes) => bytes,
+            Err(code) => return code,
+        };
         let pat = match std::str::from_utf8(pat_bytes) {
             Ok(s) => s,
             Err(_) => {
@@ -208,7 +277,13 @@ pub extern "C" fn jerboa_regex_compile_ex(
         match Regex::new(&full_pattern) {
             Ok(re) => {
                 let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
-                REGEX_STORE.lock().unwrap().insert(id, re);
+                let mut store = match lock_regex_store() {
+                    Ok(store) => store,
+                    Err(code) => return code,
+                };
+                store.insert(id, re);
+                // SAFETY: handle is non-null above and points to a writable
+                // u64 result slot owned by the caller.
                 unsafe {
                     *handle = id;
                 }
@@ -237,12 +312,18 @@ pub extern "C" fn jerboa_regex_find_at(
         if match_start.is_null() || match_end.is_null() {
             return -1;
         }
-        let store = REGEX_STORE.lock().unwrap();
+        let store = match lock_regex_store() {
+            Ok(store) => store,
+            Err(code) => return code,
+        };
         let re = match store.get(&handle) {
             Some(r) => r,
             None => return -1,
         };
-        let text_bytes = unsafe { std::slice::from_raw_parts(text, text_len) };
+        let text_bytes = match ffi_bytes(text, text_len, "text") {
+            Ok(bytes) => bytes,
+            Err(code) => return code,
+        };
         let s = match std::str::from_utf8(text_bytes) {
             Ok(s) => s,
             Err(_) => return -1,
@@ -252,6 +333,8 @@ pub extern "C" fn jerboa_regex_find_at(
         }
         match re.find_at(s, start_offset) {
             Some(m) => {
+                // SAFETY: match_start and match_end are non-null above and
+                // point to writable usize result slots.
                 unsafe {
                     *match_start = m.start();
                     *match_end = m.end();
@@ -283,12 +366,18 @@ pub extern "C" fn jerboa_regex_captures(
         if ovector_buf.is_null() || ovector_capacity < 2 {
             return -1;
         }
-        let store = REGEX_STORE.lock().unwrap();
+        let store = match lock_regex_store() {
+            Ok(store) => store,
+            Err(code) => return code,
+        };
         let re = match store.get(&handle) {
             Some(r) => r,
             None => return -1,
         };
-        let text_bytes = unsafe { std::slice::from_raw_parts(text, text_len) };
+        let text_bytes = match ffi_bytes(text, text_len, "text") {
+            Ok(bytes) => bytes,
+            Err(code) => return code,
+        };
         let s = match std::str::from_utf8(text_bytes) {
             Ok(s) => s,
             Err(_) => return -1,
@@ -301,9 +390,24 @@ pub extern "C" fn jerboa_regex_captures(
             None => return 0,
         };
         let num_groups = caps.len(); // includes group 0 (full match)
-        let slots_needed = num_groups * 2;
+        let slots_needed = match num_groups.checked_mul(2) {
+            Some(n) => n,
+            None => {
+                set_last_error("capture slot count overflow".to_string());
+                return -1;
+            }
+        };
         let slots_to_write = slots_needed.min(ovector_capacity);
         let groups_to_write = slots_to_write / 2;
+        let groups_written_i32 = match i32::try_from(groups_to_write) {
+            Ok(n) => n,
+            Err(_) => {
+                set_last_error("capture group count exceeds i32".to_string());
+                return -1;
+            }
+        };
+        // SAFETY: ovector_buf is non-null above, ovector_capacity is at least
+        // slots_to_write, and callers provide writable usize slots.
         let ov = unsafe { std::slice::from_raw_parts_mut(ovector_buf, slots_to_write) };
         for i in 0..groups_to_write {
             match caps.get(i) {
@@ -317,7 +421,7 @@ pub extern "C" fn jerboa_regex_captures(
                 }
             }
         }
-        groups_to_write as i32
+        groups_written_i32
     })
 }
 
@@ -326,7 +430,10 @@ pub extern "C" fn jerboa_regex_captures(
 #[no_mangle]
 pub extern "C" fn jerboa_regex_group_count(handle: u64) -> i32 {
     ffi_wrap(|| {
-        let store = REGEX_STORE.lock().unwrap();
+        let store = match lock_regex_store() {
+            Ok(store) => store,
+            Err(code) => return code,
+        };
         let re = match store.get(&handle) {
             Some(r) => r,
             None => return -1,
@@ -335,3 +442,64 @@ pub extern "C" fn jerboa_regex_group_count(handle: u64) -> i32 {
         (re.captures_len()) as i32
     })
 }
+
+#[cfg(test)]
+mod tests {
+    use super::{
+        jerboa_regex_compile, jerboa_regex_find, jerboa_regex_free, jerboa_regex_is_match,
+        jerboa_regex_replace_all,
+    };
+
+    fn compile(pattern: &str) -> u64 {
+        let mut handle = 0u64;
+        assert_eq!(
+            jerboa_regex_compile(pattern.as_ptr(), pattern.len(), &mut handle),
+            0
+        );
+        handle
+    }
+
+    #[test]
+    fn is_match_rejects_null_text_with_nonzero_length() {
+        let handle = compile("a+");
+
+        assert_eq!(jerboa_regex_is_match(handle, std::ptr::null(), 1), -1);
+        assert_eq!(jerboa_regex_free(handle), 0);
+    }
+
+    #[test]
+    fn find_rejects_null_text_with_nonzero_length() {
+        let handle = compile("a+");
+        let mut start = 0usize;
+        let mut end = 0usize;
+
+        assert_eq!(
+            jerboa_regex_find(handle, std::ptr::null(), 1, &mut start, &mut end),
+            -1
+        );
+        assert_eq!(jerboa_regex_free(handle), 0);
+    }
+
+    #[test]
+    fn replace_rejects_null_replacement_with_nonzero_length() {
+        let handle = compile("a+");
+        let text = b"aaa";
+        let mut output = [0u8; 8];
+        let mut output_len = 0usize;
+
+        assert_eq!(
+            jerboa_regex_replace_all(
+                handle,
+                text.as_ptr(),
+                text.len(),
+                std::ptr::null(),
+                1,
+                output.as_mut_ptr(),
+                output.len(),
+                &mut output_len,
+            ),
+            -1
+        );
+        assert_eq!(jerboa_regex_free(handle), 0);
+    }
+}