From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox v7 5/6] ebpf: tests: add a native harness for the BPF C programs
Date: Wed, 4 Nov 2026 17:01:04 +0100 [thread overview]
Message-ID: <20261104160105.2184145-6-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261104160105.2184145-1-h.laimer@proxmox.com>
The programs' packet parsing and rewriting is plain C whose behavior
does not depend on the compilation target. So the build compiles each
one a second time natively, against shim headers that turn the
helpers into plain extern functions. A test hands a program a crafted
frame and asserts on the rewritten bytes.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
proxmox-ebpf/build.rs | 85 ++++++++
proxmox-ebpf/src/bpf-shim/bpf/bpf_helpers.h | 34 ++++
proxmox-ebpf/src/bpf-shim/bpf_debug.h | 10 +
proxmox-ebpf/src/bpf-shim/linux/bpf.h | 25 +++
proxmox-ebpf/tests/bpf/fixture.bpf.c | 37 ++++
proxmox-ebpf/tests/common/mod.rs | 211 ++++++++++++++++++++
proxmox-ebpf/tests/harness.rs | 84 ++++++++
7 files changed, 486 insertions(+)
create mode 100644 proxmox-ebpf/src/bpf-shim/bpf/bpf_helpers.h
create mode 100644 proxmox-ebpf/src/bpf-shim/bpf_debug.h
create mode 100644 proxmox-ebpf/src/bpf-shim/linux/bpf.h
create mode 100644 proxmox-ebpf/tests/bpf/fixture.bpf.c
create mode 100644 proxmox-ebpf/tests/common/mod.rs
create mode 100644 proxmox-ebpf/tests/harness.rs
diff --git a/proxmox-ebpf/build.rs b/proxmox-ebpf/build.rs
index 9cb143d955a8..e6630ec01d1d 100644
--- a/proxmox-ebpf/build.rs
+++ b/proxmox-ebpf/build.rs
@@ -18,6 +18,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");
@@ -46,8 +49,90 @@ 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,
+ ));
}
}
+
+ // the harness's own program, compiled both ways like a subsystem's, so every build runs the
+ // BPF compile and the shim through clang and the harness tests run a program without a
+ // subsystem
+ let fixture_dir = manifest_dir.join("tests").join("bpf");
+ println!("cargo:rerun-if-changed={}", fixture_dir.display());
+ for entry in std::fs::read_dir(&fixture_dir).expect("read tests/bpf") {
+ let path = entry.expect("read tests/bpf entry").path();
+ let is_program = path
+ .file_name()
+ .and_then(|s| s.to_str())
+ .is_some_and(|s| s.ends_with(".bpf.c"));
+ if is_program {
+ compile_bpf(&path, &fixture_dir, &global_include, &out_dir, bpf_debug);
+ native_objs.push(compile_native(
+ &path,
+ &fixture_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 linux/bpf.h and bpf_helpers.h
+ .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()
+ .unwrap_or_else(|e| panic!("failed to invoke {CLANG}: {e}"));
+ assert!(
+ status.success(),
+ "clang failed to compile {} natively",
+ src.display()
+ );
+ obj_path
}
fn multiarch() -> String {
diff --git a/proxmox-ebpf/src/bpf-shim/bpf/bpf_helpers.h b/proxmox-ebpf/src/bpf-shim/bpf/bpf_helpers.h
new file mode 100644
index 000000000000..b4bcfdf5717f
--- /dev/null
+++ b/proxmox-ebpf/src/bpf-shim/bpf/bpf_helpers.h
@@ -0,0 +1,34 @@
+#ifndef PROXMOX_EBPF_SHIM_BPF_HELPERS_H
+#define PROXMOX_EBPF_SHIM_BPF_HELPERS_H
+
+// Native stand-in for <bpf/bpf_helpers.h>. 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. Weak symbols of one
+// name link silently, so program and map names must stay distinct across
+// subsystems. The map definition macros keep their libbpf shapes, which are
+// plain C already.
+
+#include <linux/types.h>
+
+#define SEC(name) __attribute__((weak))
+#ifndef __always_inline
+#define __always_inline inline __attribute__((always_inline))
+#endif
+#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
+
+// the value is irrelevant natively
+#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/proxmox-ebpf/src/bpf-shim/bpf_debug.h b/proxmox-ebpf/src/bpf-shim/bpf_debug.h
new file mode 100644
index 000000000000..a7e5eac0d3b3
--- /dev/null
+++ b/proxmox-ebpf/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/proxmox-ebpf/src/bpf-shim/linux/bpf.h b/proxmox-ebpf/src/bpf-shim/linux/bpf.h
new file mode 100644
index 000000000000..ff259c329522
--- /dev/null
+++ b/proxmox-ebpf/src/bpf-shim/linux/bpf.h
@@ -0,0 +1,25 @@
+#ifndef PROXMOX_EBPF_SHIM_LINUX_BPF_H
+#define PROXMOX_EBPF_SHIM_LINUX_BPF_H
+
+// Native stand-in for <linux/bpf.h>, only what the programs touch. The other UAPI headers
+// compile natively as they are.
+
+#include <linux/types.h>
+
+// data and data_end are wide enough for native pointers, 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;
+};
+
+// map-definition constants, the values are irrelevant natively
+enum {
+ BPF_MAP_TYPE_HASH = 1,
+};
+#define BPF_F_NO_PREALLOC 1
+
+#endif
diff --git a/proxmox-ebpf/tests/bpf/fixture.bpf.c b/proxmox-ebpf/tests/bpf/fixture.bpf.c
new file mode 100644
index 000000000000..67b4fd4a38ad
--- /dev/null
+++ b/proxmox-ebpf/tests/bpf/fixture.bpf.c
@@ -0,0 +1,37 @@
+// The harness's own program, compiled like a subsystem's, its BPF object is not used. It touches
+// what a subsystem program touches, the skb fields, the helpers and a map, so the shim is run by
+// the harness tests themselves.
+#include <linux/bpf.h>
+#include <linux/pkt_cls.h>
+#include <bpf/bpf_endian.h>
+#include <bpf/bpf_helpers.h>
+
+struct {
+ __uint(type, BPF_MAP_TYPE_HASH);
+ __uint(max_entries, 16);
+ __type(key, __u32);
+ __type(value, __u8);
+} fixture_stamps SEC(".maps");
+
+// writes the interface's stamp and the frame length over the first two bytes and keeps the
+// bytes they replaced in the mark, accepts a frame of an interface without a stamp as it is
+SEC("tc")
+int fixture(struct __sk_buff *skb)
+{
+ void *data = (void *)(long)skb->data;
+ void *data_end = (void *)(long)skb->data_end;
+ if (data + 2 > data_end)
+ return TC_ACT_OK;
+ __u8 *stamp = bpf_map_lookup_elem(&fixture_stamps, &skb->ifindex);
+ if (!stamp)
+ return TC_ACT_OK;
+ __u8 bytes[2];
+ if (bpf_skb_load_bytes(skb, 0, bytes, sizeof(bytes)))
+ return TC_ACT_OK;
+ skb->mark = bpf_ntohs(*(__be16 *)bytes);
+ bytes[0] = *stamp;
+ bytes[1] = skb->len;
+ if (bpf_skb_store_bytes(skb, 0, bytes, sizeof(bytes), 0))
+ return TC_ACT_OK;
+ return TC_ACT_UNSPEC;
+}
diff --git a/proxmox-ebpf/tests/common/mod.rs b/proxmox-ebpf/tests/common/mod.rs
new file mode 100644
index 000000000000..4d9c241ad1fe
--- /dev/null
+++ b/proxmox-ebpf/tests/common/mod.rs
@@ -0,0 +1,211 @@
+//! Native harness for the subsystems' BPF C programs. A test declares a program's symbol and
+//! calls it like a plain function on a fake skb, with the helpers as bounds-checked functions
+//! over its buffer and a per-thread map registry.
+
+// every test crate compiles the harness and uses its own part of it
+#![allow(dead_code)]
+
+// 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_UNSPEC: c_int = -1;
+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/linux/bpf.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 mark(&self) -> u32 {
+ self.skb.mark
+ }
+
+ 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((self as *mut TestSkb).cast()) }
+ }
+}
+
+struct MockMap {
+ key_size: usize,
+ entries: HashMap<Vec<u8>, Box<[u8]>>,
+}
+
+thread_local! {
+ static MAPS: RefCell<HashMap<usize, MockMap>> = RefCell::new(HashMap::new());
+ static REDIRECTED: RefCell<Option<(u32, u64)>> = 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<u32> {
+ REDIRECTED.with(|r| r.borrow().map(|(ifindex, _)| ifindex))
+}
+
+/// The flags of the redirect the last run issued, zero sends out of the interface.
+pub fn redirect_flags() -> Option<u64> {
+ REDIRECTED.with(|r| r.borrow().map(|(_, flags)| flags))
+}
+
+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 mut maps = maps.borrow_mut();
+ let Some(m) = maps.get_mut(&(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_mut(key) {
+ // the box's heap allocation stays put while the registry holds it
+ Some(v) => v.as_mut_ptr() as *mut c_void,
+ None => std::ptr::null_mut(),
+ }
+ })
+}
+
+const EFAULT: c_long = nix::libc::EFAULT as c_long;
+const ENOMEM: c_long = nix::libc::ENOMEM as c_long;
+const EINVAL: c_long = nix::libc::EINVAL as c_long;
+
+#[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 {
+ // the kernel clears the destination of a failed read
+ unsafe { std::ptr::write_bytes(to as *mut u8, 0, len) };
+ return -EFAULT;
+ }
+ 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 -EFAULT;
+ }
+ 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 { -ENOMEM } 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) };
+ // the kernel refuses to cut into the headers it knows, which is the link header here.
+ // Growing stops at the harness buffer
+ let min_len = 14;
+ if new_len < min_len || new_len as usize > PKT_CAP {
+ return -EINVAL;
+ }
+ // 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, flags)));
+ TC_ACT_REDIRECT as c_long
+}
diff --git a/proxmox-ebpf/tests/harness.rs b/proxmox-ebpf/tests/harness.rs
new file mode 100644
index 000000000000..470e0a1cd88b
--- /dev/null
+++ b/proxmox-ebpf/tests/harness.rs
@@ -0,0 +1,84 @@
+//! The harness's own tests, the frame, the helpers, the map registry and a program built
+//! against the shim.
+mod common;
+
+use std::ffi::{c_int, c_void};
+
+use common::*;
+
+unsafe extern "C" {
+ fn fixture(skb: *mut SkBuff) -> c_int;
+ static fixture_stamps: c_void;
+}
+
+unsafe extern "C" fn pass(_skb: *mut SkBuff) -> c_int {
+ TC_ACT_UNSPEC
+}
+
+unsafe extern "C" fn accept(_skb: *mut SkBuff) -> c_int {
+ TC_ACT_OK
+}
+
+#[test]
+fn skb_round_trips_the_frame() {
+ let frame = [1u8, 2, 3, 4];
+ let mut skb = TestSkb::new(&frame, 7);
+ assert_eq!(skb.run(pass), TC_ACT_UNSPEC);
+ assert_eq!(skb.run(accept), TC_ACT_OK);
+ assert_eq!(skb.packet(), &frame);
+ assert_eq!(redirected(), None);
+ assert_eq!(redirect_flags(), None);
+}
+
+#[test]
+fn helpers_read_and_write_the_frame() {
+ let mut skb = TestSkb::new(&[1, 2, 3, 4, 5, 6], 7);
+ let raw = (&mut skb as *mut TestSkb).cast::<c_void>();
+ let mut out = [0u8; 2];
+ assert_eq!(bpf_skb_load_bytes(raw, 2, out.as_mut_ptr().cast(), 2), 0);
+ assert_eq!(out, [3, 4]);
+ assert_eq!(
+ bpf_skb_store_bytes(raw, 4, [9u8, 9].as_ptr().cast(), 2, 0),
+ 0
+ );
+ assert_eq!(skb.packet(), &[1, 2, 3, 4, 9, 9]);
+ // past the frame, the kernel clears the destination on a failed load
+ assert_ne!(bpf_skb_load_bytes(raw, 5, out.as_mut_ptr().cast(), 2), 0);
+ assert_eq!(out, [0, 0]);
+ assert_ne!(
+ bpf_skb_store_bytes(raw, 5, [9u8, 9].as_ptr().cast(), 2, 0),
+ 0
+ );
+ assert_eq!(skb.packet(), &[1, 2, 3, 4, 9, 9]);
+}
+
+#[test]
+fn maps_answer_registered_entries() {
+ // any address serves as a map's identity, the program hands the map's address to the helper
+ let map = 0x1000 as *const c_void;
+ register_map(map, 4);
+ map_insert(map, &7u32.to_ne_bytes(), &[9, 8, 7]);
+ let key = 7u32.to_ne_bytes();
+ let hit = bpf_map_lookup_elem(map.cast_mut(), key.as_ptr().cast());
+ assert!(!hit.is_null());
+ assert_eq!(
+ unsafe { std::slice::from_raw_parts(hit as *const u8, 3) },
+ &[9, 8, 7]
+ );
+ let key = 8u32.to_ne_bytes();
+ assert!(bpf_map_lookup_elem(map.cast_mut(), key.as_ptr().cast()).is_null());
+}
+
+#[test]
+fn a_program_runs_against_the_shim() {
+ let stamps = &raw const fixture_stamps;
+ register_map(stamps, 4);
+ map_insert(stamps, &7u32.to_ne_bytes(), &[0xab]);
+ let mut skb = TestSkb::new(&[0x12, 0x34, 5, 6], 9);
+ assert_eq!(skb.run(fixture), TC_ACT_OK);
+ assert_eq!(skb.packet(), &[0x12, 0x34, 5, 6]);
+ let mut skb = TestSkb::new(&[0x12, 0x34, 5, 6], 7);
+ assert_eq!(skb.run(fixture), TC_ACT_UNSPEC);
+ assert_eq!(skb.packet(), &[0xab, 4, 5, 6]);
+ assert_eq!(skb.mark(), 0x1234);
+}
--
2.47.3
next prev parent reply other threads:[~2026-11-04 16:02 UTC|newest]
Thread overview: 8+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-04 16:00 [PATCH proxmox v7 0/6] add proxmox-ebpf library Hannes Laimer
2026-11-04 16:01 ` [PATCH proxmox v7 1/6] ebpf: add the crate with its BPF build Hannes Laimer
2026-11-04 16:01 ` [PATCH proxmox v7 2/6] ebpf: tc: add the link plumbing Hannes Laimer
2026-11-04 16:01 ` [PATCH proxmox v7 3/6] ebpf: subsystem: add the shared tc program handling Hannes Laimer
2026-11-04 16:01 ` [PATCH proxmox v7 4/6] ebpf: subsystem: place subsystem links in a fixed order Hannes Laimer
2026-11-04 16:01 ` Hannes Laimer [this message]
2026-11-04 16:01 ` [PATCH proxmox v7 6/6] ebpf: debian: package the crate as a Rust library Hannes Laimer
2026-11-04 17:31 ` superseded: [PATCH proxmox v7 0/6] add proxmox-ebpf library Hannes Laimer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261104160105.2184145-6-h.laimer@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox