From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id D53991FF0A7 for ; Wed, 02 Sep 2026 14:33:00 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 5250F21541; Wed, 02 Sep 2026 14:32:55 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ebpf 2/3] tests: add a native harness for the BPF C programs Date: Wed, 2 Sep 2026 14:32:28 +0200 Message-ID: <20260902123229.638967-3-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260902123229.638967-1-h.laimer@proxmox.com> References: <20260902123229.638967-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1788352351567 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.704 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: KKYUFCEPMJY4KEOYAJBQPZ2OOZS5KS3X X-Message-ID-Hash: KKYUFCEPMJY4KEOYAJBQPZ2OOZS5KS3X X-MailFrom: h.laimer@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: The programs' parse and build logic is plain C whose behavior does not depend on the compilation target, so build.rs compiles each one a second time, natively, against shim headers that turn the helpers into plain extern functions, and archives the results for the test build. The harness provides those helpers as bounds-checked functions over an owned buffer plus a per-thread map registry, so a test hands a program a crafted frame and asserts on the rewritten bytes. Signed-off-by: Hannes Laimer --- build.rs | 62 +++++++++++ src/bpf-shim/bpf/bpf_endian.h | 12 +++ src/bpf-shim/bpf/bpf_helpers.h | 32 ++++++ src/bpf-shim/bpf_debug.h | 10 ++ src/bpf-shim/vmlinux.h | 70 ++++++++++++ tests/common/mod.rs | 190 +++++++++++++++++++++++++++++++++ 6 files changed, 376 insertions(+) create mode 100644 src/bpf-shim/bpf/bpf_endian.h create mode 100644 src/bpf-shim/bpf/bpf_helpers.h create mode 100644 src/bpf-shim/bpf_debug.h create mode 100644 src/bpf-shim/vmlinux.h create mode 100644 tests/common/mod.rs diff --git a/build.rs b/build.rs index 270a97f..e62be72 100644 --- a/build.rs +++ b/build.rs @@ -15,6 +15,9 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-env-changed=CARGO_FEATURE_BPF_DEBUG"); + let shim_include = src_dir.join("bpf-shim"); + let mut native_objs = Vec::new(); + for sub_entry in std::fs::read_dir(&src_dir).expect("read src/") { let sub_path = sub_entry.expect("read src/ entry").path(); let bpf_dir = sub_path.join("bpf"); @@ -43,8 +46,67 @@ fn main() { continue; } compile_bpf(&path, &bpf_dir, &global_include, &out_dir, bpf_debug); + native_objs.push(compile_native( + &path, + &bpf_dir, + &global_include, + &shim_include, + &out_dir, + )); } } + + // archived so a member is only linked when a test references its symbols, the regular + // build neither grows nor needs the mocked helpers + let archive = out_dir.join("libbpf_native.a"); + let _ = std::fs::remove_file(&archive); + let status = Command::new("ar") + .arg("rcs") + .arg(&archive) + .args(&native_objs) + .status() + .expect("failed to invoke ar"); + assert!( + status.success(), + "ar failed to create {}", + archive.display() + ); + println!("cargo:rustc-link-search=native={}", out_dir.display()); + println!("cargo:rustc-link-lib=static=bpf_native"); +} + +// native build against the shim headers, so tests call the program as a plain function +fn compile_native( + src: &Path, + local_include: &Path, + global_include: &Path, + shim_include: &Path, + out_dir: &Path, +) -> PathBuf { + let stem = src.file_stem().unwrap().to_str().unwrap(); + let obj_path = out_dir.join(format!("{stem}.native.o")); + + let status = Command::new("clang") + .args(["-O2", "-g", "-Wall", "-fPIC"]) + // shim first, it shadows vmlinux.h and the bpf headers + .arg("-I") + .arg(shim_include) + .arg("-I") + .arg(global_include) + .arg("-I") + .arg(local_include) + .arg("-c") + .arg(src) + .arg("-o") + .arg(&obj_path) + .status() + .expect("failed to invoke clang -- is it installed?"); + assert!( + status.success(), + "clang failed to compile {} natively", + src.display() + ); + obj_path } fn compile_bpf( diff --git a/src/bpf-shim/bpf/bpf_endian.h b/src/bpf-shim/bpf/bpf_endian.h new file mode 100644 index 0000000..ebcbceb --- /dev/null +++ b/src/bpf-shim/bpf/bpf_endian.h @@ -0,0 +1,12 @@ +#ifndef PROXMOX_EBPF_SHIM_BPF_ENDIAN_H +#define PROXMOX_EBPF_SHIM_BPF_ENDIAN_H + +// Native stand-in for , little-endian hosts only (the +// shim vmlinux.h enforces that). + +#define bpf_htons(x) __builtin_bswap16(x) +#define bpf_ntohs(x) __builtin_bswap16(x) +#define bpf_htonl(x) __builtin_bswap32(x) +#define bpf_ntohl(x) __builtin_bswap32(x) + +#endif diff --git a/src/bpf-shim/bpf/bpf_helpers.h b/src/bpf-shim/bpf/bpf_helpers.h new file mode 100644 index 0000000..4980724 --- /dev/null +++ b/src/bpf-shim/bpf/bpf_helpers.h @@ -0,0 +1,32 @@ +#ifndef PROXMOX_EBPF_SHIM_BPF_HELPERS_H +#define PROXMOX_EBPF_SHIM_BPF_HELPERS_H + +// Native stand-in for : the helpers become plain extern +// functions the test harness provides, sections reduce to weak linkage so +// the program objects can be linked into one test binary, and the map +// definition macros keep their libbpf shapes, which are plain C already. + +#define SEC(name) __attribute__((weak)) +#define __always_inline inline __attribute__((always_inline)) +#define __uint(name, val) int(*name)[val] +#define __type(name, val) typeof(val) *name + +#ifndef barrier_var +#define barrier_var(var) asm volatile("" : "+r"(var)) +#endif + +// map-definition constants, the values are irrelevant natively +enum { + BPF_MAP_TYPE_HASH = 1, +}; +#define BPF_F_NO_PREALLOC 1 +#define LIBBPF_PIN_BY_NAME 1 + +extern void *bpf_map_lookup_elem(void *map, const void *key); +extern long bpf_skb_load_bytes(const void *skb, __u32 offset, void *to, __u32 len); +extern long bpf_skb_store_bytes(void *skb, __u32 offset, const void *from, __u32 len, __u64 flags); +extern long bpf_skb_pull_data(void *skb, __u32 len); +extern long bpf_skb_change_tail(void *skb, __u32 new_len, __u64 flags); +extern long bpf_redirect(__u32 ifindex, __u64 flags); + +#endif diff --git a/src/bpf-shim/bpf_debug.h b/src/bpf-shim/bpf_debug.h new file mode 100644 index 0000000..a7e5eac --- /dev/null +++ b/src/bpf-shim/bpf_debug.h @@ -0,0 +1,10 @@ +#ifndef PROXMOX_EBPF_SHIM_BPF_DEBUG_H +#define PROXMOX_EBPF_SHIM_BPF_DEBUG_H + +// Native stand-in for bpf_debug.h, debug printing is a no-op in tests. + +#define DBG(...) \ + do { \ + } while (0) + +#endif diff --git a/src/bpf-shim/vmlinux.h b/src/bpf-shim/vmlinux.h new file mode 100644 index 0000000..52954f2 --- /dev/null +++ b/src/bpf-shim/vmlinux.h @@ -0,0 +1,70 @@ +#ifndef PROXMOX_EBPF_SHIM_VMLINUX_H +#define PROXMOX_EBPF_SHIM_VMLINUX_H + +// Native stand-in for vmlinux.h, only the types the programs touch. The +// header layouts match the little-endian kernel wire layouts, data and +// data_end are wide enough to hold real pointers. + +#if __BYTE_ORDER__ != __ORDER_LITTLE_ENDIAN__ +#error "the native BPF test build assumes a little-endian host" +#endif + +typedef unsigned char __u8; +typedef unsigned short __u16; +typedef unsigned int __u32; +typedef unsigned long long __u64; +typedef __u16 __be16; +typedef __u32 __be32; + +// keep in sync with SkBuff in tests/common/mod.rs +struct __sk_buff { + unsigned long data; + unsigned long data_end; + __u32 len; + __u32 ifindex; + __u32 mark; +}; + +struct ethhdr { + __u8 h_dest[6]; + __u8 h_source[6]; + __be16 h_proto; +} __attribute__((packed)); + +struct iphdr { + __u8 ihl : 4; + __u8 version : 4; + __u8 tos; + __be16 tot_len; + __be16 id; + __be16 frag_off; + __u8 ttl; + __u8 protocol; + __u16 check; + __be32 saddr; + __be32 daddr; +}; + +struct udphdr { + __be16 source; + __be16 dest; + __be16 len; + __u16 check; +}; + +struct in6_addr { + __u8 s6_addr[16]; +}; + +struct ipv6hdr { + __u8 priority : 4; + __u8 version : 4; + __u8 flow_lbl[3]; + __be16 payload_len; + __u8 nexthdr; + __u8 hop_limit; + struct in6_addr saddr; + struct in6_addr daddr; +}; + +#endif diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..af8cabd --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,190 @@ +//! Native harness for the subsystems' BPF C programs: a fake skb over an owned buffer, the +//! helpers as plain bounds-checked functions over it, and a per-thread map registry. build.rs +//! compiles every program a second time against the shim headers in src/bpf-shim and archives +//! them, tests declare the program symbols and call them like plain functions. + +// the archive is linked through the lib, a test crate that never uses the lib +// would drop it from the link together with the program symbols +use proxmox_ebpf as _; + +use std::cell::RefCell; +use std::collections::HashMap; +use std::ffi::{c_int, c_long, c_void}; + +pub const TC_ACT_OK: c_int = 0; +pub const TC_ACT_REDIRECT: c_int = 7; + +pub const PKT_CAP: usize = 2048; + +/// Keep in sync with struct __sk_buff in src/bpf-shim/vmlinux.h. +#[repr(C)] +pub struct SkBuff { + pub data: usize, + pub data_end: usize, + pub len: u32, + pub ifindex: u32, + pub mark: u32, +} + +/// The skb handed to a program plus the buffer behind it. The helpers get the skb pointer and +/// cast back to this, so the skb must stay the first field. +#[repr(C)] +pub struct TestSkb { + skb: SkBuff, + buf: Box<[u8; PKT_CAP]>, +} + +impl TestSkb { + pub fn new(packet: &[u8], ifindex: u32) -> Self { + assert!(packet.len() <= PKT_CAP); + let mut buf = Box::new([0u8; PKT_CAP]); + buf[..packet.len()].copy_from_slice(packet); + let mut t = TestSkb { + skb: SkBuff { + data: 0, + data_end: 0, + len: packet.len() as u32, + ifindex, + mark: 0, + }, + buf, + }; + t.sync(); + t + } + + fn sync(&mut self) { + let base = self.buf.as_ptr() as usize; + self.skb.data = base; + self.skb.data_end = base + self.skb.len as usize; + } + + pub fn packet(&self) -> &[u8] { + &self.buf[..self.skb.len as usize] + } + + pub fn run(&mut self, prog: unsafe extern "C" fn(*mut SkBuff) -> c_int) -> c_int { + REDIRECTED.with(|r| *r.borrow_mut() = None); + unsafe { prog(&mut self.skb) } + } +} + +struct MockMap { + key_size: usize, + entries: HashMap, Box<[u8]>>, +} + +thread_local! { + static MAPS: RefCell> = RefCell::new(HashMap::new()); + static REDIRECTED: RefCell> = const { RefCell::new(None) }; +} + +pub fn register_map(map: *const c_void, key_size: usize) { + MAPS.with(|maps| { + maps.borrow_mut().insert( + map as usize, + MockMap { + key_size, + entries: HashMap::new(), + }, + ) + }); +} + +pub fn map_insert(map: *const c_void, key: &[u8], value: &[u8]) { + MAPS.with(|maps| { + let mut maps = maps.borrow_mut(); + let m = maps.get_mut(&(map as usize)).expect("map not registered"); + assert_eq!(key.len(), m.key_size); + m.entries.insert(key.to_vec(), value.into()); + }); +} + +/// The ifindex of the redirect the last run issued, if any. +pub fn redirected() -> Option { + REDIRECTED.with(|r| *r.borrow()) +} + +unsafe fn testskb<'a>(skb: *mut c_void) -> &'a mut TestSkb { + unsafe { &mut *(skb as *mut TestSkb) } +} + +#[unsafe(no_mangle)] +pub extern "C" fn bpf_map_lookup_elem(map: *mut c_void, key: *const c_void) -> *mut c_void { + MAPS.with(|maps| { + let maps = maps.borrow(); + let Some(m) = maps.get(&(map as usize)) else { + panic!("lookup on unregistered map"); + }; + let key = unsafe { std::slice::from_raw_parts(key as *const u8, m.key_size) }; + match m.entries.get(key) { + // the box's heap allocation stays put while the registry holds it + Some(v) => v.as_ptr() as *mut c_void, + None => std::ptr::null_mut(), + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn bpf_skb_load_bytes( + skb: *mut c_void, + offset: u32, + to: *mut c_void, + len: u32, +) -> c_long { + let t = unsafe { testskb(skb) }; + let (offset, len) = (offset as usize, len as usize); + if offset + len > t.skb.len as usize { + return -1; + } + unsafe { std::ptr::copy_nonoverlapping(t.buf.as_ptr().add(offset), to as *mut u8, len) }; + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn bpf_skb_store_bytes( + skb: *mut c_void, + offset: u32, + from: *const c_void, + len: u32, + _flags: u64, +) -> c_long { + let t = unsafe { testskb(skb) }; + let (offset, len) = (offset as usize, len as usize); + if offset + len > t.skb.len as usize { + return -1; + } + unsafe { + std::ptr::copy_nonoverlapping(from as *const u8, t.buf.as_mut_ptr().add(offset), len) + }; + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn bpf_skb_pull_data(skb: *mut c_void, len: u32) -> c_long { + // the buffer is always linear, pulling within it is a no-op + let t = unsafe { testskb(skb) }; + if len > t.skb.len { -1 } else { 0 } +} + +#[unsafe(no_mangle)] +pub extern "C" fn bpf_skb_change_tail(skb: *mut c_void, new_len: u32, _flags: u64) -> c_long { + let t = unsafe { testskb(skb) }; + if new_len as usize > PKT_CAP { + return -1; + } + // like the kernel, grown room reads as zeros + let old = t.skb.len as usize; + if new_len as usize > old { + t.buf[old..new_len as usize].fill(0); + } + t.skb.len = new_len; + t.sync(); + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn bpf_redirect(ifindex: u32, _flags: u64) -> c_long { + REDIRECTED.with(|r| *r.borrow_mut() = Some(ifindex)); + TC_ACT_REDIRECT as c_long +} -- 2.47.3