security: harden pcap ffi

ober

e56785f09d3c731058cddd18bed0ad216043e06f

diff --git a/docs/ffi-audit.md b/docs/ffi-audit.md
index d8d8551..0b3e5fe 100644
--- a/docs/ffi-audit.md
+++ b/docs/ffi-audit.md
@@ -41,9 +41,9 @@ Latest summary from `tools/ffi-audit-report.ss`:
     (c-file-count 5)
     (no-mangle-exports 196)
     (unsafe-sites 432)
-    (unsafe-sites-with-nearby-safety-comment 162)
-    (unsafe-sites-without-nearby-safety-comment 270)
-    (unsafe-comment-review-sites 270)
+    (unsafe-sites-with-nearby-safety-comment 172)
+    (unsafe-sites-without-nearby-safety-comment 260)
+    (unsafe-comment-review-sites 260)
     (export-review-sites 196))
   (vendor
     (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated)))
@@ -101,9 +101,13 @@ documents output-slot and ovector invariants, checks capture slot arithmetic,
 and returns a normal FFI error if the regex handle store is poisoned.
 `process_ctl.rs` now documents process-name, memory-lock, signal-probe, errno,
 FreeBSD sysctl/setproctitle, and executable-path output invariants, and rejects
-FreeBSD process-title capacity overflow before allocating. The generated
-inventory now reports 162 annotated native unsafe sites and 270 remaining unsafe
-review sites.
+FreeBSD process-title capacity overflow before allocating. `pcap_capture.rs`
+now routes its C ABI exports through panic containment, documents BPF ioctl,
+packet-buffer, timestamp, interface-list, and `getifaddrs` invariants, and has
+device-free regressions for null pointer rejection. `panic.rs` now has an
+`i64` wrapper for handle-returning exports and documents `jerboa_last_error`
+buffer writes. The generated inventory now reports 172 annotated native unsafe
+sites and 260 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 1416c62..46d1090 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -645,10 +645,15 @@ not started."
   and returns a normal FFI error if the regex handle store is poisoned.
   `process_ctl.rs` now documents process-name, memory-lock, signal-probe,
   errno, FreeBSD sysctl/setproctitle, and executable-path output invariants, and
-  rejects FreeBSD process-title capacity overflow before allocating. The
-  generated report now shows 162 annotated native unsafe sites and 270 remaining
-  unsafe review sites. Remaining work: continue unsafe invariant comments across
-  the rest of `jerboa-native-rs`.
+  rejects FreeBSD process-title capacity overflow before allocating.
+  `pcap_capture.rs` now routes its C ABI exports through panic containment,
+  documents BPF ioctl, packet-buffer, timestamp, interface-list, and
+  `getifaddrs` invariants, and has device-free regressions for null pointer
+  rejection. `panic.rs` now has an `i64` wrapper for handle-returning exports
+  and documents `jerboa_last_error` buffer writes. The generated report now
+  shows 172 annotated native unsafe sites and 260 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.
@@ -1136,7 +1141,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 | 270 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 |
+| Un-annotated Rust `unsafe` blocks | 260 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 fd02453..c000d17 100644
--- a/docs/reviews/2026-07-27-native-export-review.sexp
+++ b/docs/reviews/2026-07-27-native-export-review.sexp
@@ -85,10 +85,10 @@
     (export (symbol "jerboa_last_error") (file "jerboa-native-rs/src/panic.rs") (line 9) (scheme-callers 11 (caller (file "lib/std/compress/native-rust.ss") (line 42)) (caller (file "lib/std/crypto/native-rust.ss") (line 48)) (caller (file "lib/std/crypto/x509.ss") (line 24)) (caller (file "lib/std/native.ss") (line 56)) (caller (file "lib/std/net/tls-rustls.ss") (line 167)) (caller (file "lib/std/os/aproc.ss") (line 164)) (caller (file "lib/std/os/integrity.ss") (line 42)) (caller (file "lib/std/os/secure-output.ss") (line 47)) (caller (file "lib/std/pcap.ss") (line 66)) (caller (file "lib/std/regex-native.ss") (line 51)) (caller (file "lib/std/wasm/sandbox.ss") (line 196))))
     (export (symbol "jerboa_pbkdf2_derive") (file "jerboa-native-rs/src/crypto.rs") (line 740) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 246))))
     (export (symbol "jerboa_pbkdf2_verify") (file "jerboa-native-rs/src/crypto.rs") (line 773) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 261))))
