security: harden http ffi boundary

ober

8e262bf0622592aeaeb0947959b3f22bdbd18bf1

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index 464b383..5b1dc00 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 438)
-    (unsafe-sites-with-nearby-safety-comment 123)
-    (unsafe-sites-without-nearby-safety-comment 315)
-    (unsafe-comment-review-sites 315)
+    (unsafe-sites 439)
+    (unsafe-sites-with-nearby-safety-comment 125)
+    (unsafe-sites-without-nearby-safety-comment 314)
+    (unsafe-comment-review-sites 314)
     (export-review-sites 196))
   (vendor
     (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
@@ -87,8 +87,9 @@ signature output, shared-secret output, and HKDF slice/output invariants; the
 HKDF wrapper now also rejects null optional buffers with nonzero lengths. The
 compression FFI wrappers now document input/output/result-slot invariants and
 reject oversized decompression caps before `u64` overflow. The generated
-inventory now reports 123 annotated native unsafe sites and 315 remaining
-unsafe review sites.
+inventory now reports 125 annotated native unsafe sites and 314 remaining
+unsafe review sites. `http_parse.rs` now documents parser output/writev C ABI
+contracts and rejects a null second `writev` buffer with a nonzero length.
 
 Remaining work before closing K3-P1-01:
 
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index 111db80..d22aa0e 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -630,10 +630,11 @@ not started."
   now document the key/signature/shared-secret/HKDF FFI buffer invariants, and
   HKDF rejects null optional buffers with nonzero lengths. `compress.rs` now
   documents compression/decompression FFI buffer invariants and rejects
-  oversized decompression caps before `u64` overflow. The generated report now
-  shows 123 annotated native unsafe sites and 315 remaining unsafe review sites.
-  Remaining work: continue unsafe invariant comments across the rest of
-  `jerboa-native-rs`.
+  oversized decompression caps before `u64` overflow. `http_parse.rs` now
+  documents parser output/writev C ABI contracts and rejects a null second
+  `writev` buffer with a nonzero length. The generated report now shows 125
+  annotated native unsafe sites and 314 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.
@@ -1121,7 +1122,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 | 315 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
+| Un-annotated Rust `unsafe` blocks | 314 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 6c15e47..31619e6 100644
--- a/docs/reviews/2026-07-27-native-export-review.sexp
+++ b/docs/reviews/2026-07-27-native-export-review.sexp
@@ -65,7 +65,7 @@
     (export (symbol "jerboa_gzip") (file "jerboa-native-rs/src/compress.rs") (line 116) (scheme-callers 1 (caller (file "lib/std/compress/native-rust.ss") (line 36))))
     (export (symbol "jerboa_hmac_sha256") (file "jerboa-native-rs/src/crypto.rs") (line 206) (scheme-callers 2 (caller (file "lib/std/crypto/native-rust.ss") (line 109)) (caller (file "lib/std/crypto/native-rust.ss") (line 120))))
     (export (symbol "jerboa_hmac_sha256_verify") (file "jerboa-native-rs/src/crypto.rs") (line 245) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 120))))
-    (export (symbol "jerboa_http_parse") (file "jerboa-native-rs/src/http_parse.rs") (line 25) (scheme-callers 4 (caller (file "lib/std/net/fiber-httpd.ss") (line 78)) (caller (file "lib/std/net/fiber-httpd.ss") (line 81)) (caller (file "lib/std/net/httpsd.ss") (line 28)) (caller (file "lib/std/net/httpsd.ss") (line 71))))
+    (export (symbol "jerboa_http_parse") (file "jerboa-native-rs/src/http_parse.rs") (line 30) (scheme-callers 4 (caller (file "lib/std/net/fiber-httpd.ss") (line 78)) (caller (file "lib/std/net/fiber-httpd.ss") (line 81)) (caller (file "lib/std/net/httpsd.ss") (line 28)) (caller (file "lib/std/net/httpsd.ss") (line 71))))
     (export (symbol "jerboa_inflate") (file "jerboa-native-rs/src/compress.rs") (line 55) (scheme-callers 1 (caller (file "lib/std/compress/native-rust.ss") (line 33))))
     (export (symbol "jerboa_inotify_add_watch") (file "jerboa-native-rs/src/inotify_native.rs") (line 22) (scheme-callers 1 (caller (file "lib/std/os/inotify-native.ss") (line 65))))
     (export (symbol "jerboa_inotify_close") (file "jerboa-native-rs/src/inotify_native.rs") (line 158) (scheme-callers 1 (caller (file "lib/std/os/inotify-native.ss") (line 71))))
