security: harden compression ffi outputs

ober

403629af8aa38318c893a9fc1b1193becc9d3f6a

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index 743a3bd..41956a9 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -183,7 +183,11 @@ to 256 MiB before allocating a digest input bytevector. The Scheme
 `(std regex-native)` wrapper now validates bytevector slices before copying,
 uses scoped foreign `void*` slots for native u64 out-parameters, checks those
 slots before reading, verifies replacement output lengths before slicing, and
-documents the status/count integer-width ABI. `secure_fs.rs` now documents
+documents the status/count integer-width ABI. The Scheme `(std compress
+native-rust)` wrapper now declares native result-length out slots as `void*`,
+allocates/frees scoped `size_t` slots with `dynamic-wind`, checks native output
+lengths before slicing, and has focused round-trip plus decompression-cap
+regressions. `secure_fs.rs` now documents
 descriptor-relative `openat`/`mkdirat`, fd
 duplication/ownership transfer, stat buffers, directory stream lifecycle,
 caller output slots, atomic output publication, and test-owned fd conversion
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index f410a5a..5b7641c 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -725,7 +725,11 @@ the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit
   executable hashing reads into memory. The Scheme `(std regex-native)` wrapper
   now uses scoped foreign `void*` out-slots for native u64 result buffers,
   validates bytevector slices before copying, checks replacement output lengths
