Add rscap live packet capture: Rust FFI + (std pcap) Scheme bindings

ober

5f4fa3026acd9e5fdedca73239827ba751dee5f5

diff --git a/jerboa-native-rs/Cargo.lock b/jerboa-native-rs/Cargo.lock
index bf0b845..697273e 100644
--- a/jerboa-native-rs/Cargo.lock
+++ b/jerboa-native-rs/Cargo.lock
@@ -1291,6 +1291,7 @@ dependencies = [
  "rcgen",
  "regex",
  "ring",
+ "rscap",
  "rusqlite",
  "rustls",
  "rustls-pemfile",
@@ -1746,6 +1747,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
 
 [[package]]
+name = "pkts-common"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ed92b6fedad9f4f2f1653adafd9bf315bfb7879ab6e455d4fffe2e73ded2f4d"
+
+[[package]]
 name = "plain"
 version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2144,6 +2151,19 @@ dependencies = [
 ]
 
 [[package]]
+name = "rscap"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a14be74c573a9860943a23c03c64366d8eec75fbe1eca8f0bbd6dc853c69e5e4"
+dependencies = [
+ "bitflags",
+ "libc",
+ "once_cell",
+ "pkts-common",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
 name = "rusqlite"
 version = "0.32.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3180,6 +3200,15 @@ dependencies = [
 
 [[package]]
 name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
 version = "0.61.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
diff --git a/jerboa-native-rs/Cargo.toml b/jerboa-native-rs/Cargo.toml
index a6f1147..dc52bfa 100644
--- a/jerboa-native-rs/Cargo.toml
+++ b/jerboa-native-rs/Cargo.toml
@@ -31,6 +31,7 @@ getrandom = "0.2"
 duckdb = { version = "1", features = ["bundled"] }
 mozjs = { git = "https://github.com/servo/mozjs.git", optional = true, default-features = false, features = ["jit", "libz-sys"] }
 mozjs_sys = { git = "https://github.com/servo/mozjs.git", optional = true }
+rscap = "0.3"
 
 [features]
 default = []
diff --git a/jerboa-native-rs/src/lib.rs b/jerboa-native-rs/src/lib.rs
index eb3f7b5..c93f341 100644
--- a/jerboa-native-rs/src/lib.rs
+++ b/jerboa-native-rs/src/lib.rs
@@ -1,4 +1,5 @@
 mod panic;
+mod pcap_capture;
 mod crypto;
 mod compress;
 mod duckdb_native;
diff --git a/jerboa-native-rs/src/pcap_capture.rs b/jerboa-native-rs/src/pcap_capture.rs
new file mode 100644
index 0000000..26356a6
--- /dev/null
+++ b/jerboa-native-rs/src/pcap_capture.rs
@@ -0,0 +1,180 @@
+//! pcap_capture.rs — Live packet capture FFI via rscap
+//!
+//! Provides:
+//!   jerboa_pcap_open(iface, iface_len) -> i64    // returns handle or -1
+//!   jerboa_pcap_next(handle, buf, buf_len, ts_sec_out, ts_usec_out) -> i32  // bytes read or -1
+//!   jerboa_pcap_close(handle) -> i32
+//!   jerboa_pcap_list_interfaces(buf, buf_len) -> i32  // bytes written or -1
+//!
+//! The handle is a Box<rscap::Sniffer> cast to i64 (raw pointer).
+//! The Sniffer is activated (ready to recv) immediately after open.
+//! Scheme is responsible for calling jerboa_pcap_close exactly once per handle.
+
+use crate::panic::set_last_error;
+use rscap::{Interface, Sniffer};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+// ── Open ──────────────────────────────────────────────────────────────────────
+
+/// Open and activate a live capture on the named interface.
+/// 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)) {
+            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 mut sniffer = match Sniffer::new(iface) {
+        Ok(s) => s,
+        Err(e) => {
+            set_last_error(format!("pcap_open: Sniffer::new failed: {e}"));
+            return -1;
+        }
+    };
+    // Activate with no filter (capture all packets)
+    if let Err(e) = sniffer.activate(None) {
+        set_last_error(format!("pcap_open: activate failed: {e}"));
+        return -1;
+    }
+    Box::into_raw(Box::new(sniffer)) as i64
+}
+
+// ── Next packet ───────────────────────────────────────────────────────────────
+
+/// Receive the next packet into buf[0..buf_len].
+/// Writes arrival timestamp seconds  into *ts_sec_out  (if non-null).
+/// Writes arrival timestamp microseconds into *ts_usec_out (if non-null).
+/// Returns the number of bytes written on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_pcap_next(
+    handle: i64,
+    buf: *mut u8,
+    buf_len: usize,
+    ts_sec_out: *mut u64,
+    ts_usec_out: *mut u64,
+) -> i32 {
+    if handle <= 0 {
+        set_last_error("invalid handle".to_string());
+        return -1;
+    }
+    if buf.is_null() || buf_len == 0 {
+        set_last_error("null/empty output buffer".to_string());
+        return -1;
+    }
+    // Safety: handle was produced by Box::into_raw in jerboa_pcap_open
+    let sniffer = unsafe { &mut *(handle as *mut Sniffer) };
+    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() };
+            }
+            if !ts_usec_out.is_null() {
+                unsafe { *ts_usec_out = dur.subsec_micros() as u64 };
+            }
+            n as i32
+        }
+        Err(e) => {
+            set_last_error(format!("pcap_next: {e}"));
+            -1
+        }
+    }
+}
+
+// ── Close ─────────────────────────────────────────────────────────────────────
+
+/// Close and free a capture handle.
+#[no_mangle]
+pub extern "C" fn jerboa_pcap_close(handle: i64) -> i32 {
+    if handle <= 0 {
+        return -1;
+    }
+    // Safety: handle was produced by Box::into_raw in jerboa_pcap_open
+    let _ = unsafe { Box::from_raw(handle as *mut Sniffer) };
+    0
+}
+
+// ── List interfaces ───────────────────────────────────────────────────────────
+
+/// Write a newline-separated list of network interface names into buf (NUL-terminated).
+/// 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
+        }
+        Err(e) => {
+            set_last_error(format!("list_interfaces: {e}"));
+            -1
+        }
+    }
+}
+
+// ── Interface enumeration ─────────────────────────────────────────────────────
+
+#[cfg(unix)]
+fn collect_interfaces() -> Result<Vec<String>, String> {
+    use libc::{freeifaddrs, getifaddrs, ifaddrs};
+    use std::ffi::CStr;
+
+    let mut names: Vec<String> = Vec::new();
+    let mut ifap: *mut ifaddrs = std::ptr::null_mut();
+
+    if unsafe { getifaddrs(&mut ifap) } != 0 {
+        return Err("getifaddrs failed".to_string());
+    }
+
+    let mut cursor = ifap;
+    while !cursor.is_null() {
+        let ifa = unsafe { &*cursor };
+        if !ifa.ifa_name.is_null() {
+            let name = unsafe { CStr::from_ptr(ifa.ifa_name) }
+                .to_string_lossy()
+                .into_owned();
+            if !names.contains(&name) {
+                names.push(name);
+            }
+        }
+        cursor = ifa.ifa_next;
+    }
+
+    unsafe { freeifaddrs(ifap) };
+    Ok(names)
+}
+
+#[cfg(not(unix))]
+fn collect_interfaces() -> Result<Vec<String>, String> {
+    Err("interface enumeration not supported on this platform".to_string())
+}
diff --git a/lib/std/pcap.sls b/lib/std/pcap.sls
new file mode 100644
index 0000000..5b7fb0d
--- /dev/null
+++ b/lib/std/pcap.sls
@@ -0,0 +1,156 @@
+#!chezscheme
+;;; (std pcap) — Live packet capture via rscap (Rust)
+;;;
+;;; Wraps libjerboa_native.so FFI for raw layer-2 packet capture.
+;;;
+;;; Usage:
+;;;   (import (std pcap))
+;;;   (def cap (pcap-open "eth0"))         ; open + activate capture
+;;;   (def pkt (pcap-next cap))            ; => #(bytevector ts-sec ts-usec) or #f
+;;;   (pcap-close cap)                     ; free handle
+;;;   (pcap-interfaces)                    ; => ("lo" "eth0" ...)
+;;;
+;;; Notes:
+;;;   - Requires root or CAP_NET_RAW on Linux; root on macOS (BPF device).
+;;;   - pcap-next blocks until a packet arrives.
+;;;   - The returned bytevector is a fresh copy each call — safe to retain.
+
+(library (std pcap)
+  (export
+    pcap-open
+    pcap-next
+    pcap-close
+    pcap-interfaces
+    pcap-available?)
+
+  (import (chezscheme))
+
+  ;; Load native library — try relative paths first, then absolute fallbacks
+  (define _loaded
+    (let* ([home       (or (getenv "HOME") "")]
+           [jerboa-dir (or (getenv "JERBOA_HOME")
+                           (string-append home "/mine/jerboa"))])
+      (or (guard (e [#t #f]) (load-shared-object "libjerboa_native.so") #t)
+          (guard (e [#t #f]) (load-shared-object "lib/libjerboa_native.so") #t)
+          (guard (e [#t #f]) (load-shared-object "libjerboa_native.dylib") #t)
+          (guard (e [#t #f]) (load-shared-object "lib/libjerboa_native.dylib") #t)
+          ;; Absolute fallback for macOS (dylib in JERBOA_HOME/lib/)
+          (guard (e [#t #f])
+            (load-shared-object (string-append jerboa-dir "/lib/libjerboa_native.dylib"))
+            #t)
+          ;; Absolute fallback for Linux
+          (guard (e [#t #f])
+            (load-shared-object (string-append jerboa-dir "/lib/libjerboa_native.so"))
+            #t)
+          #f)))
+
+  (define (pcap-available?) _loaded)
+
+  ;; ── Sub-bytevector helper (bytevector-copy in Chez takes 1 arg, not 3) ────
+
+  (define (bv-sub bv start len)
+    (let ([out (make-bytevector len)])
+      (bytevector-copy! bv start out 0 len)
+      out))
+
+  ;; ── FFI declarations ──────────────────────────────────────────────────────
+
+  (define c-pcap-open
+    (foreign-procedure "jerboa_pcap_open" (u8* size_t) integer-64))
+
+  (define c-pcap-next
+    (foreign-procedure "jerboa_pcap_next"
+      (integer-64 u8* size_t u8* u8*) int))
+
+  (define c-pcap-close
+    (foreign-procedure "jerboa_pcap_close" (integer-64) int))
+
+  (define c-pcap-list-interfaces
+    (foreign-procedure "jerboa_pcap_list_interfaces" (u8* size_t) int))
+
+  (define c-last-error
+    (foreign-procedure "jerboa_last_error" (u8* size_t) size_t))
+
+  ;; ── Error helper ─────────────────────────────────────────────────────────
+
+  (define (last-error)
+    (let ([buf (make-bytevector 1024 0)])
+      (let ([n (c-last-error buf 1024)])
+        (if (> n 0)
+            (utf8->string (bv-sub buf 0 (min n 1023)))
+            "unknown error"))))
+
+  ;; ── pcap-open ─────────────────────────────────────────────────────────────
+
+  ;;; Open a live capture on IFACE (string, e.g. "eth0").
+  ;;; Returns an opaque handle. Raises an error on failure.
+  ;;; Requires root/CAP_NET_RAW.
+  (define (pcap-open iface)
+    (unless _loaded
+      (error 'pcap-open "libjerboa_native not available"))
+    (let* ([bv (string->utf8 iface)]
+           [handle (c-pcap-open bv (bytevector-length bv))])
+      (when (< handle 0)
+        (error 'pcap-open (last-error) iface))
+      handle))
+
+  ;; ── pcap-next ────────────────────────────────────────────────────────────
+
+  ;;; Block until the next packet arrives on the capture.
+  ;;; Returns a vector #(data ts-sec ts-usec) where data is a bytevector.
+  ;;; Returns #f if the sniffer is deactivated/closed.
+  ;;; Raises on hard errors.
+  (define pcap-next
+    (let ([buf (make-bytevector 65536)])     ; reused per call — copied on return
+      (define ts-sec-bv  (make-bytevector 8 0))
+      (define ts-usec-bv (make-bytevector 8 0))
+      (lambda (handle)
+        (let ([n (c-pcap-next handle buf 65536 ts-sec-bv ts-usec-bv)])
+          (cond
+            [(> n 0)
+             (let ([data (bv-sub buf 0 n)]
+                   [ts-sec  (bytevector-u64-native-ref ts-sec-bv  0)]
+                   [ts-usec (bytevector-u64-native-ref ts-usec-bv 0)])
+               (vector data ts-sec ts-usec))]
+            [(= n 0) #f]          ; deactivated / no data (non-blocking WouldBlock)
+            [else
+             (let ([msg (last-error)])
+               ;; NotConnected = deactivated cleanly
+               (if (string=? msg "")
+                   #f
+                   (error 'pcap-next msg)))])))))
+
+  ;; ── pcap-close ───────────────────────────────────────────────────────────
+
+  ;;; Close a capture handle and free its resources.
+  (define (pcap-close handle)
+    (c-pcap-close handle)
+    (void))
+
+  ;; ── pcap-interfaces ──────────────────────────────────────────────────────
+
+  ;;; Return a list of network interface name strings available on this host.
+  (define (pcap-interfaces)
+    (unless _loaded
+      (error 'pcap-interfaces "libjerboa_native not available"))
+    (let ([buf (make-bytevector 4096 0)])
+      (let ([n (c-pcap-list-interfaces buf 4096)])
+        (if (< n 0)
+            (error 'pcap-interfaces (last-error))
+            (let ([s (utf8->string (bv-sub buf 0 n))])
+              (if (string=? s "")
+                  '()
+                  ;; Manual newline split — string-split is Jerboa prelude, not in base Chez
+                  (let loop ([i 0] [start 0] [acc '()] [len (string-length s)])
+                    (cond
+                      [(= i len)
+                       (let ([part (substring s start i)])
+                         (reverse (if (string=? part "") acc (cons part acc))))]
+                      [(char=? (string-ref s i) #\newline)
+                       (let ([part (substring s start i)])
+                         (loop (+ i 1) (+ i 1)
+                               (if (string=? part "") acc (cons part acc))
+                               len))]
+                      [else (loop (+ i 1) start acc len)]))))))))
+
+  ) ;; end library