-    (export (symbol "jerboa_pcap_close") (file "jerboa-native-rs/src/pcap_capture.rs") (line 324) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 58))))
-    (export (symbol "jerboa_pcap_list_interfaces") (file "jerboa-native-rs/src/pcap_capture.rs") (line 350) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 62))))
-    (export (symbol "jerboa_pcap_next") (file "jerboa-native-rs/src/pcap_capture.rs") (line 260) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 53))))
-    (export (symbol "jerboa_pcap_open") (file "jerboa-native-rs/src/pcap_capture.rs") (line 195) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 49))))
+    (export (symbol "jerboa_pcap_close") (file "jerboa-native-rs/src/pcap_capture.rs") (line 341) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 58))))
+    (export (symbol "jerboa_pcap_list_interfaces") (file "jerboa-native-rs/src/pcap_capture.rs") (line 371) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 62))))
+    (export (symbol "jerboa_pcap_next") (file "jerboa-native-rs/src/pcap_capture.rs") (line 269) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 53))))
+    (export (symbol "jerboa_pcap_open") (file "jerboa-native-rs/src/pcap_capture.rs") (line 200) (scheme-callers 1 (caller (file "lib/std/pcap.ss") (line 49))))
     (export (symbol "jerboa_pg_column_name") (file "jerboa-native-rs/src/postgres_native.rs") (line 310) (scheme-callers 1 (caller (file "lib/std/db/postgresql-native.ss") (line 41))))
     (export (symbol "jerboa_pg_connect") (file "jerboa-native-rs/src/postgres_native.rs") (line 83) (scheme-callers 1 (caller (file "lib/std/db/postgresql-native.ss") (line 24))))
     (export (symbol "jerboa_pg_disconnect") (file "jerboa-native-rs/src/postgres_native.rs") (line 132) (scheme-callers 1 (caller (file "lib/std/db/postgresql-native.ss") (line 26))))