-  before slicing, and documents the status/count integer-width ABI.
+  before slicing, and documents the status/count integer-width ABI. The Scheme
+  `(std compress native-rust)` wrapper now uses scoped foreign `void*`/`size_t`
+  result-length slots, validates bytevector slices, checks native output
+  lengths before slicing, and has focused round-trip plus decompression-cap
+  regressions.
   `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 4a19ba1..0bbf59d 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`, and `regex-native` 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`, and `compress native-rust` 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 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 and compression 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 d6013fd..b41e9c2 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)` out-parameter wrapper now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites and 0 Scheme blocking bindings missing `__collect_safe`. | 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)` plus `(std compress native-rust)` out-parameter wrappers now have nearby `SAFETY:` comments or equivalent checked FFI/scanner invariants. Generated counts are at 0 unannotated native unsafe sites and 0 Scheme blocking bindings missing `__collect_safe`. | 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/compress/native-rust.ss b/lib/std/compress/native-rust.ss
index bbbc4b1..7a7f7df 100644
--- a/lib/std/compress/native-rust.ss
+++ b/lib/std/compress/native-rust.ss
@@ -7,7 +7,7 @@
 (library (std compress native-rust)
   (export
     rust-deflate rust-inflate
-    rust-gzip rust-gunzip
+    rust-gzip rust-gunzip ; jerboa-security: suppress archive-listing-line-based-drops-control-names -- exported compression procedure names only; no archive listing parser here
     *rust-max-decompressed-size*)
 
   (import (chezscheme)
@@ -19,48 +19,87 @@
 
   (def *rust-max-decompressed-size* (make-parameter (* 100 1024 1024)))  ;; 100MB
 
-  (def (bv-sub bv start len)
-    (let ([out (make-bytevector len)])
-      (bytevector-copy! bv start out 0 len)
-      out))
+  (def (checked-bv-sub who bv start len)
+    (unless (bytevector? bv)
+      (error who "expected bytevector" bv))
+    (unless (and (fixnum? start) (>= start 0))
+      (error who "invalid bytevector start" start))
+    (unless (and (fixnum? len) (>= len 0))
+      (error who "invalid bytevector length" len))
+    (let ([end (+ start len)])
+      (unless (<= end (bytevector-length bv))
+        (error who "bytevector slice out of bounds" start len (bytevector-length bv)))
+      (let ([out (make-bytevector len)])
+        (bytevector-copy! bv start out 0 len) ; jerboa-security: suppress ffi-pointer-arithmetic-without-bounds -- checked-bv-sub validates start/len/end before copying
+        out)))
+
+  (def (non-null-ptr? ptr)
+    (and ptr (not (= ptr 0))))
+
+  (def (call-with-size-t-slot k)
+    (let ([slot #f])
+      (dynamic-wind
+        (lambda ()
+          (set! slot (foreign-alloc (foreign-sizeof 'size_t))) ; jerboa-security: suppress u8star-ffi-with-foreign-alloc -- this scoped allocator is used only for void* size_t out slots; u8* inputs/outputs receive Scheme bytevectors
+          (foreign-set! 'size_t slot 0 0))
+        (lambda ()
+          (and (non-null-ptr? slot) (k slot)))
+        (lambda ()
+          (when (non-null-ptr? slot)
+            (foreign-free slot)
+            (set! slot #f))))))
+
+  (def (foreign-size-t-ref/checked who ptr)
+    (unless (non-null-ptr? ptr)
+      (error who "native length pointer is NULL"))
+    (foreign-ref 'size_t ptr 0)) ; jerboa-security: suppress ffi-pointer-return-without-null-guard -- helper rejects null pointers and reads offset 0 from a scoped size_t out-parameter buffer
 
   ;; --- Deflate/Inflate ---
 
   (def c-jerboa-deflate
-    (foreign-procedure "jerboa_deflate" (u8* size_t u8* size_t u8*) int))
+    ;; status is int; output_len is Rust usize / C size_t.
+    (foreign-procedure "jerboa_deflate" (u8* size_t u8* size_t void*) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Scheme size_t matches native usize buffer lengths; status is deliberately int
 
   (def c-jerboa-inflate
-    (foreign-procedure "jerboa_inflate" (u8* size_t u8* size_t u8*) int))
+    ;; status is int; output_len is Rust usize / C size_t.
+    (foreign-procedure "jerboa_inflate" (u8* size_t u8* size_t void*) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Scheme size_t matches native usize buffer lengths; status is deliberately int
 
   (def c-jerboa-gzip
-    (foreign-procedure "jerboa_gzip" (u8* size_t u8* size_t u8*) int))
+    ;; status is int; output_len is Rust usize / C size_t.
+    (foreign-procedure "jerboa_gzip" (u8* size_t u8* size_t void*) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Scheme size_t matches native usize buffer lengths; status is deliberately int
 
   (def c-jerboa-gunzip
-    (foreign-procedure "jerboa_gunzip" (u8* size_t u8* size_t u8*) int))
+    ;; status is int; output_len is Rust usize / C size_t.
+    (foreign-procedure "jerboa_gunzip" (u8* size_t u8* size_t void*) int)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- Scheme size_t matches native usize buffer lengths; status is deliberately int
 
   (def c-jerboa-last-error
-    (foreign-procedure "jerboa_last_error" (u8* size_t) size_t))
+    (foreign-procedure "jerboa_last_error" (u8* size_t) size_t)) ; jerboa-security: suppress ffi-integer-width-ambiguous -- returns copied byte count bounded by caller size_t buffer length
 
   (def (get-last-error)
     (let ([buf (make-bytevector 512)])
       (let ([len (c-jerboa-last-error buf 512)])
         (if (> len 0)
-          (utf8->string (bv-sub buf 0 (min len 511)))
+          (utf8->string (checked-bv-sub 'get-last-error buf 0 (min len 511)))
           "unknown error"))))
 
   (def (compress-op c-func name input output-max)
-    (let ([out (make-bytevector output-max)]
-          [len-buf (make-bytevector 8)])
-      (let ([rc (c-func input (bytevector-length input) out output-max len-buf)])
-        (when (< rc 0)
-          (error name (get-last-error)))
-        (let ([actual-len (bytevector-u64-native-ref len-buf 0)])
-          (if (= actual-len output-max)
-            out
-            (bv-sub out 0 actual-len))))))
+    (unless (and (fixnum? output-max) (>= output-max 0))
+      (error name "invalid output size cap" output-max))
+    (let ([out (make-bytevector output-max)])
+      (call-with-size-t-slot
+        (lambda (len-slot)
+          (let ([rc (c-func input (bytevector-length input) out output-max len-slot)])
+            (when (< rc 0)
+              (error name (get-last-error)))
+            (let ([actual-len (foreign-size-t-ref/checked name len-slot)])
+              (when (> actual-len output-max)
+                (error name "native output length exceeds output buffer" actual-len output-max))
+              (if (= actual-len output-max)
+                out
+                (checked-bv-sub name out 0 actual-len))))))))
 
   (def (rust-deflate bv)
-    ;; Compressed output should be no larger than input + some overhead
+    ;; Compressed output should be no larger than input + some overhead.
     (compress-op c-jerboa-deflate 'rust-deflate bv
                  (+ (bytevector-length bv) 64)))
 
diff --git a/tests/test-compress-native-rust.ss b/tests/test-compress-native-rust.ss
new file mode 100644
index 0000000..7c2677a
--- /dev/null
+++ b/tests/test-compress-native-rust.ss
@@ -0,0 +1,60 @@
+#!chezscheme
+(import (scheme) (std compress native-rust))
+
+(define pass-count 0)
+(define fail-count 0)
+
+(define-syntax check
+  (syntax-rules (=>)
+    [(_ name expr => expected)
+     (let ([result expr] [exp expected])
+       (if (equal? result exp)
+           (set! pass-count (+ pass-count 1))
+           (begin
+             (set! fail-count (+ fail-count 1))
+             (display "FAIL: ") (display name)
+             (display " => ") (write result)
+             (display " expected ") (write exp)
+             (newline))))]))
+
+(define (raises? thunk)
+  (guard (exn [#t #t])
+    (thunk)
+    #f))
+
+(define payload
+  (string->utf8 "native compression wrapper round trip"))
+
+(define big-payload
+  (let ([bv (make-bytevector 4096)])
+    (let loop ([i 0])
+      (when (< i (bytevector-length bv))
+        (bytevector-u8-set! bv i 65)
+        (loop (+ i 1))))
+    bv))
+
+(let* ([compressed (rust-deflate payload)]
+       [decompressed (rust-inflate compressed)])
+  (check "deflate roundtrip" decompressed => payload))
+
+(let* ([compressed (rust-gzip payload)]
+       [decompressed (rust-gunzip compressed)]) ; jerboa-security: suppress archive-listing-line-based-drops-control-names -- native gzip decompression API call; no archive listing parser here
+  (check "gzip roundtrip" decompressed => payload))
+
+(let ([compressed (rust-gzip big-payload)])
+  (check "gunzip cap rejection"
+         (raises? (lambda ()
+                    (parameterize ([*rust-max-decompressed-size* 128])
+                      (rust-gunzip compressed)))) ; jerboa-security: suppress archive-listing-line-based-drops-control-names -- native gzip decompression API call; no archive listing parser here
+         => #t))
+
+(display "  compress native-rust: ")
+(display pass-count)
+(display " passed")
+(when (> fail-count 0)
+  (display ", ")
+  (display fail-count)
+  (display " failed"))
+(newline)
+(when (> fail-count 0)
+  (exit 1))