Layer 0 Spec — device family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::device module exposes the primitives for interacting with
the hardware devices exposed by the kernel: receiving uevent
notifications (hardware appearance/disappearance, via a netlink socket),
access to evdev input devices (/dev/input/eventX: keyboards, mice,
gamepads, touchscreens), and the articulation with sysfs (/sys/...) for
reading/writing device attributes.
This family is the system foundation for everything in the upper layers that builds a device model: hardware enumeration, hotplug, input handling for the Wayland compositor (ADR-003), layer 5 system services.
Scope of the family.
Three distinct subsystems:
-
uevent(netlink): aNETLINK_KOBJECT_UEVENTsocket receives from the kernel the device appearance/disappearance messages. Layer 0 opens the socket, reads the messages, and decodes the wire format (action@devpathheader +KEY=VALUEpairs separated by\0) via a borrowed, zero-allocation iterator. -
evdev(input): input devices are char devices opened through thefsfamily (openat). Layer 0 provides the typed reading ofstruct input_eventrecords and the dedicated ioctls (EVIOC*) as typed functions (never a generic ioctl, cf. ADR-021 convention 3). -
sysfs: a pseudo file system; its attributes are read and written with thefsfamily. Layer 0 adds no specific wrapper (cf. subsection 3) — path construction and attribute parsing are layer 1 logic.
Cross-cutting characteristics of the family.
-
Universal CLOEXEC. Every FD created by the family (uevent socket) carries
CLOEXECby default. Theevdevchar devices are opened byfs::openat, which already applies the same discipline. -
Decoding = mirror of the kernel format, not logic. The decoding of
ueventmessages (key=valuepairs) and ofinput_eventrecords reflects stable kernel ABI wire formats. This is the same category asSignalFdInfo(thesignalfamily), which layer 0 already decodes. Conversely, building a rich device model (typed subsystem enums, device tree, uevent ↔ sysfs correlation) is logic: layer 1. -
Borrowed parsers, zero allocation. The
uevent/input_eventdecoders write into (or borrow from) a buffer provided by the caller. No heap allocation in the happy path (ADR-021 convention 4). -
No generic ioctl. Each
EVIOC*operation is a dedicated, typed function (ADR-021 convention 3). Noioctl(fd, request, ...)function is exposed. -
Kernel sentinels →
Option<T>/ typed enums (ADR-021 convention 1).EVIOCGRAB(argument1vs null pointer) becomes two distinct functionsevdev_grab/evdev_release; the timestamp clockid becomes an enum.
Subsection 1: uevent — device notifications (netlink)
The kernel broadcasts, on a netlink socket of the NETLINK_KOBJECT_UEVENT
protocol, a message at each device lifecycle event (add, remove, change,
bind, unbind, move, online, offline). This is the modern hotplug
mechanism, replacing the old /sbin/hotplug.
The socket: UEventSocket
#![allow(unused)]
fn main() {
pub struct UEventSocket { /* opaque, owns an internal OwnedFd */ }
pub fn uevent_socket_open(
groups: UEventGroups,
flags: UEventSocketFlags,
) -> Result<UEventSocket, Errno>;
bitflags! {
/// Netlink multicast groups to listen to.
pub struct UEventGroups: u32 {
/// Raw messages generated by the kernel (netlink group 1).
const KERNEL = 1 << 0;
/// Messages re-broadcast by the userspace device manager
/// (netlink group 2, the "libudev monitor").
const USERSPACE = 1 << 1;
}
}
bitflags! {
pub struct UEventSocketFlags: i32 {
const NONBLOCK = 0x800; // SOCK_NONBLOCK
const CLOEXEC = 0x80000; // SOCK_CLOEXEC (always enabled by the wrapper)
}
}
impl UEventSocket {
pub fn as_fd(&self) -> BorrowedFd<'_>;
pub fn into_fd(self) -> OwnedFd;
/// Reads a message and decodes it in place into `buffer`.
///
/// The returned `UEventMessage` **borrows** `buffer`: it stays valid as long
/// as the buffer is not reused.
pub fn read<'b>(
&self,
buffer: &'b mut [u8],
) -> Result<UEventMessage<'b>, Errno>;
}
}
Underlying syscalls. socket (AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC,
NETLINK_KOBJECT_UEVENT = 15) then bind on a struct sockaddr_nl
(nl_family = AF_NETLINK, nl_pid = 0 — the kernel assigns, nl_groups
according to UEventGroups). Reading uses recvmsg (and not recv) in order to
retrieve the source address and verify authenticity (cf. below). Numbers:
socket (x86_64 #41, ARM64 #198), bind (x86_64 #49, ARM64 #200),
recvmsg (x86_64 #47, ARM64 #212).
Preconditions.
groupscannot be empty (EINVALotherwise — nothing to listen to).- Listening to the
KERNELgroup requires no privilege. Messages from theKERNELgroup always come from the kernel (nl_pid == 0). CLOEXECis always enabled by the wrapper.
Behavior.
Creates and binds the socket in a single operation. On each read, the kernel
delivers one complete uevent message. If the buffer is too small, the
message is truncated and the error EMSGSIZE/ENOBUFS may be reported depending
on the mode — the caller therefore sizes the buffer generously (cf. “Sizing”).
Authenticity — anti-spoofing verification.
Any process holding CAP_NET_ADMIN can emit to a netlink multicast group. To
avoid mistaking a real kernel uevent for a forged message, the wrapper
systematically verifies, via recvmsg, that the source address has
nl_pid == 0 (kernel) and that the SCM_CREDENTIALS credential, if the
USERSPACE mode is used, corresponds to uid == 0. A message that fails this
verification triggers Errno::EPERM (the message is consumed and rejected).
Decision (layer 0 “abstract without hiding”). The wrapper validates the source because it is a safety precondition of the mechanism itself (without it, the API would be a trap). It goes no further: it does not filter by subsystem, does not deduplicate, does not correlate with
sysfs. These conveniences are layer 1 logic.
Buffer sizing.
A kernel uevent message almost always fits under 2 KiB, but can reach ~16 KiB in
extreme cases (long property lists). Recommendation: a 8192-byte buffer. A
constant UEVENT_RECOMMENDED_BUFFER_SIZE = 8192 is exposed as a guideline.
Errors.
EINVAL:groupsempty or invalid flags.EPERM: message rejected for authenticity reasons (non-kernel source).EMSGSIZE/ENOBUFS: buffer too small or receive queue saturated.EAGAIN:NONBLOCKsocket with no message available.EMFILE,ENFILE,ENOMEM: resource limits.
Performance.
Opening: ~10-20 µs. Reading an available message: ~2-5 µs. The event rate is intrinsically low (hotplug), hence not critical.
The decoded message: UEventMessage
#![allow(unused)]
fn main() {
pub struct UEventMessage<'b> { /* borrows &'b [u8] */ }
impl<'b> UEventMessage<'b> {
/// The action (header before the first `\0`), e.g. `add`, `remove`.
/// A typed subset for the known actions, raw otherwise.
pub fn action(&self) -> UEventAction;
/// The `DEVPATH` relative to `/sys` (extracted from the header or the properties).
pub fn device_path(&self) -> Option<&'b [u8]>;
/// The subsystem (`SUBSYSTEM=...`), e.g. `usb`, `input`, `block`.
pub fn subsystem(&self) -> Option<&'b [u8]>;
/// The value of an arbitrary property by key.
pub fn property(&self, key: &[u8]) -> Option<&'b [u8]>;
/// Iterates over all `(key, value)` pairs without allocating.
pub fn properties(&self) -> UEventProperties<'b>;
/// The raw bytes of the message (for diagnostics / passthrough).
pub fn as_bytes(&self) -> &'b [u8];
}
pub struct UEventProperties<'b> { /* cursor over the buffer */ }
impl<'b> Iterator for UEventProperties<'b> {
type Item = (&'b [u8], &'b [u8]); // (key, value), borrowed slices
fn next(&mut self) -> Option<Self::Item>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UEventAction {
Add,
Remove,
Change,
Move,
Online,
Offline,
Bind,
Unbind,
/// Unrecognized action; the slice is the raw header.
Other,
}
}
Decoded wire format.
A kernel uevent message has the form:
add@/devices/pci0000:00/.../input/input12\0ACTION=add\0DEVPATH=/devices/.../input12\0SUBSYSTEM=input\0...\0
- Header: everything before the first
\0—action@devpath. - Properties: a sequence of
KEY=VALUEterminated by\0.
The decoder makes no copy: action(), subsystem(), property() and the
properties() iterator return slices that point into the caller’s buffer. Keys
and values are bytes (&[u8]), not str: the kernel does not guarantee
UTF-8 (ADR — layer 0, “zero assumption about encodings”, Principle 3).
Conversion to str is left to the caller.
Note on the USERSPACE (libudev) format.
Messages from the USERSPACE group are prefixed with a binary libudev header
(magic 0xfeedcafe, offsets). The decoder detects it and exposes the properties
the same way; the binary header is hidden behind the API. If the magic is absent
or corrupted, read returns EBADMSG.
Tests.
- Decoding a synthetic
addmessage: verifyaction,subsystem, complete iteration of the properties, absence of allocation (via a test allocator that panics onalloc). - Truncated / headerless message → clean error, no panic or OOB (slicing via
get, never direct indexing — Principle 3). - End-to-end test (privileged, marked “ignore” by default): trigger a real
uevent (e.g.
modprobe/rmmodof a dummy module, or a write to/sys/.../uevent) and read the message. - Property-based (proptest): for any byte buffer, the decoder never panics and the iterator terminates.
- Fuzzing (
cargo-fuzz): the uevent decoder accepts external data (the kernel buffer) → mandatory fuzz harness onUEventMessage::parse.
Subsection 2: evdev — input devices
Linux input devices are exposed under /dev/input/eventX. They are opened
with the fs family (openat for reading, possibly OpenFlags::NONBLOCK) — the
device family has no dedicated opening function, an evdev being an ordinary
char device. What layer 0 adds: the typed reading of events and the
EVIOC* ioctls for querying and control.
Reading events: InputEvent
#![allow(unused)]
fn main() {
/// `#[repr(C)]` mirror of `struct input_event` (24 bytes on LP64).
/// Fields keep the kernel names (ADR-029, "mirror type" nuance).
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct InputEvent {
/// Seconds of the timestamp (`struct timeval::tv_sec`, `time_t`).
/// **Signed**: `time_t`/`suseconds_t` are `long` on LP64 (our 2 targets).
pub sec: i64,
/// Microseconds of the timestamp (`struct timeval::tv_usec`, `suseconds_t`).
pub usec: i64,
/// Event type (`EV_KEY`, `EV_REL`, `EV_ABS`, `EV_SYN`...).
pub event_type: u16,
/// Code (key, axis, button) depending on the type.
pub code: u16,
/// Value (1/0 for a key, delta for `EV_REL`, absolute for `EV_ABS`).
pub value: i32,
}
/// Reads a batch of events into `events`, without allocation.
/// Returns the number of **complete** events read.
pub fn evdev_read_events(
device: BorrowedFd<'_>,
events: &mut [InputEvent],
) -> Result<usize, Errno>;
}
Underlying syscall. read (x86_64 #0, ARM64 #63) on the evdev FD. The
kernel delivers an integer multiple of size_of::<input_event>() (24 bytes on
both of Air’s LP64 targets, x86_64 and aarch64 — no y2038 splitting since long
is 64 bits).
Behavior.
evdev_read_events reads into the events slice reinterpreted as bytes, then
returns the number of complete events. If the kernel returns a byte count that is
not a multiple of 24 (should never happen), the wrapper reports EPROTO rather
than exposing a partial event. On a NONBLOCK FD with no data: EAGAIN.
Why sec/usec as u64 rather than an Instant.
The evdev timestamp comes from a configurable clock (CLOCK_REALTIME by
default, or CLOCK_MONOTONIC via evdev_set_clock). Layer 0 reflects the two
kernel fields as-is; correlating with Instant/Duration (the time
family) is layer 1 logic, which knows which clock was chosen.
Pure decoder (no syscall)
#![allow(unused)]
fn main() {
impl InputEvent {
/// Reinterprets a byte buffer as a slice of `InputEvent` (zero copy).
/// `None` if the length is not a multiple of `size_of::<InputEvent>()`
/// or if the alignment is not respected.
pub fn slice_from_bytes(bytes: &[u8]) -> Option<&[InputEvent]>;
}
}
Useful when the bytes come from elsewhere (io_uring, mmap). A pure type, it
lives in air-sys-types.
Device identity and description
#![allow(unused)]
fn main() {
/// `EVIOCGVERSION` — evdev protocol version of the driver.
pub fn evdev_driver_version(device: BorrowedFd<'_>) -> Result<u32, Errno>;
/// `EVIOCGID` — bus/vendor/product/version identifier.
pub fn evdev_device_id(device: BorrowedFd<'_>) -> Result<InputId, Errno>;
/// `EVIOCGNAME(len)` — device name, written into `buffer`.
/// Returns the slice actually filled (bytes, possibly non-UTF-8).
pub fn evdev_name<'b>(
device: BorrowedFd<'_>,
buffer: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGPHYS(len)` — physical location (topology), e.g. `usb-0000:00:14.0-1/input0`.
pub fn evdev_physical_location<'b>(
device: BorrowedFd<'_>,
buffer: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGUNIQ(len)` — unique identifier (often empty).
pub fn evdev_unique_id<'b>(
device: BorrowedFd<'_>,
buffer: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `#[repr(C)]` mirror of `struct input_id` (kernel names kept, ADR-029).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputId {
pub bustype: u16,
pub vendor: u16,
pub product: u16,
pub version: u16,
}
}
Capabilities: event bits and properties
#![allow(unused)]
fn main() {
/// `EVIOCGBIT(event_type, len)` — bitmap of the codes supported for a given
/// event type. `event_type = None` queries the supported **types**
/// (equivalent to `EVIOCGBIT(0, len)`).
pub fn evdev_supported_codes<'b>(
device: BorrowedFd<'_>,
event_type: Option<EventType>,
bitmap: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGPROP(len)` — device properties (`INPUT_PROP_*`:
/// direct pointer, semi-mt, button-pad...).
pub fn evdev_properties<'b>(
device: BorrowedFd<'_>,
bitmap: &'b mut [u8],
) -> Result<&'b [u8], Errno>;
/// `EVIOCGABS(axis)` — range and state of an absolute axis (`ABS_X`, `ABS_MT_*`...).
pub fn evdev_abs_info(
device: BorrowedFd<'_>,
axis: AbsAxis,
) -> Result<InputAbsInfo, Errno>;
/// `#[repr(C)]` mirror of `struct input_absinfo` (kernel names kept).
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputAbsInfo {
pub value: i32,
pub minimum: i32,
pub maximum: i32,
pub fuzz: i32,
pub flat: i32,
pub resolution: i32,
}
}
EventType and AbsAxis are typed enums (the kernel’s EV_* and ABS_*
constants) — layer 0 exposes named values rather than magic integers (ADR-029).
A Raw(u16) variant allows passing a not-yet-named code without blocking.
Current state
#![allow(unused)]
fn main() {
/// `EVIOCGKEY(len)` — current state (pressed/released) of all keys.
pub fn evdev_key_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
/// `EVIOCGLED(len)` — state of the LEDs.
pub fn evdev_led_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
/// `EVIOCGSND(len)` — state of the sound outputs.
pub fn evdev_sound_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
/// `EVIOCGSW(len)` — state of the switches (lid, jack...).
pub fn evdev_switch_state<'b>(d: BorrowedFd<'_>, bitmap: &'b mut [u8]) -> Result<&'b [u8], Errno>;
}
Exclusive control and clock
#![allow(unused)]
fn main() {
/// `EVIOCGRAB` with argument `1` — exclusive grab of the device.
/// (ADR-021 conv. 1: no magic argument, two distinct functions.)
pub fn evdev_grab(device: BorrowedFd<'_>) -> Result<(), Errno>;
/// `EVIOCGRAB` with a null pointer — releases the exclusive grab.
pub fn evdev_release(device: BorrowedFd<'_>) -> Result<(), Errno>;
/// `EVIOCREVOKE` — permanently revokes access to this FD (irreversible).
/// Used by display servers to neutralize a handed-over FD.
pub fn evdev_revoke(device: BorrowedFd<'_>) -> Result<(), Errno>;
/// `EVIOCSCLOCKID` — chooses the clock for the event timestamps.
pub fn evdev_set_clock(
device: BorrowedFd<'_>,
clock: EventClock,
) -> Result<(), Errno>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventClock {
/// `CLOCK_REALTIME` (kernel default).
Realtime,
/// `CLOCK_MONOTONIC` (recommended for input correlation).
Monotonic,
}
}
Underlying syscall (all EVIOC*). ioctl (x86_64 #16, ARM64 #29), each
request being a distinct EVIOC* constant. No generic ioctl wrapper is
exposed (ADR-021 convention 3): each operation is a typed function above. The
request values (_IOR('E', ...) etc.) are computed internally in
air-sys-syscall::device.
Preconditions and errors (cross-cutting evdev).
EVIOCGRABfails withEBUSYif another client already holds the grab.evdev_set_clockonly acceptsRealtime/Monotonic(typing), so noEINVALon an invalid clockid on Air’s side.ENOTTYif the FD is not an evdev (wrong file type).EFAULTimpossible to reach from the safe API (buffers provided by Air).
Performance.
Query ioctl: ~1-3 µs. evdev_read_events: ~1-2 µs per batch. The grab is
negligible.
Tricky tests.
- The nominal tests for the
EVIOC*require a real evdev. Strategy: create a virtual device viauinput(/dev/uinput) in a test harness, inject events, read them back via evdev, verify the round-trip (type/code/value).uinputalso allows testingEVIOCGID,EVIOCGNAME,EVIOCGBITdeterministically. - Lacking the
uinputprivilege, the tests are marked “ignore” with an explicit skip, and the decoding logic (InputEvent::slice_from_bytes) is tested purely on synthetic buffers + proptest + fuzz. - Coverage: the ioctl error branches that are hard to trigger (e.g.
ENOTTY) are recorded inCOVERAGE-EXCEPTIONS.md(category “feature/kernel” or “impossible-value”).
Subsection 3: sysfs — no dedicated wrapper
sysfs (/sys/...) is a pseudo file system. Reading an attribute
(/sys/class/input/event3/device/name), writing into a uevent
(/sys/.../uevent to re-trigger an event), traversing the tree
(/sys/devices/...): all of this is done with the fs family (openat,
read, write, getdents64).
Decision (layer 0, anti-duplication). The
devicefamily exposes nosysfswrapper. Re-exposingread/writeunder a “sysfs” name would be valueless duplication, and all the added value (building the path/sys/class/<subsystem>/<name>/<attr>, parsing an attribute as an integer/boolean, correlating a uevent’sDEVPATHwith its sysfs entry, enumerating a subsystem) is logic → layer 1 (futureair-devicecrate). Layer 0 merely provides thefsanduevent/evdevprimitives on which this logic will rely.
This subsection exists to explicitly remove the ambiguity: if a developer
looks for “the layer 0 sysfs function”, the answer is: there is none, it’s fs
- (layer 1).
Device family summary
Exposed functions:
| Category | Main functions |
|---|---|
| uevent | uevent_socket_open, UEventSocket::read, UEventMessage::{action,subsystem,device_path,property,properties,as_bytes} |
| evdev — reading | evdev_read_events, InputEvent::slice_from_bytes |
| evdev — identity | evdev_driver_version, evdev_device_id, evdev_name, evdev_physical_location, evdev_unique_id |
| evdev — capabilities | evdev_supported_codes, evdev_properties, evdev_abs_info |
| evdev — state | evdev_key_state, evdev_led_state, evdev_sound_state, evdev_switch_state |
| evdev — control | evdev_grab, evdev_release, evdev_revoke, evdev_set_clock |
| sysfs | (none — see subsection 3) |
Total: ~22 main public functions.
Non-wrapped syscalls (listed in UNSUPPORTED.md):
EVIOCSABS(set abs info): rare write, reserved for calibration; to be added later without breaking the API if needed.EVIOCGKEYCODE/EVIOCSKEYCODE(and_V2): scancode remapping; specialized, out of the initial layer 0 scope.EVIOCGMTSLOTS: per-slot multi-touch state; possible future addition.EVIOCSFF/EVIOCRMFF/force feedback: force feedback; dedicated later effort.- The old
/sbin/hotplughotplug andnetlink genl: obsolete / out of scope.
Distribution of types between the two crates
In air-sys-types (pure, no syscall)
InputEvent,InputId,InputAbsInfo—#[repr(C)]mirrors of kernel structures (fields with kernel names, ADR-029).EventType,AbsAxis,EventClock,UEventAction— typed enums.UEventGroups,UEventSocketFlags— bitflags.UEventMessage<'b>,UEventProperties<'b>— borrowed views, pure decoding (no syscall); placed with the types since the parsing does not touch the kernel.- The
UEVENT_RECOMMENDED_BUFFER_SIZEconstant.
In air-sys-syscall::device (calls syscalls)
UEventSocket— RAII owning anOwnedFd;open/readcallsocket/bind/recvmsg. Same rule asSignalFd/LandlockRuleset: a type that calls a syscall lives in the wrappers crate, never inair-sys-types.- All the
evdev_*functions (ioctl/readwrappers).
That is ~10 types added to air-sys-types.
Underlying decisions that emerged in the device family
1. uevent/input_event decoding in layer 0 (borrowed parsers).
The key=value (uevent) and struct input_event formats are stable kernel
ABI wire formats. Reflecting them without allocating is structure
mirroring, exactly like SignalFdInfo: layer 0 does it. The boundary with
layer 1 is clear-cut: decoding the format = layer 0; interpreting (subsystem
enums, device model, correlation) = layer 1.
2. UEventSocket::read verifies authenticity.
Without the nl_pid == 0 / uid == 0 verification, the API would be a security
trap (forgeable uevents). The validation is therefore a safety precondition of
the primitive, legitimate in layer 0 — distinct from a convenience.
3. No dedicated evdev opening.
An evdev is a char device: you open it with fs::openat. The device family
only adds what is specific (typed reading + EVIOC*).
4. No sysfs wrapper.
Anti-duplication of fs; all the sysfs added value is layer 1 logic. A dedicated
subsection to remove the ambiguity.
5. EVIOCGRAB → two functions (grab/release).
Application of ADR-021 conv. 1: the magic argument (1 vs null pointer) becomes
two explicit functions, without a sentinel.
6. Bitmaps returned as borrowed slices.
EVIOCGBIT/EVIOCGPROP/EVIOCG{KEY,LED,SND,SW} write into a buffer provided by
the caller: zero allocation (ADR-021 conv. 4). The bit-by-bit interpretation
(“is key K supported?”) is a layer 1 helper.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::device module (layer 0).