diff --git a/docs/status.md b/docs/status.md
index f0dd26c..c44e9a4 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, integrity ABI, regex-native ABI, and process-control ABI now have nearby `SAFETY:` comments, with the generated unannotated unsafe-site count at 270. | 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, regex-native ABI, process-control ABI, and pcap ABI now have nearby `SAFETY:` comments, with the generated unannotated unsafe-site count at 260. | 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/panic.rs b/jerboa-native-rs/src/panic.rs
index adb73c1..ae66126 100644
--- a/jerboa-native-rs/src/panic.rs
+++ b/jerboa-native-rs/src/panic.rs
@@ -12,6 +12,9 @@ pub extern "C" fn jerboa_last_error(buf: *mut u8, buf_len: usize) -> usize {
         let bytes = msg.as_bytes();
         let copy_len = bytes.len().min(buf_len.saturating_sub(1));
         if !buf.is_null() && copy_len > 0 {
+            // SAFETY: buf is non-null above, copy_len is bounded by
+            // buf_len.saturating_sub(1), and the following byte is reserved for
+            // the NUL terminator.
             unsafe {
                 std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, copy_len);
                 *buf.add(copy_len) = 0; // null terminate
@@ -42,6 +45,23 @@ pub fn ffi_wrap<F: FnOnce() -> i32 + panic::UnwindSafe>(f: F) -> i32 {
     }
 }
 
+pub fn ffi_wrap_i64<F: FnOnce() -> i64 + panic::UnwindSafe>(f: F) -> i64 {
+    match panic::catch_unwind(f) {
+        Ok(code) => code,
+        Err(e) => {
+            let msg = if let Some(s) = e.downcast_ref::<&str>() {
+                s.to_string()
+            } else if let Some(s) = e.downcast_ref::<String>() {
+                s.clone()
+            } else {
+                "unknown panic".to_string()
+            };
+            set_last_error(msg);
+            -1
+        }
+    }
+}
+
 pub fn ffi_wrap_usize<F: FnOnce() -> usize + panic::UnwindSafe>(f: F) -> usize {
     match panic::catch_unwind(f) {
         Ok(code) => code,
diff --git a/jerboa-native-rs/src/pcap_capture.rs b/jerboa-native-rs/src/pcap_capture.rs
index a2e80a8..860a13c 100644
--- a/jerboa-native-rs/src/pcap_capture.rs
+++ b/jerboa-native-rs/src/pcap_capture.rs
@@ -9,7 +9,7 @@
 //! Capture handles are opaque positive integers backed by a generation-checked
 //! registry. Forged, stale, and double-closed handles are rejected.
 
-use crate::panic::set_last_error;
+use crate::panic::{ffi_wrap, ffi_wrap_i64, set_last_error};
 use rscap::{Interface, Sniffer};
 use std::sync::{Arc, Mutex, OnceLock};
 use std::time::{SystemTime, UNIX_EPOCH};
@@ -168,6 +168,9 @@ mod macos_bpf {
             bf_insns: &mut insn,
         };
 
+        // SAFETY: fd is obtained from the live Sniffer, prog points to a stack
+        // BPF program with one instruction, and BIOCSETF expects a mutable
+        // bpf_program pointer for the ioctl duration only.
         let rc = unsafe {
             libc::ioctl(
                 fd,
@@ -181,6 +184,8 @@ mod macos_bpf {
 
         // Enable immediate mode: return each packet right away, don't buffer
         let mut imm: libc::c_uint = 1;
+        // SAFETY: fd is obtained from the live Sniffer and imm points to a
+        // stack c_uint for the BIOCIMMEDIATE ioctl duration only.
         unsafe { libc::ioctl(fd, BIOCIMMEDIATE, &mut imm as *mut libc::c_uint) };
 
         Ok(())
@@ -193,61 +198,65 @@ mod macos_bpf {
 /// Returns a handle (i64 > 0) on success, or -1 on error (check jerboa_last_error).
 #[no_mangle]
 pub extern "C" fn jerboa_pcap_open(iface_ptr: *const u8, iface_len: usize) -> i64 {
-    if iface_ptr.is_null() {
-        set_last_error("null interface pointer".to_string());
-        return -1;
-    }
-    let iface_str = unsafe {
-        match std::str::from_utf8(std::slice::from_raw_parts(iface_ptr, iface_len)) {
+    ffi_wrap_i64(|| {
+        if iface_ptr.is_null() {
+            set_last_error("null interface pointer".to_string());
+            return -1;
+        }
+        // SAFETY: iface_ptr is non-null above and the C ABI contract requires a
+        // readable iface_len-byte UTF-8 interface-name buffer.
+        let iface_str = unsafe {
+            match std::str::from_utf8(std::slice::from_raw_parts(iface_ptr, iface_len)) {
+                Ok(s) => s,
+                Err(e) => {
+                    set_last_error(format!("invalid UTF-8 in interface name: {e}"));
+                    return -1;
+                }
+            }
+        };
+        let iface = match Interface::new(iface_str) {
+            Ok(i) => i,
+            Err(e) => {
+                set_last_error(format!("pcap_open: invalid interface '{iface_str}': {e}"));
+                return -1;
+            }
+        };
+        let sniffer = match Sniffer::new(iface) {
             Ok(s) => s,
             Err(e) => {
-                set_last_error(format!("invalid UTF-8 in interface name: {e}"));
+                set_last_error(format!("pcap_open: Sniffer::new failed: {e}"));
                 return -1;
             }
-        }
-    };
-    let iface = match Interface::new(iface_str) {
-        Ok(i) => i,
-        Err(e) => {
-            set_last_error(format!("pcap_open: invalid interface '{iface_str}': {e}"));
-            return -1;
-        }
-    };
-    let sniffer = match Sniffer::new(iface) {
-        Ok(s) => s,
-        Err(e) => {
-            set_last_error(format!("pcap_open: Sniffer::new failed: {e}"));
-            return -1;
-        }
-    };
+        };
 
-    #[cfg(not(target_os = "macos"))]
-    let mut sniffer = sniffer;
+        #[cfg(not(target_os = "macos"))]
+        let mut sniffer = sniffer;
 
-    // Activate: set accept-all filter and begin capturing
-    #[cfg(target_os = "macos")]
-    let activate_result = macos_bpf::activate(&sniffer);
-    #[cfg(not(target_os = "macos"))]
-    let activate_result = sniffer.activate(None);
+        // Activate: set accept-all filter and begin capturing
+        #[cfg(target_os = "macos")]
+        let activate_result = macos_bpf::activate(&sniffer);
+        #[cfg(not(target_os = "macos"))]
+        let activate_result = sniffer.activate(None);
 
-    if let Err(e) = activate_result {
-        set_last_error(format!("pcap_open: activate failed: {e}"));
-        return -1;
-    }
+        if let Err(e) = activate_result {
+            set_last_error(format!("pcap_open: activate failed: {e}"));
+            return -1;
+        }
 
-    match capture_registry().lock() {
-        Ok(mut registry) => match registry.insert(Arc::new(Mutex::new(Some(sniffer)))) {
-            Ok(handle) => handle,
-            Err(message) => {
-                set_last_error(format!("pcap_open: {message}"));
+        match capture_registry().lock() {
+            Ok(mut registry) => match registry.insert(Arc::new(Mutex::new(Some(sniffer)))) {
+                Ok(handle) => handle,
+                Err(message) => {
+                    set_last_error(format!("pcap_open: {message}"));
+                    -1
+                }
+            },
+            Err(_) => {
+                set_last_error("pcap_open: capture registry lock poisoned".to_string());
                 -1
             }
-        },
-        Err(_) => {
-            set_last_error("pcap_open: capture registry lock poisoned".to_string());
-            -1
         }
-    }
+    })
 }
 
 // ── Next packet ───────────────────────────────────────────────────────────────
@@ -264,57 +273,65 @@ pub extern "C" fn jerboa_pcap_next(
     ts_sec_out: *mut u64,
     ts_usec_out: *mut u64,
 ) -> i32 {
-    if buf.is_null() || buf_len == 0 {
-        set_last_error("null/empty output buffer".to_string());
-        return -1;
-    }
-    let sniffer = match capture_registry().lock() {
-        Ok(registry) => match registry.get(handle) {
+    ffi_wrap(|| {
+        if buf.is_null() || buf_len == 0 {
+            set_last_error("null/empty output buffer".to_string());
+            return -1;
+        }
+        let sniffer = match capture_registry().lock() {
+            Ok(registry) => match registry.get(handle) {
+                Some(sniffer) => sniffer,
+                None => {
+                    set_last_error("pcap_next: invalid or closed capture handle".to_string());
+                    return -1;
+                }
+            },
+            Err(_) => {
+                set_last_error("pcap_next: capture registry lock poisoned".to_string());
+                return -1;
+            }
+        };
+        let mut capture = match sniffer.lock() {
+            Ok(capture) => capture,
+            Err(_) => {
+                set_last_error("pcap_next: capture lock poisoned".to_string());
+                return -1;
+            }
+        };
+        let sniffer = match capture.as_mut() {
             Some(sniffer) => sniffer,
             None => {
-                set_last_error("pcap_next: invalid or closed capture handle".to_string());
+                set_last_error("pcap_next: closed capture handle".to_string());
                 return -1;
             }
-        },
-        Err(_) => {
-            set_last_error("pcap_next: capture registry lock poisoned".to_string());
-            return -1;
-        }
-    };
-    let mut capture = match sniffer.lock() {
-        Ok(capture) => capture,
-        Err(_) => {
-            set_last_error("pcap_next: capture lock poisoned".to_string());
-            return -1;
-        }
-    };
-    let sniffer = match capture.as_mut() {
-        Some(sniffer) => sniffer,
-        None => {
-            set_last_error("pcap_next: closed capture handle".to_string());
-            return -1;
-        }
-    };
-    let out = unsafe { std::slice::from_raw_parts_mut(buf, buf_len) };
-
-    match sniffer.recv(out) {
-        Ok(n) => {
-            let dur = SystemTime::now()
-                .duration_since(UNIX_EPOCH)
-                .unwrap_or_default();
-            if !ts_sec_out.is_null() {
-                unsafe { *ts_sec_out = dur.as_secs() };
+        };
+        // SAFETY: buf is non-null and buf_len > 0 above; callers provide a
+        // writable packet buffer for rscap to fill.
+        let out = unsafe { std::slice::from_raw_parts_mut(buf, buf_len) };
+
+        match sniffer.recv(out) {
+            Ok(n) => {
+                let dur = SystemTime::now()
+                    .duration_since(UNIX_EPOCH)
+                    .unwrap_or_default();
+                if !ts_sec_out.is_null() {
+                    // SAFETY: ts_sec_out is optional; when non-null it points to
+                    // a writable u64 timestamp result slot.
+                    unsafe { *ts_sec_out = dur.as_secs() };
+                }
+                if !ts_usec_out.is_null() {
+                    // SAFETY: ts_usec_out is optional; when non-null it points
+                    // to a writable u64 timestamp result slot.
+                    unsafe { *ts_usec_out = dur.subsec_micros() as u64 };
+                }
+                n as i32
             }
-            if !ts_usec_out.is_null() {
-                unsafe { *ts_usec_out = dur.subsec_micros() as u64 };
+            Err(e) => {
+                set_last_error(format!("pcap_next: {e}"));
+                -1
             }
-            n as i32
-        }
-        Err(e) => {
-            set_last_error(format!("pcap_next: {e}"));
-            -1
         }
-    }
+    })
 }
 
 // ── Close ─────────────────────────────────────────────────────────────────────
@@ -322,24 +339,28 @@ pub extern "C" fn jerboa_pcap_next(
 /// Close and free a capture handle.
 #[no_mangle]
 pub extern "C" fn jerboa_pcap_close(handle: i64) -> i32 {
-    match capture_registry().lock() {
-        Ok(mut registry) => match registry.remove(handle) {
-            Some(sniffer) => {
-                // A concurrent pcap_next may hold another Arc. In that case
-                // destruction is deferred safely until that call returns.
-                drop(sniffer);
-                0
-            }
-            None => {
-                set_last_error("pcap_close: invalid or already closed capture handle".to_string());
+    ffi_wrap(|| {
+        match capture_registry().lock() {
+            Ok(mut registry) => match registry.remove(handle) {
+                Some(sniffer) => {
+                    // A concurrent pcap_next may hold another Arc. In that case
+                    // destruction is deferred safely until that call returns.
+                    drop(sniffer);
+                    0
+                }
+                None => {
+                    set_last_error(
+                        "pcap_close: invalid or already closed capture handle".to_string(),
+                    );
+                    -1
+                }
+            },
+            Err(_) => {
+                set_last_error("pcap_close: capture registry lock poisoned".to_string());
                 -1
             }
-        },
-        Err(_) => {
-            set_last_error("pcap_close: capture registry lock poisoned".to_string());
-            -1
         }
-    }
+    })
 }
 
 // ── List interfaces ───────────────────────────────────────────────────────────
@@ -348,26 +369,30 @@ pub extern "C" fn jerboa_pcap_close(handle: i64) -> i32 {
 /// Returns bytes written (excluding NUL), or -1 on error.
 #[no_mangle]
 pub extern "C" fn jerboa_pcap_list_interfaces(buf: *mut u8, buf_len: usize) -> i32 {
-    if buf.is_null() || buf_len == 0 {
-        set_last_error("null/empty buffer for interface list".to_string());
-        return -1;
-    }
-    match collect_interfaces() {
-        Ok(names) => {
-            let joined = names.join("\n");
-            let bytes = joined.as_bytes();
-            let copy_len = bytes.len().min(buf_len.saturating_sub(1));
-            unsafe {
-                std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, copy_len);
-                *buf.add(copy_len) = 0;
-            }
-            copy_len as i32
+    ffi_wrap(|| {
+        if buf.is_null() || buf_len == 0 {
+            set_last_error("null/empty buffer for interface list".to_string());
+            return -1;
         }
-        Err(e) => {
-            set_last_error(format!("list_interfaces: {e}"));
-            -1
+        match collect_interfaces() {
+            Ok(names) => {
+                let joined = names.join("\n");
+                let bytes = joined.as_bytes();
+                let copy_len = bytes.len().min(buf_len.saturating_sub(1));
+                // SAFETY: buf is non-null and buf_len > 0 above; copy_len is
+                // bounded by buf_len - 1, leaving room for the NUL terminator.
+                unsafe {
+                    std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, copy_len);
+                    *buf.add(copy_len) = 0;
+                }
+                copy_len as i32
+            }
+            Err(e) => {
+                set_last_error(format!("list_interfaces: {e}"));
+                -1
+            }
         }
-    }
+    })
 }
 
 // ── Interface enumeration ─────────────────────────────────────────────────────
@@ -380,14 +405,19 @@ fn collect_interfaces() -> Result<Vec<String>, String> {
     let mut names: Vec<String> = Vec::new();
     let mut ifap: *mut ifaddrs = std::ptr::null_mut();
 
+    // SAFETY: getifaddrs writes a linked-list head into ifap on success.
     if unsafe { getifaddrs(&mut ifap) } != 0 {
         return Err("getifaddrs failed".to_string());
     }
 
     let mut cursor = ifap;
     while !cursor.is_null() {
+        // SAFETY: cursor walks the getifaddrs-owned linked list until the null
+        // terminator; nodes stay valid until freeifaddrs(ifap).
         let ifa = unsafe { &*cursor };
         if !ifa.ifa_name.is_null() {
+            // SAFETY: ifa_name is non-null above and owned by getifaddrs as a
+            // NUL-terminated interface-name string.
             let name = unsafe { CStr::from_ptr(ifa.ifa_name) }
                 .to_string_lossy()
                 .into_owned();
@@ -398,6 +428,7 @@ fn collect_interfaces() -> Result<Vec<String>, String> {
         cursor = ifa.ifa_next;
     }
 
+    // SAFETY: ifap is the list head returned by successful getifaddrs.
     unsafe { freeifaddrs(ifap) };
     Ok(names)
 }
@@ -405,7 +436,8 @@ fn collect_interfaces() -> Result<Vec<String>, String> {
 #[cfg(test)]
 mod tests {
     use super::{
-        capture_registry, decode_handle, encode_handle, jerboa_pcap_close, HandleTable,
+        capture_registry, decode_handle, encode_handle, jerboa_pcap_close,
+        jerboa_pcap_list_interfaces, jerboa_pcap_next, jerboa_pcap_open, HandleTable,
         MAX_HANDLE_GENERATION,
     };
     use std::sync::{Arc, Mutex};
@@ -446,6 +478,27 @@ mod tests {
         let forged = encode_handle(0xffff, 123).expect("well-formed forged handle");
         assert_eq!(jerboa_pcap_close(forged), -1);
     }
+
+    #[test]
+    fn ffi_open_rejects_null_interface_without_device() {
+        assert_eq!(jerboa_pcap_open(std::ptr::null(), 1), -1);
+    }
+
+    #[test]
+    fn ffi_next_rejects_null_output_without_device() {
+        let mut ts_sec = 0u64;
+        let mut ts_usec = 0u64;
+
+        assert_eq!(
+            jerboa_pcap_next(123, std::ptr::null_mut(), 1, &mut ts_sec, &mut ts_usec),
+            -1
+        );
+    }
+
+    #[test]
+    fn ffi_list_interfaces_rejects_null_output_without_device() {
+        assert_eq!(jerboa_pcap_list_interfaces(std::ptr::null_mut(), 1), -1);
+    }
 }
 
 #[cfg(not(unix))]