Binary-safe substitution result (preserve embedded NUL)

ober

79132fd5551061fc10cf7a2483990cb51fd5b0f7

diff --git a/jerboa_pcre2_shim.c b/jerboa_pcre2_shim.c
index 868b6f3..d2058ef 100644
--- a/jerboa_pcre2_shim.c
+++ b/jerboa_pcre2_shim.c
@@ -270,6 +270,17 @@ size_t jerboa_pcre2_substitute_result_length(
     return result ? result->length : 0;
 }
 
+int jerboa_pcre2_substitute_result_copy(
+    const jerboa_pcre2_substitute_result_t *result,
+    char *buffer, size_t buffer_length)
+{
+    if (!result || !result->data) return PCRE2_ERROR_NULL;
+    if (!buffer && result->length > 0) return PCRE2_ERROR_NULL;
+    if (buffer_length < result->length) return PCRE2_ERROR_NOMEMORY;
+    memcpy(buffer, result->data, result->length);
+    return 0;
+}
+
 int jerboa_pcre2_substitute_free(jerboa_pcre2_substitute_result_t *result)
 {
     if (result) {
diff --git a/src/jerboa-pcre2/ffi.ss b/src/jerboa-pcre2/ffi.ss
index 9c87ecc..f8e3563 100644
--- a/src/jerboa-pcre2/ffi.ss
+++ b/src/jerboa-pcre2/ffi.ss
@@ -40,6 +40,7 @@
     ffi-pcre2-substitute-code
     ffi-pcre2-substitute-result
     ffi-pcre2-substitute-result-length
+    ffi-pcre2-substitute-result-bytes
     ffi-pcre2-substitute-free
 
     ;; Named groups & pattern info
@@ -172,6 +173,7 @@
   (def c-substitute-code #f)
   (def c-substitute-result #f)
   (def c-substitute-result-length #f)
+  (def c-substitute-result-copy #f)
   (def c-substitute-free #f)
   (def c-substring-number-from-name #f)
   (def c-capture-count #f)
@@ -224,6 +226,9 @@
             (c-lambda (void*) string "jerboa_pcre2_substitute_result"))
       (set! c-substitute-result-length
             (c-lambda (void*) size_t "jerboa_pcre2_substitute_result_length"))
+      (set! c-substitute-result-copy
+            (foreign-procedure __collect_safe "jerboa_pcre2_substitute_result_copy"
+              (void* u8* size_t) integer-32))
       (set! c-substitute-free
             (c-lambda (void*) int "jerboa_pcre2_substitute_free"))
       (set! c-substring-number-from-name
@@ -402,6 +407,18 @@
     (need-native 'ffi-pcre2-substitute-result-length)
     (c-substitute-result-length (ensure-match-data 'ffi-pcre2-substitute-result-length result)))
 
+  ;; Binary-safe result extraction: copy exactly result-length bytes (which may
+  ;; contain embedded NUL) into a fresh bytevector instead of relying on the
+  ;; NUL-terminated string return, which truncates at the first NUL byte.
+  (def (ffi-pcre2-substitute-result-bytes result)
+    (let* ([len (ffi-pcre2-substitute-result-length result)]
+           [bv  (make-bytevector len 0)]
+           [rc  (c-substitute-result-copy result bv len)])
+      (unless (zero? rc)
+        (error 'ffi-pcre2-substitute-result-bytes
+               "PCRE2 substitute result copy failed" rc))
+      bv))
+
   (def (ffi-pcre2-substitute-free result)
     (need-native 'ffi-pcre2-substitute-free)
     (c-substitute-free (ensure-match-data 'ffi-pcre2-substitute-free result))
diff --git a/src/jerboa-pcre2/pcre2.ss b/src/jerboa-pcre2/pcre2.ss
index fa2492f..4245006 100644
--- a/src/jerboa-pcre2/pcre2.ss
+++ b/src/jerboa-pcre2/pcre2.ss
@@ -504,7 +504,9 @@
                     (lambda ()
                       (let ([rc (ffi-pcre2-substitute-code result)])
                         (cond
-                          [(>= rc 0) (ffi-pcre2-substitute-result result)]
+                          [(>= rc 0)
+                           (utf8->string
+                             (ffi-pcre2-substitute-result-bytes result))]
                           [(= rc PCRE2_ERROR_NOMATCH) subject]
                           [else (error who
                                        (ffi-pcre2-get-error-message rc) rc)])))
diff --git a/tests/pcre2-test.ss b/tests/pcre2-test.ss
index 8498a66..0e3cd18 100644
--- a/tests/pcre2-test.ss
+++ b/tests/pcre2-test.ss
@@ -231,7 +231,18 @@
       (pcre2-replace-all rx (make-string 1000 #\a) (make-string 100 #\x)))
     (check (string-length result) => 100000)
     (check (string-ref result 0) => #\x)
-    (check (string-ref result 99999) => #\x)))
+    (check (string-ref result 99999) => #\x))
+
+  (test-case "substitution preserves embedded NUL bytes (binary-safe)"
+    (define repl (string #\Y (integer->char 0) #\Z))
+    (define result (pcre2-replace-all "X" "aXbXc" repl))
+    (check (string-length result) => 9)
+    (check (string-ref result 0) => #\a)
+    (check (string-ref result 1) => #\Y)
+    (check (char->integer (string-ref result 2)) => 0)
+    (check (string-ref result 3) => #\Z)
+    (check (string-ref result 4) => #\b)
+    (check (string-ref result 8) => #\c)))
 
 ;; -----------------------------------------------------------------
 (test-group "concurrency and lifecycle"