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

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 :

ModuleContenuStatut
modelLe modèle canonique (types de données purs)Implémenté
codecLe trait TerminalCodec (interface stable, object-safe)Déclaré
codec_ansiLe codec ansi (AnsiCodec) — 1ᵉʳ codec, adaptateur legacy dégradantImplémenté
terminal_objectL’objet AirTerminal (tag + codec object-safe)Implémenté
ttyisatty / ttyname (dérivés couche 1 de termios L0)Implémenté
termiosAttributs / contrôle de ligne / winsize / make_raw (§9)Implémenté
ptyAllocation PTY + helper openpty (§9)Implémenté
sessionSession & 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 tag TerminalTag::Air et son codec riche restent à venir ;
  • AttachSurface implémenté côté compositeur : la variante TermOut::AttachSurface et le registre SurfaceKind existent dans le modèle (design gravé) et le codec ansi la 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) : AirTerminal ne fait que traduire octets ↔ modèle ; le pilotage d’un fd reste à l’appelant (ou à un helper de couche 2 sur air-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 que Press).
    • Modifiers : bitflags maison Shift/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). Resize réutilise air_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 maison Bold/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 un Box<dyn TerminalCodec> (le tag Legacy/Air sélectionne le codec au runtime). Aucune méthode générique, aucune constante associée, aucun Self: Sized.
  • Aucune méthode par défaut, aucune implémentation : chaque codec fournit explicitement les trois opérations.
  • encode_output ajoute 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_input range l’inconnu dans une variante Unknown (zéro perte) ; le codec legacy ansi (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 imprimableKey::Character(char) (décodage incrémental via core::str::from_utf8 ; octet invalide → Unknown, jamais de panique) ;
  • contrôles C0 : \r/\nEnter ; \tTab ; 0x7f/0x08Backspace ; 0x010x1aCharacter + modificateur Control ; ESC seul (non suivi de [/O) → Escape ; autres C0 → Unknown ;
  • CSI (ESC [ …) : flèches A/B/C/D, H/F et 1~/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 reconnueTermIn::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 ; couleurs Default39/49, Indexed38;5;n/48;5;n, Rgb38;2;r;g;b/48;2;r;g;b) puis les octets run ;
  • Cursor(op) → CSI (MoveTorow;colH, MoveByA/B/C/D, Show/Hide?25h/?25l) ;
  • Clear(scope) → ED/EL (2J/0J/2K/0K) ;
  • Scroll{lines} → CSI S (haut) / T (bas) selon le signe ;
  • SetMode{mode, enabled} → modes privés DEC (MouseTracking?1000+?1006, BracketedPaste?2004, AlternateScreen?1049, FocusReporting?1004), suffixe h/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 vrai AttachSurface est 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 : AirTerminal ne lit ni n’écrit de fd ; la boucle lecture→decode_input et encode_output→écriture reste à l’appelant (ou à un helper de couche 2 sur air-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 — un tcgetattr(fd) (couche 0) réussi ⟺ terminal ; toute erreur (ENOTTY, EBADF, …) rend false (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 au ttyname(3) libc et son tampon statique partagé). Vérifie d’abord isatty (sinon ENOTTY), puis lit le lien /proc/self/fd/<n> via readlinkat (couche 0, famille fs) ; le chemin est rendu en octets (Principe 3), recopié dans buffer (ERANGE si 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-sceau vX.Y »).

8. Contraintes de crate

  • #![no_std] + extern crate alloc (couche 1 std-free) : prouvé par cargo build -p air-terminal --lib ; std autorisé uniquement sous #[cfg(test)] (pty réel, prélude de test, fork setsid pour 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 i18n air-base-core (couche 1, no_std, ADR-054) pour le tier plomberie tty (§9). air-base-core (et non air-base-lib) évite d’entraîner icu4x, ce qui garde air-terminal dans 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/termios host-testables ; tty/pty sur un PTY réel (open_pty_master + pty_unlock/pty_peer, sans root) ; session — chemins d’erreur et tcgetpgrp Ok(None) dans le parent, cycle job-control nominal dans un enfant forké setsid. Une exception ADR-035 in-code : la propagation ? de openpty (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’erreur readlinkat de tty reste couvert par le test direct de resolve_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 ErrnoAirError). 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 0 Termios est ré-exposé, manipulable via ses accesseurs typés local_flags/char_size/…).
  • tcdrain / tcflush(FlushQueue) / tcflow(FlowAction) / tcsendbreak — les tc* 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 du get/set_winsize de 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-TOCTOU TIOCGPTPEER).
  • openpty() -> AirResult<PtyPair> — helper de haut niveau : enchaîne open_pty_masterpty_unlockpty_peer et rend la paire (maître, esclave) en OwnedFd RAII (PtyPair : accesseurs empruntés master()/slave(), into_parts() cédant les deux). Les attributs/winsize initiaux ne sont pas imposés (pas de sur-conception) : une libc bâtissant openpty(termp, winp) appelle openpty() puis tcsetattr/set_window_size sur l’esclave.

9.3 Session & job-control (session)

  • tcgetpgrp(fd) -> AirResult<Option<Pid>> (sentinelle 0None, 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 0 set_ctty, acquisition sans vol ; le vol CAP_SYS_ADMIN n’est pas exposé) / clear_controlling_terminal(fd) (couche 0 clear_ctty ; piège documenté : SIGHUP au 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 BorrowedFdsans 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.