security: harden rustls ffi buffers

ober

3bb53cf4c4dc28ef5af8116705337a26cbba89dc

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index 23a936b..0d6ca56 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -31,7 +31,7 @@ Latest summary from `tools/ffi-audit-report.ss`:
     (foreign-callable 7)
     (pointer-sites 287)
     (width-sensitive-sites 145)
-    (blocking-candidates 51)
+    (blocking-candidates 57)
     (blocking-without-collect-safe 0)
     (hazard-site-count 340)
     (blocking-review-sites 0)
@@ -202,6 +202,11 @@ lengths before copying across the Scheme/native boundary, preserves retry and
 deadline behavior without exposing movable Scheme bytevectors to blocking FFI,
 and documents the hardcoded loader-candidate, pointer-return, and width
 invariants needed for a clean focused scanner result.
+The Scheme `(std net tls-rustls)` wrapper now uses a shared `dynamic-wind`
+foreign-buffer helper for rustls reads and writes, bounds foreign-to-Scheme and
+Scheme-to-foreign byte copies, documents zero-handle checks for Rust TLS handle
+returns, and carries width/status invariants for timeout, nonblocking, and fd
+helpers.
 `secure_fs.rs` now documents
 descriptor-relative `openat`/`mkdirat`, fd
 duplication/ownership transfer, stat buffers, directory stream lifecycle,
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 496917f..70e8cd4 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -742,6 +742,11 @@ the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit
   before copying across the Scheme/native boundary, preserves retry and
   deadline behavior without exposing movable Scheme bytevectors to blocking
   FFI, and documents loader-candidate, pointer-return, and width invariants.
+  The Scheme `(std net tls-rustls)` wrapper now uses a shared `dynamic-wind`
+  foreign-buffer helper for rustls reads and writes, bounds foreign-to-Scheme
+  and Scheme-to-foreign byte copies, documents zero-handle checks for Rust TLS
+  handle returns, and carries width/status invariants for timeout, nonblocking,
+  and fd helpers.
   `wasm_sm.rs` now compiles under `unsafe_op_in_unsafe_fn` for the
   SpiderMonkey feature, bounds C ABI buffers, validates host-memory offsets,
   converts poisoned handle-store locks into normal FFI errors, and documents
diff --git a/docs/security-reference.md b/docs/security-reference.md
index e864b29..7d36a72 100644
--- a/docs/security-reference.md
+++ b/docs/security-reference.md
@@ -718,7 +718,7 @@ Callers should not treat `allow-degraded?` as successful sandbox installation.
 
 ## 7. Parser Hardening
 
-Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers.ss`). Phase 5 (FFI audit) has a reproducible inventory in [ffi-audit.md](ffi-audit.md) via `make ffi-audit-report`; native unsafe review and selected Scheme FFI slices such as Seatbelt, sandbox compatibility, `exec-id`, `regex-native`, the unified regex facade, `compress native-rust`, `crypto native-rust`, and `net tls` are remediated, while broad Scheme per-binding remediation remains open.
+Phases 1-4 are implemented and tested (42 tests in `tests/test-security2-parsers.ss`). Phase 5 (FFI audit) has a reproducible inventory in [ffi-audit.md](ffi-audit.md) via `make ffi-audit-report`; native unsafe review and selected Scheme FFI slices such as Seatbelt, sandbox compatibility, `exec-id`, `regex-native`, the unified regex facade, `compress native-rust`, `crypto native-rust`, `net tls`, and `net tls-rustls` are remediated, while broad Scheme per-binding remediation remains open.
 
 ### Depth limits
 
@@ -1000,7 +1000,7 @@ These are known gaps documented as current limitations, not implementation promi
   `(std crypto native-rust)` / `(std crypto password)`. The high-level HMAC,
   AEAD, KDF, and password-hashing compatibility APIs now route through the Rust
   native crypto boundary.
-- **FFI audit (Phase 5 of parser hardening) is in progress.** `make ffi-audit-report` inventories Scheme FFI sites, Rust C ABI exports, pointer/width-sensitive bindings, blocking candidates, and Rust unsafe sites. Native unsafe review and selected Scheme FFI slices, including the regex, compression, crypto, and OpenSSL TLS wrappers, are remediated; the remaining Scheme per-binding null-return, bounds, ownership, and GC-safety review is tracked in [ffi-audit.md](ffi-audit.md).
+- **FFI audit (Phase 5 of parser hardening) is in progress.** `make ffi-audit-report` inventories Scheme FFI sites, Rust C ABI exports, pointer/width-sensitive bindings, blocking candidates, and Rust unsafe sites. Native unsafe review and selected Scheme FFI slices, including the regex, compression, crypto, OpenSSL TLS, and rustls TLS wrappers, are remediated; the remaining Scheme per-binding null-return, bounds, ownership, and GC-safety review is tracked in [ffi-audit.md](ffi-audit.md).
 - **No red team evaluation.** No independent adversarial testing has been performed.
 - **Secure memory still exposes a raw region escape hatch.** The high-level
   `secure-bytevector` API is bounds-checked and integrated with
diff --git a/docs/status.md b/docs/status.md
index 4ae38f5..e38a5a1 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`, secure filesystem capability boundary in `secure_fs.rs`, TLS native ABI in `tls.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, SQLite native ABI, the Scheme Seatbelt `sandbox_init` error-buffer paths, the retired `(std os sandbox)` compatibility surface, `(std os exec-id)` realpath/hash helpers, and the Scheme `(std regex-native)`, `(std regex)`, `(std compress native-rust)`, `(std crypto native-rust)`, and `(std net tls)` out-parameter or foreign-buffer wrappers now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites, 0 Scheme blocking bindings missing `__collect_safe`, and 340 remaining provisional Scheme safety-review sites. | Finish per-binding Scheme FFI review and targeted scanner rules for the remaining provisional verdicts. |
+| 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`, secure filesystem capability boundary in `secure_fs.rs`, TLS native ABI in `tls.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, SQLite native ABI, the Scheme Seatbelt `sandbox_init` error-buffer paths, the retired `(std os sandbox)` compatibility surface, `(std os exec-id)` realpath/hash helpers, and the Scheme `(std regex-native)`, `(std regex)`, `(std compress native-rust)`, `(std crypto native-rust)`, `(std net tls)`, and `(std net tls-rustls)` out-parameter or foreign-buffer wrappers now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites, 0 Scheme blocking bindings missing `__collect_safe`, and 340 remaining provisional Scheme safety-review sites. | Finish per-binding Scheme FFI review and targeted scanner rules for the remaining provisional verdicts. |
 | 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, Linux Landlock filesystem/TCP-connect setup for requested axes, macOS Seatbelt deny-default path/exec/no-network setup for supported axes, standard worker-eval Capsicum entry on FreeBSD, explicit sandbox-axis refusal, egress proxy env wiring, and platform CI smoke for Linux/macOS/FreeBSD sandbox paths. | Keep Linux/macOS/FreeBSD parity tests current; finish arbitrary-command/proxy-aware Capsicum worker paths. |
 | Fuzzing | `tests/fuzz/corpus/` has 15 checked-in seed inputs, `tests/fuzz/regression/` has 11 crash/rejection regressions, `make fuzz-smoke` runs the deterministic regression gate first, and GitHub CI runs smoke fuzzing normally plus deep fuzzing on scheduled daily runs. | Keep adding minimized corpus and regression inputs for every parser/security bug found. |
diff --git a/lib/std/net/tls-rustls.ss b/lib/std/net/tls-rustls.ss
index fde16f0..c49bea6 100644
--- a/lib/std/net/tls-rustls.ss
+++ b/lib/std/net/tls-rustls.ss
@@ -64,15 +64,15 @@
   ;; Standard TLS server context (no client auth)
   (def c-tls-server-new
     (try
-      (foreign-procedure "jerboa_tls_server_new"
-        (u8* unsigned-64 u8* unsigned-64) unsigned-64)
+      (foreign-procedure "jerboa_tls_server_new" ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- Rust returns an opaque numeric handle and wrapper rejects zero before use
+        (u8* unsigned-64 u8* unsigned-64) unsigned-64) ; jerboa-security: suppress u8star-ffi-with-foreign-alloc -- certificate/key path u8* args receive Scheme bytevectors, not foreign-allocated buffers
       (catch (e) (tls-native-unavailable 'rustls-server-ctx-new))))
 
   ;; mTLS server context (requires client certs signed by given CA)
   (def c-tls-server-new-mtls
     (try
-      (foreign-procedure "jerboa_tls_server_new_mtls"
-        (u8* unsigned-64 u8* unsigned-64 u8* unsigned-64) unsigned-64)
+      (foreign-procedure "jerboa_tls_server_new_mtls" ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- Rust returns an opaque numeric handle and wrapper rejects zero before use
+        (u8* unsigned-64 u8* unsigned-64 u8* unsigned-64) unsigned-64) ; jerboa-security: suppress u8star-ffi-with-foreign-alloc -- certificate/key/CA path u8* args receive Scheme bytevectors, not foreign-allocated buffers
       (catch (e) (tls-native-unavailable 'rustls-server-ctx-new-mtls))))
 
   (def c-tls-server-free
@@ -83,19 +83,19 @@
   ;; Accept a TLS connection on an already-accepted TCP fd
   (def c-tls-accept
     (try
-      (foreign-procedure __collect_safe "jerboa_tls_accept" (unsigned-64 int) unsigned-64)
+      (foreign-procedure __collect_safe "jerboa_tls_accept" (unsigned-64 int) unsigned-64) ; jerboa-security: suppress collect-safe-unpinned-bytevector -- accept receives only a Rust handle and fd, with no movable Scheme bytevector/string arguments
       (catch (e) (tls-native-unavailable 'rustls-accept))))
 
   ;; Standard TLS client connect (system CA trust)
   (def c-tls-connect
     (try
-      (foreign-procedure __collect_safe "jerboa_tls_connect"
+      (foreign-procedure __collect_safe "jerboa_tls_connect" ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- Rust returns an opaque numeric connection handle and wrapper rejects zero before use
         (u8* unsigned-64 unsigned-16) unsigned-64)
       (catch (e) (tls-native-unavailable 'rustls-connect))))
 
   (def c-tls-connect-timeout
     (try
-      (foreign-procedure __collect_safe "jerboa_tls_connect_timeout"
+      (foreign-procedure __collect_safe "jerboa_tls_connect_timeout" ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- Rust returns an opaque numeric connection handle and wrapper rejects zero before use
         (u8* unsigned-64 unsigned-16 unsigned-64) unsigned-64)
       (catch (e) (tls-native-unavailable 'rustls-connect))))
 
@@ -104,21 +104,21 @@
   ;; client boundary used by policy-enforcing callers.
   (def c-tls-connect-address-timeout
     (try
-      (foreign-procedure __collect_safe "jerboa_tls_connect_addr_timeout"
+      (foreign-procedure __collect_safe "jerboa_tls_connect_addr_timeout" ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- Rust returns an opaque numeric connection handle and wrapper rejects zero before use
         (u8* unsigned-64 u8* unsigned-64 unsigned-16 unsigned-64) unsigned-64)
       (catch (e) (tls-native-unavailable 'rustls-connect-address))))
 
   ;; TLS client with certificate pinning (no CA verification)
   (def c-tls-connect-pinned
     (try
-      (foreign-procedure __collect_safe "jerboa_tls_connect_pinned"
+      (foreign-procedure __collect_safe "jerboa_tls_connect_pinned" ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- Rust returns an opaque numeric connection handle and wrapper rejects zero before use
         (u8* unsigned-64 unsigned-16 u8* unsigned-64) unsigned-64)
       (catch (e) (tls-native-unavailable 'rustls-connect-pinned))))
 
   ;; mTLS client connect (presents client cert, verifies server against CA)
   (def c-tls-connect-mtls
     (try
-      (foreign-procedure __collect_safe "jerboa_tls_connect_mtls"
+      (foreign-procedure __collect_safe "jerboa_tls_connect_mtls" ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- Rust returns an opaque numeric connection handle and wrapper rejects zero before use
         (u8* unsigned-64 unsigned-16
          u8* unsigned-64 u8* unsigned-64 u8* unsigned-64) unsigned-64)
       (catch (e) (tls-native-unavailable 'rustls-connect-mtls))))
@@ -126,7 +126,7 @@
   ;; I/O
   (def c-tls-read
     (try
-      (foreign-procedure __collect_safe "jerboa_tls_read" (unsigned-64 void* unsigned-64) int)
+      (foreign-procedure __collect_safe "jerboa_tls_read" (unsigned-64 void* unsigned-64) int) ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- void* is a scoped foreign buffer allocated and checked by call-with-rustls-io-buffer
       (catch (e) (tls-native-unavailable 'rustls-read))))
 
   (def c-tls-write
@@ -147,18 +147,18 @@
   ;; Utilities
   (def c-tls-set-nonblock
     (try
-      (foreign-procedure "jerboa_tls_set_nonblock" (unsigned-64 int) int)
+      (foreign-procedure "jerboa_tls_set_nonblock" (unsigned-64 int) int) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI uses int for a boolean flag and status result; wrapper passes 0/1 only
       (catch (e) (tls-native-unavailable 'rustls-set-nonblock))))
 
   (def c-tls-set-timeout
     (try
-      (foreign-procedure "jerboa_tls_set_timeout"
+      (foreign-procedure "jerboa_tls_set_timeout" ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI uses fixed-width millisecond timeout fields and returns a small status code
         (unsigned-64 unsigned-64 unsigned-64) int)
       (catch (e) (tls-native-unavailable 'rustls-set-timeout))))
 
   (def c-tls-get-fd
     (try
-      (foreign-procedure "jerboa_tls_get_fd" (unsigned-64) int)
+      (foreign-procedure "jerboa_tls_get_fd" (unsigned-64) int) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Rust ABI returns an OS fd/status int for an existing unsigned-64 TLS handle
       (catch (e) (tls-native-unavailable 'rustls-get-fd))))
 
   ;; Error reporting
@@ -172,7 +172,7 @@
       (let ([len (c-last-error buf 512)])
         (if (> len 0)
           (utf8->string (let ([out (make-bytevector (min len 511))])
-            (bytevector-copy! buf 0 out 0 (min len 511)) out))
+            (bytevector-copy! buf 0 out 0 (min len 511)) out)) ; jerboa-security: suppress ffi-pointer-arithmetic-without-bounds -- len is bounded by the fixed 512-byte error buffer before copying into the freshly allocated output bytevector
           "unknown error"))))
 
   ;; ========== Server context ==========
@@ -317,6 +317,45 @@
 
   ;; ========== I/O ==========
 
+  (def (non-null-ptr? ptr)
+    (and ptr (not (= ptr 0))))
+
+  (def (call-with-rustls-io-buffer who size k)
+    (unless (and (integer? size) (exact? size) (>= size 0))
+      (error who "invalid rustls foreign buffer size" size))
+    (let ([ptr #f])
+      (dynamic-wind
+        (lambda ()
+          (set! ptr (foreign-alloc (max 1 size))) ; jerboa-security: suppress foreign-alloc-no-free -- ptr is freed by the dynamic-wind after thunk when the Rust TLS call returns or raises
+          (unless (non-null-ptr? ptr)
+            (error who "rustls foreign buffer allocation failed" size)))
+        (lambda ()
+          (k ptr))
+        (lambda ()
+          (when (non-null-ptr? ptr)
+            (foreign-free ptr)
+            (set! ptr #f))))))
+
+  (def (copy-rustls-output! who ptr out len)
+    (unless (and (integer? len) (exact? len) (>= len 0)
+                 (<= len (bytevector-length out)))
+      (error who "rustls output length exceeds bytevector capacity"
+             len (bytevector-length out)))
+    (let loop ([i 0])
+      (when (< i len)
+        (bytevector-u8-set! out i (foreign-ref 'unsigned-8 ptr i)) ; jerboa-security: suppress ffi-pointer-arithmetic-without-bounds -- i is loop-bounded by len after len is checked against the target bytevector capacity
+        (loop (+ i 1)))))
+
+  (def (copy-rustls-input! who bv ptr len)
+    (unless (and (integer? len) (exact? len) (>= len 0)
+                 (<= len (bytevector-length bv)))
+      (error who "rustls input length exceeds bytevector capacity"
+             len (bytevector-length bv)))
+    (let loop ([i 0])
+      (when (< i len)
+        (foreign-set! 'unsigned-8 ptr i (bytevector-u8-ref bv i)) ; jerboa-security: suppress ffi-pointer-arithmetic-without-bounds -- i is loop-bounded by len after len is checked against the source bytevector capacity
+        (loop (+ i 1)))))
+
   (def (rustls-read handle buf max-len)
     ;; Read up to max-len bytes. Returns bytes read, 0 on EOF, -1 on error.
     (unless (bytevector? buf)
@@ -325,17 +364,13 @@
                  (<= max-len (bytevector-length buf)))
       (error 'rustls-read "read length exceeds bytevector capacity"
              max-len (bytevector-length buf)))
-    (let* ([limit max-len]
-           [tmp (foreign-alloc (max 1 limit))])
-      (try
-        (let ([n (c-tls-read handle tmp limit)])
-          (when (> n 0)
-            (let loop ([i 0])
-              (when (< i n)
-                (bytevector-u8-set! buf i (foreign-ref 'unsigned-8 tmp i))
-                (loop (+ i 1)))))
-          n)
-        (finally (foreign-free tmp)))))
+    (let ([limit max-len])
+      (call-with-rustls-io-buffer 'rustls-read limit
+        (lambda (tmp)
+          (let ([n (c-tls-read handle tmp limit)])
+            (when (> n 0)
+              (copy-rustls-output! 'rustls-read tmp buf n))
+            n)))))
 
   (def (rustls-write handle buf len)
     ;; Write len bytes from buf. Returns bytes written or -1 on error.
@@ -345,17 +380,13 @@
                  (<= len (bytevector-length buf)))
       (error 'rustls-write "write length exceeds bytevector capacity"
              len (bytevector-length buf)))
-    (let* ([limit len]
-           [tmp (foreign-alloc (max 1 limit))])
-      (let loop ([i 0])
-        (when (< i limit)
-          (foreign-set! 'unsigned-8 tmp i (bytevector-u8-ref buf i))
-          (loop (+ i 1))))
-      (let ([n (try
-                 (c-tls-write handle tmp limit)
-                 (finally (foreign-free tmp)))])
-      (when (> n 0) (c-tls-flush handle))
-      n)))
+    (let ([limit len])
+      (call-with-rustls-io-buffer 'rustls-write limit
+        (lambda (tmp)
+          (copy-rustls-input! 'rustls-write buf tmp limit)
+          (let ([n (c-tls-write handle tmp limit)])
+            (when (> n 0) (c-tls-flush handle))
+            n)))))
 
   (def (rustls-flush handle)
     (c-tls-flush handle))