Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. uevent (netlink): a NETLINK_KOBJECT_UEVENT socket receives from the kernel the device appearance/disappearance messages. Layer 0 opens the socket, reads the messages, and decodes the wire format (action@devpath header + KEY=VALUE pairs separated by \0) via a borrowed, zero-allocation iterator.

  2. evdev (input): input devices are char devices opened through the fs family (openat). Layer 0 provides the typed reading of struct input_event records and the dedicated ioctls (EVIOC*) as typed functions (never a generic ioctl, cf. ADR-021 convention 3).

  3. sysfs: a pseudo file system; its attributes are read and written with the fs family. Layer 0 adds no specific wrapper (cf. subsection 3) — path construction and attribute parsing are layer 1 logic.

Cross-cutting characteristics of the family.

  1. Universal CLOEXEC. Every FD created by the family (uevent socket) carries CLOEXEC by default. The evdev char devices are opened by fs::openat, which already applies the same discipline.

  2. Decoding = mirror of the kernel format, not logic. The decoding of uevent messages (key=value pairs) and of input_event records reflects stable kernel ABI wire formats. This is the same category as SignalFdInfo (the signal family), which layer 0 already decodes. Conversely, building a rich device model (typed subsystem enums, device tree, uevent ↔ sysfs correlation) is logic: layer 1.

  3. Borrowed parsers, zero allocation. The uevent/input_event decoders write into (or borrow from) a buffer provided by the caller. No heap allocation in the happy path (ADR-021 convention 4).

  4. No generic ioctl. Each EVIOC* operation is a dedicated, typed function (ADR-021 convention 3). No ioctl(fd, request, ...) function is exposed.

  5. Kernel sentinels → Option<T> / typed enums (ADR-021 convention 1). EVIOCGRAB (argument 1 vs null pointer) becomes two distinct functions evdev_grab / evdev_release; the timestamp clockid becomes an enum.


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.

  • groups cannot be empty (EINVAL otherwise — nothing to listen to).
  • Listening to the KERNEL group requires no privilege. Messages from the KERNEL group always come from the kernel (nl_pid == 0).
  • CLOEXEC is 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: groups empty or invalid flags.
  • EPERM: message rejected for authenticity reasons (non-kernel source).
  • EMSGSIZE / ENOBUFS: buffer too small or receive queue saturated.
  • EAGAIN: NONBLOCK socket 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 \0action@devpath.
  • Properties: a sequence of KEY=VALUE terminated 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 add message: verify action, subsystem, complete iteration of the properties, absence of allocation (via a test allocator that panics on alloc).
  • 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/rmmod of 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 on UEventMessage::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).

  • EVIOCGRAB fails with EBUSY if another client already holds the grab.
  • evdev_set_clock only accepts Realtime/Monotonic (typing), so no EINVAL on an invalid clockid on Air’s side.
  • ENOTTY if the FD is not an evdev (wrong file type).
  • EFAULT impossible 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 via uinput (/dev/uinput) in a test harness, inject events, read them back via evdev, verify the round-trip (type/code/value). uinput also allows testing EVIOCGID, EVIOCGNAME, EVIOCGBIT deterministically.
  • Lacking the uinput privilege, 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 in COVERAGE-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 device family exposes no sysfs wrapper. Re-exposing read/write under 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’s DEVPATH with its sysfs entry, enumerating a subsystem) is logic → layer 1 (future air-device crate). Layer 0 merely provides the fs and uevent/evdev primitives 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:

CategoryMain functions
ueventuevent_socket_open, UEventSocket::read, UEventMessage::{action,subsystem,device_path,property,properties,as_bytes}
evdev — readingevdev_read_events, InputEvent::slice_from_bytes
evdev — identityevdev_driver_version, evdev_device_id, evdev_name, evdev_physical_location, evdev_unique_id
evdev — capabilitiesevdev_supported_codes, evdev_properties, evdev_abs_info
evdev — stateevdev_key_state, evdev_led_state, evdev_sound_state, evdev_switch_state
evdev — controlevdev_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/hotplug hotplug and netlink 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_SIZE constant.

In air-sys-syscall::device (calls syscalls)

  • UEventSocket — RAII owning an OwnedFd; open/read call socket/bind/recvmsg. Same rule as SignalFd / LandlockRuleset: a type that calls a syscall lives in the wrappers crate, never in air-sys-types.
  • All the evdev_* functions (ioctl/read wrappers).

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).