@@ -191,7 +191,7 @@
     (export (symbol "jerboa_wasm_memory_write") (file "jerboa-native-rs/src/wasm.rs") (line 511) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 106))))
     (export (symbol "jerboa_wasm_module_free") (file "jerboa-native-rs/src/wasm.rs") (line 142) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 76))))
     (export (symbol "jerboa_wasm_module_new") (file "jerboa-native-rs/src/wasm.rs") (line 104) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 70))))
-    (export (symbol "jerboa_writev2") (file "jerboa-native-rs/src/http_parse.rs") (line 99) (scheme-callers 1 (caller (file "lib/std/net/io.ss") (line 81))))
+    (export (symbol "jerboa_writev2") (file "jerboa-native-rs/src/http_parse.rs") (line 110) (scheme-callers 1 (caller (file "lib/std/net/io.ss") (line 81))))
     (export (symbol "jerboa_x509_cert_fingerprint") (file "jerboa-native-rs/src/x509.rs") (line 562) (scheme-callers 1 (caller (file "lib/std/crypto/x509.ss") (line 31))))
     (export (symbol "jerboa_x509_generate_self_signed") (file "jerboa-native-rs/src/x509.rs") (line 124) (scheme-callers 1 (caller (file "lib/std/crypto/x509.ss") (line 27))))
   )
diff --git a/docs/status.md b/docs/status.md
index 6d1e4e0..fca592f 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, and compression buffers now have nearby `SAFETY:` comments, reducing the generated unannotated unsafe-site count to 315. | 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, and HTTP parse/writev boundary now have nearby `SAFETY:` comments, with the generated unannotated unsafe-site count at 314. | 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/http_parse.rs b/jerboa-native-rs/src/http_parse.rs
index 18bb065..29ad614 100644
--- a/jerboa-native-rs/src/http_parse.rs
+++ b/jerboa-native-rs/src/http_parse.rs
@@ -22,14 +22,21 @@ const MAX_HEADERS: usize = 32;
 pub const PARSE_OUT_SIZE: usize = 14 + MAX_HEADERS * 8; // 270
 
 #[no_mangle]
+/// # Safety
+///
+/// `buf` must point to `buf_len` readable bytes, and `out` must point to a
+/// writable `PARSE_OUT_SIZE`-byte buffer. Both pointers must remain valid for
+/// the duration of the call.
 pub unsafe extern "C" fn jerboa_http_parse(buf: *const u8, buf_len: usize, out: *mut u8) -> i32 {
     if buf.is_null() || out.is_null() || buf_len == 0 {
         return -1;
     }
 
-    // SAFETY: null pointers are rejected above; the FFI caller must pass
-    // readable input bytes and a writable PARSE_OUT_SIZE output buffer.
+    // SAFETY: buf is non-null, buf_len > 0, and the C ABI contract requires a
+    // readable input buffer of this length.
     let data = unsafe { std::slice::from_raw_parts(buf, buf_len) };
+    // SAFETY: out is non-null and the C ABI contract requires a writable
+    // PARSE_OUT_SIZE-byte output buffer.
     let out_slice = unsafe { std::slice::from_raw_parts_mut(out, PARSE_OUT_SIZE) };
 
     let mut headers_storage = [httparse::EMPTY_HEADER; MAX_HEADERS];
@@ -96,6 +103,10 @@ pub unsafe extern "C" fn jerboa_http_parse(buf: *const u8, buf_len: usize, out: 
 // ---------------------------------------------------------------------------
 
 #[no_mangle]
+/// # Safety
+///
+/// `buf1` must point to `len1` readable bytes. If `len2 > 0`, `buf2` must point
+/// to `len2` readable bytes. The file descriptor must be valid for writing.
 pub unsafe extern "C" fn jerboa_writev2(
     fd: i32,
     buf1: *const u8,
@@ -106,6 +117,9 @@ pub unsafe extern "C" fn jerboa_writev2(
     if buf1.is_null() || len1 == 0 {
         return -1;
     }
+    if buf2.is_null() && len2 > 0 {
+        return -1;
+    }
     let use_two = !buf2.is_null() && len2 > 0;
     let iovs = [
         libc::iovec {
@@ -122,3 +136,17 @@ pub unsafe extern "C" fn jerboa_writev2(
     // non-null with len2 > 0; iovs points to count initialized entries.
     unsafe { libc::writev(fd, iovs.as_ptr(), count) }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::jerboa_writev2;
+
+    #[test]
+    fn writev2_rejects_null_second_buffer_with_nonzero_length() {
+        let header = b"HTTP/1.1 200 OK\r\n\r\n";
+        // SAFETY: buf1 points to the readable `header` bytes; the test asserts
+        // the wrapper rejects buf2 == NULL with len2 > 0 before touching fd.
+        let rc = unsafe { jerboa_writev2(-1, header.as_ptr(), header.len(), std::ptr::null(), 1) };
+        assert_eq!(rc, -1);
+    }
+}