Building a Small Rust eBPF EDR 2: Stable Event ABI
Table of Contents
Part 1 explained the overall structure of rand-guard. This post focuses on the boundary between the eBPF programs and the userspace runtime. That boundary is not just a data pipe. For userspace to safely interpret raw events created in the kernel, the event layout, version, size, and kind must be defined consistently.
In rand-guard, crates/common owns that role. This crate defines the shared event schema used by both eBPF and userspace.
Why the ABI Matters
An eBPF program creates an event struct in kernel context and submits it to the EVENTS ring buffer. Userspace reads a byte slice from that ring buffer and interprets it as a specific Rust struct.
If both sides disagree about the struct layout, the result is dangerous. The eBPF side may think it submitted a ProcessExecEvent, while userspace reads it with a different size or field order and prints an incorrect pid, path, or port. In security telemetry, a wrong event is more than a simple bug. It can lead an investigator to make a decision from bad evidence.
For that reason, rand-guard manages the shared ABI with these principles.
- eBPF and userspace refer to the same Rust types.
- Structs crossing the ring buffer use
#[repr(C)]. - Every event begins with a common
EventHeader. - Userspace checks the schema version and event size.
- String fields use fixed-size buffers.
- Truncated strings are marked with truncation flags.
The Role of crates/common
crates/common/src/lib.rs is written with #![no_std] because the eBPF side must be able to use it.
The shared constants have these meanings.
pub const EVENT_SCHEMA_VERSION: u16 = 1;
pub const COMM_LEN: usize = 16;
pub const PATH_LEN: usize = 256;
pub const EVENT_FLAG_FILENAME_TRUNCATED: u16 = 1 << 0;
COMM_LEN reflects the Linux process comm length. PATH_LEN is the size of the path buffer stored in events. If a longer path appears, the project does not try to collect it all. It stores what fits and marks the event with a truncation flag.
This favors safety and consistency over complete collection. Handling arbitrary long strings or dynamic allocation inside eBPF is not a good direction. Small fixed buffers and explicit truncation fit the current project better.
EventHeader and EventKind
Every raw event has a common header. The header contains the schema version, event kind, struct size, timestamp, pid, tid, uid, gid, and related metadata.
When userspace reads bytes from the ring buffer, it checks the header first.
raw bytes
-> read EventHeader
-> check schema version
-> check event kind
-> check expected size
-> read concrete event struct
The event kind identifies concrete event types such as ProcessExec, FileOpen, and NetworkConnect. The userspace dispatcher uses that kind to decide which struct should be read.
flowchart TD
A[Ring buffer bytes] --> B[Read EventHeader]
B --> C{schema version ok?}
C -- no --> X[InvalidSchema]
C -- yes --> D{event kind}
D --> E[Process event]
D --> F[File event]
D --> G[Network event]
E --> H[Normalize]
F --> H
G --> H
This flow lives in crates/user/src/dispatch.rs. dispatch_event first checks whether the byte slice is large enough to contain a header. It then compares the header version with EVENT_SCHEMA_VERSION. After that, it checks the expected struct size for each event kind and only then reads the concrete event with read_unaligned.
Why Size Checks Are Needed
The schema version alone is not enough. During development, adding a field or changing field order can change an event struct size. If userspace reads an event using an old size, it may miss the tail of the event or try to read bytes that are not there.
For that reason, each event has a SIZE, and the dispatcher compares header.size with core::mem::size_of::<EventType>(). If they do not match, the event is not normalized and is treated as InvalidSchema.
This is conservative on purpose. Unknown events are not forced into plausible-looking JSON. In a telemetry pipeline, it is better to drop an incomplete event than to emit misleading output.
Strings and Truncation
Process names and file paths are important event fields. But strings in eBPF always need bounded handling.
rand-guard stores comm and paths as fixed-size byte arrays. The eBPF program performs bounded reads, and userspace builds a String from the null terminator or valid length. If a path is longer than the buffer, the event marks filename_truncated through a flag.
That information is reflected in output.
{"event_type":"process_start","comm":"bash","filename_truncated":false}
The truncation flag matters because it prevents an investigator from assuming a path is complete. In security logs, not only the value but also whether the value is complete has meaning.
FileFilterConfig Is Also ABI
The common crate also contains FileFilterConfig, not only event structs. This is the settings struct that userspace writes into the eBPF FILE_FILTER map.
pub struct FileFilterConfig {
pub prefix_count: u32,
pub prefixes: [[u8; FILE_FILTER_PREFIX_LEN]; FILE_FILTER_MAX_PREFIXES],
pub prefix_lens: [u32; FILE_FILTER_MAX_PREFIXES],
}
This struct must also have the same layout on both sides. Userspace fills it with watch path prefixes and writes it into the map. The eBPF file programs can then apply kernel-side prefix filtering.
However, if watch patterns are configured or too many prefixes are present, the kernel-side prefix filter is disabled. Pattern matching is safer and more flexible in userspace.
How to Treat ABI Changes
ABI changes in this project should not be treated lightly. Adding a field to an event struct is not just a Rust refactor. It changes the kernel/userspace boundary.
A good ABI change should satisfy these conditions.
- The new field is actually needed for detection or output.
- It can be collected safely in eBPF.
- Its stack and ring-buffer cost fit inside the fixed-size event model.
- Userspace normalization and output tests change with it.
- Truncation or missing values are represented clearly.
If a change does not pass those checks, it is better not to add the field. A small event schema helps not only performance but also explainability.
Part 2 Summary
This post covered the event ABI used by rand-guard.
crates/commonis the schema shared by eBPF and userspace.- Every event has version, kind, and size through
EventHeader. - The userspace dispatcher normalizes events only after schema and size checks.
- Strings use fixed-size buffers and explicit truncation flags.
- ABI changes directly affect the reliability of security telemetry.
The next post covers process lifecycle telemetry. It explains how execve, execveat, fork, and exit events feed into the process table and enrich later file and network events.