air-terminal — modèle canonique de terminal + trait TerminalCodec (mince)
Couche : 1. Statut : implémentée (version mince de sceau).
Vision : ADR-061.
Companion couche 0 : ADR-060
(famille terminal : termios/winsize/PTY). no_std :
ADR-048 /
ADR-055. Nommage :
ADR-029 (zéro abréviation).
1. Raison d’être
Le PTY legacy est un seul canal d’octets non typé : sortie applicative, entrée clavier, souris encodée en fausses séquences, collage et négociation y sont tous multiplexés en-bande (modèle « téléscripteur » POSIX termios / ECMA-48). Air, qui possède tout le stack, peut faire mieux — non en encodant mieux la souris en séquences, mais en n’ayant pas à le faire (ADR-061 §Contexte).
La décision d’ADR-061 est un modèle interne canonique Air (la lingua franca) que tous les codecs traduisent : les codecs ne se parlent jamais entre eux ; chaque type de terminal = un codec de plus, jamais une refonte.
2. Portée de cette crate (MINCE — ADR-061 § « Discipline de portée »)
Cette crate grave le design (assurance anti-double-travail) sans le sur-implémenter. Elle contient exactement :
| Module | Contenu | Statut |
|---|---|---|
model | Le modèle canonique (types de données purs) | Implémenté |
codec | Le trait TerminalCodec (interface stable, object-safe) | Déclaré |
codec_ansi | Le codec ansi (AnsiCodec) — 1ᵉʳ codec, adaptateur legacy dégradant | Implémenté |
terminal_object | L’objet AirTerminal (tag + codec object-safe) | Implémenté |
tty | isatty / ttyname (dérivés couche 1 de termios L0) | Implémenté |
termios | Attributs / contrôle de ligne / winsize / make_raw (§9) | Implémenté |
pty | Allocation PTY + helper openpty (§9) | Implémenté |
session | Session & job-control d’un terminal (§9) | Implémenté |
HORS PÉRIMÈTRE — différés couche 2 (ADR-061 §Décision, conçus quand
air-console/air-tui les réclameront, pas avant) :
- les codecs riches (
kitty,air-codec-1) — le tagTerminalTag::Airet son codec riche restent à venir ; AttachSurfaceimplémenté côté compositeur : la varianteTermOut::AttachSurfaceet le registreSurfaceKindexistent dans le modèle (design gravé) et le codecansila dégrade (n’émet rien), mais aucune logique de compositeur n’est écrite ;- l’I/O réelle sur fd (boucle lire→décoder / encoder→écrire) :
AirTerminalne fait que traduire octets ↔ modèle ; le pilotage d’un fd reste à l’appelant (ou à un helper de couche 2 surair-stdio).
termcap/terminfo restent hors libc (ncurses/libtinfo, couche 2 — ADR-061
§ « Discipline de portée »).
3. Modèle canonique (model)
Types de données purs (#[derive(Debug, Clone, PartialEq, Eq)] partout ;
Hash/Copy là où le type est POD). Deux axes : TermIn (entrant) et TermOut
(sortant), plus TermCaps (capacités annoncées).
3.1 Discipline d’extensibilité (ADR-061 §Décision.3)
« Annoncer → Sélectionner → dégrader-sur-inconnu ». Mécanique Rust/fil : chaque
énumération susceptible d’évoluer est #[non_exhaustive] (compat à la
recompilation, quand on ajoute une variante) et porte une variante
Unknown (compat runtime, quand un codec reçoit plus récent que soi —
l’inconnu est catégorisé et ignoré, jamais fatal). Les octets de fil non
interprétés sont conservés dans un RawEvent (zéro perte ;
Principe 3 : ce sont des
octets, pas de l’UTF-8 présumé).
Les slots d’extension évoqués par ADR-061 (Touch/Gesture/Device côté entrée ;
Pressure/Tilt côté souris) ne sont pas matérialisés en variantes vides
spéculatives (« pas de sur-design ») : #[non_exhaustive] les réserve.
3.2 Entrée — TermIn
TermIn:Keyboard(KeyboardEvent)·Mouse(MouseEvent)·Screen(ScreenEvent)·Unknown(RawEvent).KeyboardEvent { key: Key, modifiers: Modifiers, kind: KeyEventKind }.Key:Character(char)·Enter·Tab·Backspace·Escape·Arrow(ArrowDirection)·Home·End·PageUp·PageDown·Insert·Delete·Function(u8)·Unknown.KeyEventKind:Press·Release·Repeat(les deux derniers exigent le protocole kitty ; le legacy ne rapporte quePress).Modifiers: bitflags maisonShift/Alt/Control/Super(bits inconnus retenus).
MouseEvent:Button{ button, action, position, modifiers }·Wheel{ direction, position, modifiers }·Motion{ position, modifiers }·Unknown{ subtype: u16, payload: RawEvent }.MouseButton:Left·Middle·Right·Other(u8).ButtonAction:Press·Release.WheelDirection:Up/Down/Left/Right.CellPosition { column: u16, row: u16 }.
ScreenEvent:FocusIn·FocusOut·Resize(Winsize)·Paste(Vec<u8>)·Unknown(RawEvent).Resizeréutiliseair_sys_types::terminal::Winsize(aucun type dupliqué).
3.3 Sortie — TermOut
TermOut:Text{ run: Vec<u8>, attributes: TextAttributes }·Cursor(CursorOperation)·Clear(ClearScope)·Scroll{ lines: i16 }·SetMode{ mode: TerminalMode, enabled: bool }·AttachSurface{ rectangle: CellRectangle, surface: SurfaceIdentifier }.TextAttributes { foreground: Color, background: Color, style: TextStyle };Color:Default·Indexed(u8)·Rgb{ r, g, b };TextStyle: bitflags maisonBold/Italic/Underline/Inverse.CursorOperation:MoveTo(CellPosition)·MoveBy{ columns: i16, rows: i16 }·Show·Hide.ClearScope:Screen/Line/ToEndOfScreen/ToEndOfLine.TerminalMode:MouseTracking/BracketedPaste/AlternateScreen/FocusReporting.CellRectangle { origin: CellPosition, columns: u16, rows: u16 }.SurfaceIdentifier(u64)(opaque).SurfaceKind:Framebuffer·GpuTexture·Unknown(u16)— registre extensible (ADR-061 §Décision.4 ; design seulement, aucune logique de compositeur).
3.4 Capacités — TermCaps
TermCaps { colors: ColorDepth, mouse: bool, keyboard_protocol: KeyboardProtocol, surfaces: bool, bracketed_paste: bool, focus_events: bool }. ColorDepth :
Monochrome/Ansi16/Indexed256/TrueColor. KeyboardProtocol :
Legacy/Kitty. C’est la base de la sélection d’intersection.
3.5 Note d’implémentation — bitflags maison
Modifiers/TextStyle sont des newtypes maison (un struct N(uN) +
implémentations core::ops::{BitOr, BitAnd, BitXor, Not, …} + from_bits_retain
qui retient les bits inconnus), pas la crate externe bitflags : la
surface utile est trop petite pour justifier la dépendance (règle des 80 %,
ADR-024), et le « zéro dépendance externe » de la crate est ainsi préservé.
4. Trait TerminalCodec (codec)
#![allow(unused)]
fn main() {
pub trait TerminalCodec {
fn decode_input(&mut self, wire: &[u8]) -> alloc::vec::Vec<TermIn>;
fn encode_output(&mut self, output: &TermOut, destination: &mut alloc::vec::Vec<u8>);
fn caps(&self) -> TermCaps;
}
}
- Object-safe : l’objet
AirTerminal(§6) tient unBox<dyn TerminalCodec>(le tagLegacy/Airsélectionne le codec au runtime). Aucune méthode générique, aucune constante associée, aucunSelf: Sized. - Aucune méthode par défaut, aucune implémentation : chaque codec fournit explicitement les trois opérations.
encode_outputajoute au tampon de l’appelant (pas d’allocation imposée par événement — un flux haute fréquence peut agréger par trame, ADR-061 §Décision.3).- Discipline « dégrader-sur-inconnu » :
decode_inputrange l’inconnu dans une varianteUnknown(zéro perte) ; le codec legacyansi(cf. §6) est un adaptateur dégradant en sortie (il jette ce que xterm ne sait pas piloter), sans jamais contraindre le modèle.
Le premier codec (ansi) est implémenté dans cette crate (§6) ; les codecs
riches suivront en couche 2.
5. Codec ansi (codec_ansi)
Le codec ansi/termios (AnsiCodec) est le premier (et seul, au sceau)
codec concret — l’adaptateur legacy dégradant d’ADR-061 §Décision.5. Il
implémente TerminalCodec et traduit dans les deux sens entre les octets de fil
d’un terminal xterm/VT générique et le modèle canonique. Il est borné : ce
n’est pas un émulateur xterm complet.
6.1 Décodage (decode_input) — ensemble borné, stateful
Le décodeur est stateful : une séquence (ou un caractère UTF-8) coupée en fin de tampon est mémorisée et complétée au prochain appel. Il reconnaît :
- UTF-8 imprimable →
Key::Character(char)(décodage incrémental viacore::str::from_utf8; octet invalide →Unknown, jamais de panique) ; - contrôles C0 :
\r/\n→Enter;\t→Tab;0x7f/0x08→Backspace;0x01–0x1a→Character+ modificateurControl;ESCseul (non suivi de[/O) →Escape; autres C0 →Unknown; - CSI (
ESC [ …) : flèchesA/B/C/D,H/Fet1~/4~(Home/End),5~/6~(PageUp/PageDown),2~/3~(Insert/Delete), fonctions (ESC O P/Q/R/S= F1–F4,ESC [ 15~/17~…24~= F5–F12), avec paramètres de modificateurs (ESC [ 1;5A= Ctrl+Up) ; - souris SGR (
ESC [ < b ; x ; y M|m) →MouseEvent(Button/Wheel/Motion + position 0-based + modificateurs ;M= appui,m= relâchement) ; - focus (
ESC [ I/ESC [ O) →ScreenEvent::FocusIn/FocusOut; - collage entre parenthèses (
ESC [ 200~…ESC [ 201~) →ScreenEvent::Paste(octets); - toute séquence non reconnue →
TermIn::Unknown(RawEvent)(« dégrader-sur- inconnu », zéro perte). Une CSI anormalement longue (> 64 octets sans octet final) est dégradée plutôt que bufferisée sans fin (borne anti-emballement).
6.2 Encodage (encode_output) — octets ANSI, ajoutés au tampon
Text{run, attributes}→ SGR (ESC [ 0 ; … m) : styles Bold/Italic/Underline/ Inverse ; couleursDefault→39/49,Indexed→38;5;n/48;5;n,Rgb→38;2;r;g;b/48;2;r;g;b) puis les octetsrun;Cursor(op)→ CSI (MoveTo→row;colH,MoveBy→A/B/C/D,Show/Hide→?25h/?25l) ;Clear(scope)→ ED/EL (2J/0J/2K/0K) ;Scroll{lines}→ CSIS(haut) /T(bas) selon le signe ;SetMode{mode, enabled}→ modes privés DEC (MouseTracking→?1000+?1006,BracketedPaste→?2004,AlternateScreen→?1049,FocusReporting→?1004), suffixeh/l;AttachSurface{..}→ DÉGRADE : le codec legacy ne peut pas lier de surface (un PTY n’a pas de canal hors-bande) → il n’émet rien. Le vraiAttachSurfaceest couche 2 (côté compositeur, ADR-061 §Décision.4).
6.3 Capacités (caps)
Celles d’un terminal ansi/xterm générique : colors: TrueColor, mouse: true,
keyboard_protocol: Legacy, surfaces: false, bracketed_paste: true,
focus_events: true.
6.4 Fuzzing
decode_input accepte des données externes (canal PTY hostile) : une cible
cargo-fuzz (fuzz_air_terminal_ansi_decode) pousse des octets arbitraires,
tranche par tranche, dans le codec stateful et vérifie l’invariant « ne jamais
paniquer » (CLAUDE.md — fuzzing systématique de toute API de données externes).
6. Objet AirTerminal (terminal_object)
AirTerminal (ADR-061 §Décision.1) représente le terminal-pair : un tag
(TerminalTag { Legacy, Air }, #[non_exhaustive]) + un codec. Mince et
testable : legacy_ansi() (tag Legacy + AnsiCodec), et decode_input /
encode_output / caps / tag — les trois premiers délèguent au codec.
- Codec object-safe (
Box<dyn TerminalCodec>, pas un paramètre générique) : le tag sélectionne le codec au runtime ; de futurs codecs (kitty,air-codec-1) s’ajoutent sans changer la signature d’AirTerminal(« chaque type de terminal = un codec de plus, jamais une refonte »). Le coût d’un appel virtuel par lot d’octets est négligeable devant l’I/O terminal (Principe 5). - I/O réelle sur fd = hors sceau :
AirTerminalne lit ni n’écrit de fd ; la boucle lecture→decode_inputetencode_output→écriture reste à l’appelant (ou à un helper de couche 2 surair-stdio).
7. isatty / ttyname (tty)
Dérivés couche 1 de la famille terminal de la couche 0 (ce ne sont pas des
syscalls : ADR-060 les place explicitement en couche 1).
pub fn isatty(fd: BorrowedFd<'_>) -> bool— untcgetattr(fd)(couche 0) réussi ⟺ terminal ; toute erreur (ENOTTY,EBADF, …) rendfalse(sémantique libc). Aucune erreur propagée.pub fn ttyname(fd: BorrowedFd<'_>, buffer: &mut [u8]) -> Result<&[u8], Errno>— forme réentrante, sans état statique (contrairement auttyname(3)libc et son tampon statique partagé). Vérifie d’abordisatty(sinonENOTTY), puis lit le lien/proc/self/fd/<n>viareadlinkat(couche 0, famillefs) ; le chemin est rendu en octets (Principe 3), recopié dansbuffer(ERANGEsi trop petit), et la sous-tranche remplie est renvoyée.- Le numéro de fd est formaté à la main dans un tampon de pile (
/proc+ signe + décimales +NUL≤ 26 octets, capacité 32) — sans allocation ni dépendance de formatage. Errno::ERANGE(valeur 34) a été ajoutée additivement à la couche 0 (air-sys-types::Errno) au titre de la politique de complétion additive du sceau (contrats couche 1 ; cf. INDEX §« re-sceauvX.Y»).
- Le numéro de fd est formaté à la main dans un tampon de pile (
8. Contraintes de crate
#![no_std]+extern crate alloc(couche 1 std-free) : prouvé parcargo build -p air-terminal --lib;stdautorisé uniquement sous#[cfg(test)](pty réel, prélude de test, forksetsidpour le job-control).- Zéro dépendance externe : trois crates sœurs internes — les deux de la
couche 0 (
air-sys-syscall+air-sys-types) et le cœur d’erreurs sans i18nair-base-core(couche 1,no_std, ADR-054) pour le tier plomberie tty (§9).air-base-core(et nonair-base-lib) évite d’entraîner icu4x, ce qui gardeair-terminaldans la fermeture runtime*-linux-air. Aucune surface C/C++, aucune entrée DEPENDENCIES.md (deps internes hors règle des 80 %). - Tests 100 % (lignes + branches + régions) :
model/codec/termioshost-testables ;tty/ptysur un PTY réel (open_pty_master+pty_unlock/pty_peer, sans root) ;session— chemins d’erreur ettcgetpgrpOk(None)dans le parent, cycle job-control nominal dans un enfant forkésetsid. Une exception ADR-035 in-code : la propagation?deopenpty(marqueur// COVERAGE (STRUCTURAL, ADR-035)) — échecs intermédiaires (épuisement de PTY/descripteurs, kernel < 4.13) non provoquables en CI ; le chemin succès est couvert. Le chemin d’erreurreadlinkatdettyreste couvert par le test direct deresolve_link_into.
9. Tier « plomberie tty » — termios / PTY / job-control (ADR-060, §5.3 P0.3)
Distinct du codec (traduction octets ↔ modèle, §4–§6), ce tier re-expose en
couche 1 la famille terminal scellée de la couche 0 (couche-0-v1.8,
ADR-060) avec le modèle d’erreurs d’Air (AirError/AirResult d’
air-base-core, conversion Errno → AirError). C’est un tier fidèle
(même posture qu’air-filesystem::AirFile, audit face-libc P0 item 3, famille
#15) : un appel = un ioctl, EINTR remonté tel quel (jamais de retry,
ADR-021 conv. 2 — tcdrain est le point d’annulation), FD en RAII
(OwnedFd), aucune fonction unsafe exposée (tout l’unsafe reste en
couche 0), Option/newtypes/énumérations typés (jamais un entier magique), as
proscrit. Il rend le stack suffisant pour bâtir openpty(3)/getpass(3)/
sshd en face C au-dessus sans jamais sauter en couche 0 côté consommateur —
ces fonctions ne sont pas implémentées ici (elles vivent au-dessus).
9.1 Attributs & contrôle de ligne (termios)
tcgetattr(fd) -> AirResult<Termios>/tcsetattr(fd, SetAction, &Termios) -> AirResult<()>— cœur termios (le type couche 0Termiosest ré-exposé, manipulable via ses accesseurs typéslocal_flags/char_size/…).tcdrain/tcflush(FlushQueue)/tcflow(FlowAction)/tcsendbreak— lestc*POSIX (durée de break non paramétrée — honnêteté de contrat, hérité de la couche 0).window_size(fd) -> AirResult<Winsize>/set_window_size(fd, &Winsize)— taille de fenêtre (noms verbeux ADR-029 duget/set_winsizede la couche 0).
make_raw(&Termios) -> Termios — décision d’API (SCELLÉE couche-1-v1.0). Le
cfmakeraw(3) libc mute en place et rend void — il jette l’information
(RAPPORT-CONSOLIDE §B2 : « fonctions void qui avalent l’erreur »). Air ne jette
rien : make_raw est une opération pure qui rend la nouvelle valeur (un
nouveau Termios en mode raw). N’étant jamais faillible (pure mutation de
struct), elle ne rend pas un Result factice (qui ne serait jamais Err —
ce serait malhonnête, doctrine « fait ce qu’il dit ») : elle rend une valeur
inspectable, entièrement testable sans terminal (unitaire + property-based
proptest : invariants + idempotence sur un Termios arbitraire). L’effet est
identique à musl/glibc (coupe IGNBRK BRKINT PARMRK ISTRIP INLCR IGNCR ICRNL IXON
/ OPOST / ECHO ECHONL ICANON ISIG IEXTEN / PARENB ; force CS8 ; VMIN=1,
VTIME=0).
9.2 Pseudo-terminaux (pty)
open_pty_master() -> AirResult<OwnedFd>,pty_number(master) -> AirResult<PtyNumber>,pty_unlock(master),pty_peer(master) -> AirResult<OwnedFd>(voie moderne anti-TOCTOUTIOCGPTPEER).openpty() -> AirResult<PtyPair>— helper de haut niveau : enchaîneopen_pty_master→pty_unlock→pty_peeret rend la paire(maître, esclave)enOwnedFdRAII (PtyPair: accesseurs empruntésmaster()/slave(),into_parts()cédant les deux). Les attributs/winsize initiaux ne sont pas imposés (pas de sur-conception) : une libc bâtissantopenpty(termp, winp)appelleopenpty()puistcsetattr/set_window_sizesur l’esclave.
9.3 Session & job-control (session)
tcgetpgrp(fd) -> AirResult<Option<Pid>>(sentinelle0→None, ADR-021 conv. 1) /tcsetpgrp(fd, Pid)— groupe de premier plan.session_id(fd) -> AirResult<Pid>(tcgetsid, toujours> 0→ pas d’Option).set_controlling_terminal(fd)(couche 0set_ctty, acquisition sans vol ; le volCAP_SYS_ADMINn’est pas exposé) /clear_controlling_terminal(fd)(couche 0clear_ctty; piège documenté :SIGHUPau groupe de premier plan). Noms verbeux (ADR-029) car sans nom de fonction POSIX ; les noms POSIX d’autorité (tcgetpgrp/tcsetpgrp/session_id) sont, eux, conservés.
9.4 Note fuzz
Aucune cible de fuzz pour ce tier (à la différence du codec ansi dont
decode_input fuzze un flux d’octets hostile, §6.4). La surface d’entrée est
structurée — des Termios/Winsize #[repr(C)], des énumérations typées, des
BorrowedFd — sans parsing d’octets externes. Il n’y a donc rien à fuzzer
(une cible vide serait du bruit) ; la robustesse est prouvée par la couverture
100 % (PTY réel + enfant setsid) et le property-based de make_raw.