Spec couche 1 — air-device (énumération, surveillance, identité des périphériques)
Spécification technique — Version 1.0. Couche 1 « Primitives système ». Dernière crate couche 1.
Position et méthode
air-device construit, au-dessus de la famille device couche 0 (uevent
netlink + evdev + sysfs via fs), le modèle de périphériques d’Air :
énumération du matériel, surveillance du hotplug, accès typé aux propriétés et à
l’identité. C’est l’équivalent fonctionnel de libudev, réécrit nativement en
Rust, sans aucune dépendance C.
Décision de socle (gravée, 2026-06-12).
air-deviceest natif : zérolibudev, zéro dépendance àsystemd-udevd. Il énumère en parcourant/syslui-même (viaair-filesystem) et reçoit le hotplug en lisant le socket uevent kernel (couche 0). Conséquence assumée : Air ne dépend pas, pour la découverte de matériel, d’un démon systemd qui tourne — c’est le premier maillon de l’indépendance progressive vis-à-vis de systemd (cf. stratégie de remplacement : udevd est le candidat précoce, peu risqué). À réconcilier : la ligne «libudeven couche 1 » d’ADR-005 doit être amendée (pré-public → édition directe ou RFC, ADR-015). Même nature que les réconciliations passées : AirLog→socket journald natif, DNS maison→pas degetaddrinfo.
Règle du joint (anti-contrat-à-vie). La surface publique d’
air-devices’exprime exclusivement dans le vocabulaire d’Air —AirDevice,AirSubsystem, propriétés typées — jamais un miroir d’udev(udev_device, nomsID_*bruts). C’est ce qui rend l’implémentation dessous (sysfs natif aujourd’hui, autre chose demain) remplaçable sans douleur. À vérifier en CI : aucune couche > 1 ne dépend d’un type systemd/udev-spécifique.
API Rust d’abord ; ABI C (libair-base/dédié) différée. Méthode
doc-d’abord : ce contrat est validé avant implémentation.
Conventions de couche 1 (rappel)
Rust idiomatique, aucun unsafe exposé ; couverture 100 % (couche
fondatrice) ; nommage explicite (ADR-029) ; arithmétique défensive (Principe 2) ;
zéro présomption d’encodage (Principe 3 — les noms/attributs sysfs sont des
octets, pas forcément UTF-8 → AirString faillible vs &[u8]/AirPath) ;
validation en amont (Principe 4).
Dépendances assumées (règle des 80 %, ADR-024)
- Aucune dépendance externe. Pur Rust sur la couche 0 (
air-sys-syscall::device,::fs) et la couche 1 (air-base-lib,air-filesystem). Pas delibudev. - Pas de logique politique (macro-archi) :
air-deviceénumère, identifie, notifie. Les décisions (autoriser/monter/appliquer une règle) sont en couche 5.
Carte de consommation couche 0 (pour le journal de dette doc) :
- énumération/attributs →
air-filesystem(/sys:openat,read,getdents64, lecture de symlinks pour la résolutionDEVPATH/parent) ;- hotplug →
device::UEventSocket+UEventMessage(décodeur emprunté couche 0, déjà fuzzé) ;- identité d’entrée →
device::evdev_*(§5).
Section 1 — Le modèle : AirDevice
Un AirDevice est un nœud de l’arbre /sys/devices, identifié par son chemin
sysfs. Construction paresseuse : les attributs sont lus à la demande (un
device est bon marché à créer, on ne lit /sys que si on interroge).
#![allow(unused)]
fn main() {
/// Un périphérique du système, ancré sur son entrée sysfs.
pub struct AirDevice { /* syspath possédé + cache d'attributs paresseux */ }
impl AirDevice {
/// Construit depuis un chemin sysfs absolu (`/sys/devices/...`).
/// # Errors `NotFound` si le chemin n'existe pas / n'est pas un device.
pub fn from_sys_path(path: &AirPath) -> AirResult<Self>;
/// Construit depuis un `DEVPATH` d'uevent (relatif à `/sys`).
pub fn from_dev_path(devpath: &[u8]) -> AirResult<Self>;
/// Nom court du device (dernier composant du syspath), p. ex. `event3`, `sda`.
pub fn sys_name(&self) -> &AirPath;
/// Chemin sysfs absolu.
pub fn sys_path(&self) -> &AirPath;
/// Sous-système (`/sys/.../subsystem` résolu), typé.
pub fn subsystem(&self) -> AirResult<AirSubsystem>;
/// Nœud `/dev` associé (`DEVNAME`), s'il existe (p. ex. `/dev/input/event3`).
pub fn device_node(&self) -> AirResult<Option<AirPath>>;
/// Lit un attribut sysfs brut (octets), p. ex. `name`, `uevent`, `dev`.
/// # Errors `NotFound` si l'attribut n'existe pas.
pub fn attribute(&self, name: &str) -> AirResult<AirByteBuffer>;
/// Attribut interprété en UTF-8 trimé (commodité ; faillible, Principe 3).
pub fn attribute_str(&self, name: &str) -> AirResult<AirString>;
/// Attribut parsé en entier (base 10 ou `0x`). # Errors `InvalidData`.
pub fn attribute_int(&self, name: &str) -> AirResult<i64>;
/// Écrit un attribut (p. ex. déclencher `uevent`, régler `power/control`).
/// **Privilégié** selon l'attribut. # Errors `PermissionDenied`.
pub fn write_attribute(&self, name: &str, value: &[u8]) -> AirResult<()>;
/// Propriétés `uevent` du device (lues depuis `/sys/.../uevent`), itérables.
pub fn properties(&self) -> AirResult<AirDeviceProperties>;
/// Valeur d'une propriété par clé.
pub fn property(&self, key: &str) -> AirResult<Option<AirByteBuffer>>;
/// Parent direct dans l'arbre sysfs (`..` jusqu'au prochain device réel).
pub fn parent(&self) -> AirResult<Option<AirDevice>>;
/// Premier ancêtre du sous-système (et `devtype`) donné — remplace
/// `udev_device_get_parent_with_subsystem_devtype`.
pub fn parent_with_subsystem(&self, subsystem: AirSubsystem)
-> AirResult<Option<AirDevice>>;
}
}
AirSubsystem : enum typé des sous-systèmes courants (Input, Block, Net,
Usb, Pci, Tty, Hwmon, Drm, Sound…) + Other(AirString) pour ceux
non encore nommés (jamais de blocage — cohérent ADR-029 « variante Raw »).
Section 2 — Énumération : AirDeviceEnumerator
#![allow(unused)]
fn main() {
/// Parcourt `/sys` et liste les devices correspondant à un filtre.
pub struct AirDeviceEnumerator { /* filtres accumulés */ }
impl AirDeviceEnumerator {
pub fn new() -> Self;
/// Restreint à un sous-système (`/sys/class/<sub>` ou `/sys/bus/<sub>`).
pub fn match_subsystem(self, subsystem: AirSubsystem) -> Self;
/// Restreint par nom court (`sys_name`).
pub fn match_sys_name(self, pattern: &str) -> Self;
/// Restreint aux devices ayant une propriété `key=value`.
pub fn match_property(self, key: &str, value: &str) -> Self;
/// Restreint aux devices ayant l'attribut sysfs `name=value`.
pub fn match_attribute(self, name: &str, value: &[u8]) -> Self;
/// Exécute le parcours et retourne les devices (allocation autorisée
/// couche 1 ; le parcours sysfs est intrinsèquement allouant).
/// # Errors propage les erreurs `fs` non triviales (≠ `NotFound` toléré).
pub fn enumerate(&self) -> AirResult<Vec<AirDevice>>;
}
}
Le parcours suit /sys/class/<subsystem>/ et /sys/bus/<subsystem>/devices/,
résout les symlinks vers /sys/devices/..., déduplique par syspath
canonique. Filtrage paresseux : un device n’est matérialisé que s’il passe le
filtre (on ne lit pas tous les attributs de toute la machine).
Section 3 — Surveillance du hotplug : AirDeviceMonitor
#![allow(unused)]
fn main() {
/// Flux d'événements d'apparition/disparition de matériel.
pub struct AirDeviceMonitor { /* UEventSocket + filtres */ }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AirDeviceAction { Added, Removed, Changed, Bound, Unbound, Moved, Online, Offline, Other }
/// Un événement hotplug : action + device concerné.
pub struct AirDeviceEvent { /* action + AirDevice + propriétés de l'événement */ }
impl AirDeviceEvent {
pub fn action(&self) -> AirDeviceAction;
pub fn device(&self) -> &AirDevice;
pub fn property(&self, key: &str) -> Option<&[u8]>;
}
impl AirDeviceMonitor {
/// Ouvre la surveillance sur le **groupe kernel** (brut, sans démon udev).
/// # Errors si le socket netlink ne peut être ouvert/lié.
pub fn open() -> AirResult<Self>;
/// Filtre côté Air par sous-système (n'expose que les events pertinents).
pub fn match_subsystem(self, subsystem: AirSubsystem) -> Self;
/// FD sous-jacent, pour intégration dans un reactor (io_uring/poll).
pub fn as_fd(&self) -> BorrowedFd<'_>;
/// Lit le prochain événement (bloquant, ou `WouldBlock` si non-bloquant).
/// Décodé via le décodeur uevent couche 0 (emprunté), puis matérialisé.
pub fn next_event(&mut self) -> AirResult<AirDeviceEvent>;
}
}
Décision (groupe
KERNEL, autonomie). On écoute le groupe netlink kernel (brut), pas le groupeUSERSPACEre-diffusé parudevd— c’est ce qui rend la surveillance indépendante d’un démon systemd. Contrepartie honnête : un event kernel peut précéder la stabilisation complète de/sys(un attribut pas encore peuplé). Mitigations spécifiées :AirDevicelit les attributs à la demande (pas au moment de l’event), et un consommateur qui a besoin d’un attribut tardif re-lit (le device est ré-interrogeable). On ne ré-implémente pas le moteur de règles d’udev (hors périmètre couche 1 ; politique = couche 5).
Section 4 — Sous-systèmes typés (cœur v1)
Tout AirDevice expose ses propriétés en générique (clé/valeur). Au-dessus,
quelques sous-systèmes à forte valeur reçoivent un accès typé (vues légères
construites depuis sysfs), extensibles au besoin :
#![allow(unused)]
fn main() {
/// Vue typée d'un device réseau (`/sys/class/net/<iface>`).
pub struct AirNetDevice<'d>(&'d AirDevice);
impl AirNetDevice<'_> {
pub fn interface_name(&self) -> AirResult<AirString>; // sys_name
pub fn mac_address(&self) -> AirResult<AirMacAddress>; // address
pub fn is_up(&self) -> AirResult<bool>; // operstate/flags
}
/// Vue typée d'un device bloc (`/sys/class/block/<name>`).
pub struct AirBlockDevice<'d>(&'d AirDevice);
impl AirBlockDevice<'_> {
pub fn size_bytes(&self) -> AirResult<u64>; // size × 512
pub fn is_removable(&self) -> AirResult<bool>; // removable
pub fn model(&self) -> AirResult<Option<AirString>>;
}
/// Vue typée d'un device USB (`bus usb`).
pub struct AirUsbDevice<'d>(&'d AirDevice);
impl AirUsbDevice<'_> {
pub fn vendor_id(&self) -> AirResult<u16>; // idVendor (hex)
pub fn product_id(&self) -> AirResult<u16>; // idProduct (hex)
pub fn serial(&self) -> AirResult<Option<AirString>>;
}
}
Décision (v1 : générique + 3 typés).
net,block,usbsont typés (les plus utiles tôt) ; tout le reste reste générique (clé/valeur via §1), le typage s’ajoutant au besoin sans casser la surface. L’entrée typée (input) est traitée à part en §5.
Section 5 — Périphériques d’entrée : AirInputDevice
air-device couvre l’entrée au niveau périphérique : ouverture, identité,
capacités, capture, et lecture typée d’événements (mince enrobage des
evdev_* couche 0). L’interprétation de l’entrée (layouts clavier, gestes,
focus, association siège) est hors périmètre — c’est de la politique
compositeur (ADR-003), spécifiée ailleurs.
#![allow(unused)]
fn main() {
pub struct AirInputDevice { /* OwnedFd evdev + identité */ }
impl AirInputDevice {
/// Ouvre `/dev/input/eventX` (via un `AirDevice` du sous-système `Input`).
/// Horloge des horodatages réglée sur **monotone** (corrélation fiable).
pub fn open(device: &AirDevice) -> AirResult<Self>;
pub fn name(&self) -> AirResult<AirString>; // EVIOCGNAME
pub fn id(&self) -> AirResult<AirInputId>; // EVIOCGID (bus/vendor/product)
pub fn physical_location(&self) -> AirResult<AirString>; // EVIOCGPHYS
/// Capacités : types d'événements et codes supportés, propriétés.
pub fn capabilities(&self) -> AirResult<AirInputCapabilities>;
/// Capture exclusive (`grab`) / relâche. Révocation irréversible d'un FD cédé.
pub fn grab(&mut self) -> AirResult<()>;
pub fn release(&mut self) -> AirResult<()>;
pub fn as_fd(&self) -> BorrowedFd<'_>; // pour reactor
/// Lit un lot d'événements typés (enrobe `evdev_read_events`).
pub fn read_events<'b>(&self, out: &'b mut [AirInputEvent]) -> AirResult<&'b [AirInputEvent]>;
}
}
AirInputEvent enrichit l’InputEvent couche 0 d’un horodatage AirInstant
(l’horloge étant connue = monotone) et d’un event_type/code typés.
Section 6 — Décisions de fond
- Natif, zéro
libudev/udevd— autonomie de découverte, premier pas de l’indépendance systemd ; ADR-005 (ligne libudev) à amender. - Vocabulaire Air, pas de fuite udev — le joint qui rend l’implémentation remplaçable ; vérifié en CI.
- Groupe uevent
KERNEL(pasUSERSPACE/udevd) — autonomie, au prix d’une stabilisation sysfs non garantie (attributs lus/relus à la demande). - Pas de moteur de règles, pas de politique — énumération + identité + notification seulement ; règles/nommage persistant/décisions = couche 5.
- Générique partout + typage des sous-systèmes à forte valeur (net/block/usb,
- input à part), extensible.
- Octets vs UTF-8 stricts (Principe 3) — noms/attributs sysfs faillibles en
AirString.
Section 7 — Stratégie de tests
- Énumération : contre le vrai
/sysdu runner (présent en CI) — listernet/block, vérifier syspath/sysname/subsystem ; filtres (subsystem, propriété, attribut) ; dédup ;parent/parent_with_subsystem. - Attributs : parsing
attribute_int/_str(base 10/hex, trim, non-UTF-8 → erreur propre) — property-based + fuzzing sur les parseurs d’attributs (ils ingèrent des octets sysfs = entrée externe, Principe 3). - Hotplug :
AirDeviceMonitor— harnais privilégié déclenchant un vrai uevent (modprobe/rmmodd’un module factice, ou écriture/sys/.../uevent) ; à défaut de privilège, skip propre (COVERAGE-EXCEPTIONScatégorie PRIVILEGE) et test du décodage sur trames synthétiques. - Input : périphérique virtuel
uinput(comme la couche 0) — injecter des événements, relire typé, vérifier identité/capacités/grab(EBUSYau second grab). Skip propre hors privilège. - Couverture 100 % hors exceptions ; ensemble « couvrable » VIDE ; exceptions (branches privilégiées non atteignables) documentées et justifiées.
Section 8 — Travail à reprendre
- Couche 1 complète côté specs après ce document (8/8 crates).
- Manques couche 0 éventuels révélés à l’implémentation (parcours sysfs) → à
consigner (dette doc). Aucun anticipé :
fs(openat/getdents64/readlink) etdevice(uevent/evdev) couvrent le besoin. - Hors périmètre, à spécifier ailleurs : interprétation de l’entrée (compositeur, ADR-003) ; nommage persistant / règles (couche 5) ; amendement ADR-005 (ligne libudev).
Licence du document : MPL 2.0
Statut : Spécification technique de air-device (couche 1), construite nativement
sur la famille device couche 0. Dernière crate couche 1 spécifiée.