security: harden pcap ffi
ober
e56785f09d3c731058cddd18bed0ad216043e06f
--- 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: --- 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) | --- 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)))) --- 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. | --- 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, --- 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))]