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

Spec couche 1 — air-base-lib (cœur : erreurs, chaînes/chemins, temps)

Spécification technique — Version 1.0 (cœur). Couche 1 « Primitives système ».

Mise à jour de layering — ADR-054. Le cœur sans i18n décrit par cette spec (erreurs AirError/AirResult, identifiants, chemins-octets AirPath, temps AirInstant/AirDuration (monotone), AirSystemTime (horloge murale) + sleep/sleep_until (nanosleep), encodages, log) a été extrait dans la crate air-base-core (no_std, zéro icu4x) pour débloquer la fermeture runtime *-linux-air. air-base-lib conserve l’i18n (AirString/AirLocale, temps calendaire AirDateTime/AirCalendar), dépend d’air-base-core et le ré-exporte intégralement : la surface publique (air_base_lib::AirError, …::AirPath, …::AirInstant, …) reste inchangée. La spec n’est pas réécrite ; le porteur du cœur erreur/temps-monotone est désormais air-base-core (cf. ADR-054 D1/D5).

Position et méthode

air-base-lib est la crate la plus consommée du système (l’équivalent du libSystem des autres Unix) : toutes les couches supérieures en dépendent. Là où la couche 0 (air-sys-types / air-sys-syscall) expose des syscalls et des primitives kernel, air-base-lib expose des concepts applicatifs : erreurs riches et traçables, chaînes Unicode, chemins, temps.

Cette spec couvre le cœur (décision de périmètre, 2026-06-11) :

  1. ErreursAirError, AirResult, l’absorption d’Errno (couche 0).
  2. Chaînes & cheminsAirString (Unicode, via icu4x), AirPath (octets, potentiellement non-UTF-8), AirLocale.
  3. TempsAirInstant (monotone), AirDuration, AirDateTime + AirCalendar (calendaire, via icu4x).

Différé à des specs air-base-lib ultérieures (hors de ce document) : AirLog (logging structuré → journald), les identifiants (AirUuid, AirId128, AirMonotonicId), AirConfig (TOML/XDG). Différé à une passe dédiée : la surface ABI C (libair-base.so, symboles air_*, versioned symbols, stabilité 10 ans — ADR-012/ADR-027) ; ce document fige d’abord l’API Rust idiomatique, dont l’ABI C dépendra.

Décision de périmètre actée. On spécifie le cœur d’un bloc (délégable en une passe), API Rust d’abord, ABI C plus tard. La méthode reste doc-d’abord : ce contrat est validé avant implémentation ; Claude Code produit ensuite un squelette documenté puis l’implémente sans changer la surface validée.

Conventions transverses de la couche 1

  1. Rust idiomatique, aucun unsafe exposé. La frontière couche 0 → couche 1 est « API Rust sûre, unsafe confiné en couche 0 » (macro-architecture §frontières). air-base-lib n’expose aucune fonction unsafe ; tout unsafe interne éventuel porte un // SAFETY: (vérifié CI).

  2. Couverture 100 % (lignes + branches), couche fondatrice (Principe 1, comme la couche 0). Property-based + fuzzing sur toute API ingérant des données externes (validation UTF-8, parsing de chemins/locales).

  3. Nommage explicite (ADR-029). Surface verbeuse, sans abréviation (AirString, AirDateTime, normalize, to_lowercase_in_locale…). Noms d’autorité conservés : types std (OsString, Path), termes icu4x réexposés, vocabulaire Unicode (NFC, grapheme).

  4. Arithmétique défensive (Principe 2). Toute opération temporelle pouvant déborder utilise checked_* / saturating_* explicitement ; conversions lossy par TryFrom, jamais as.

  5. Zéro présomption d’encodage (Principe 3). Distinction stricte AirString (UTF-8 garanti) vs AirPath (octets, peut être non-UTF-8). Toute construction depuis des octets externes passe par une validation faillible.

  6. Pas de proc-macro Air ici (macro-architecture §air-base-lib) : la crate doit rester simple à exposer en ABI C. AirError est écrit à la main (pas de thiserror dans air-base-lib) ; thiserror reste l’idiome des autres crates couche 1+ (ADR-019).

Dépendances assumées (règle des 80 %, ADR-024)

  • icu4x : dépendance structurante, exception explicite à la règle des 80 % déjà actée (ADR-016 ; docs/EXCEPTIONS.md). Fournit normalisation, casing locale-aware, segmentation, collation, calendriers. Composants icu_* tirés à la carte (modularité native d’icu4x).
  • air-sys-types (couche 0) : pour absorber Errno.
  • air-sys-syscall (couche 0) : pour AirInstant::now / AirDateTime::now (horloges) — seul point de contact syscall du cœur.
  • Aucune autre dépendance externe dans le cœur (pas de serde/toml : la config est différée).

Section 1 — Erreurs : AirError / AirResult

Réconciliation ADR-019 ↔ macro-architecture (décision)

Décision (à graver). L’ADR-019 fige un modèle hybride : Errno minimaliste en couche 0 ; un type d’erreur enrichi par crate (via thiserror) au-dessus. La macro-architecture pose en plus un AirError générique dans air-base-lib. On réconcilie ainsi, sans contredire ADR-019 :

  • AirError est la monnaie d’erreur commune d’air-base-lib et le futur porteur de la frontière ABI C (où l’on ne peut pas exposer les enums Rust par-crate). Il absorbe Errno et porte une chaîne de causalité + du contexte.
  • Les enums d’erreur par crate (idiome ADR-019, via thiserror) restent valides au-dessus : une crate qui veut un matching exhaustif définit son enum ; il convertit vers/depuis AirError aux frontières (From).
  • AirError ne remplace pas ces enums ; il est la base commune, utilisée là où un enum de domaine serait disproportionné, et à la frontière C.

Type

#![allow(unused)]
fn main() {
/// Erreur générique d'`air-base-lib` : catégorie + message + chaîne de causalité.
/// Écrite à la main (pas de `thiserror` ici, cf. convention 6).
pub struct AirError { /* opaque : kind + message + source optionnelle */ }

pub type AirResult<T> = Result<T, AirError>;

/// Catégorie large, stable, mappable depuis `Errno` et exposable en ABI C.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AirErrorKind {
    NotFound,          // ENOENT
    PermissionDenied,  // EACCES, EPERM
    AlreadyExists,     // EEXIST
    InvalidInput,      // EINVAL
    Interrupted,       // EINTR
    WouldBlock,        // EAGAIN/EWOULDBLOCK
    Unsupported,       // ENOSYS, EOPNOTSUPP
    TimedOut,          // ETIMEDOUT
    OutOfMemory,       // ENOMEM
    BrokenResource,    // EPIPE, ECONNRESET…
    Io,                // fourre-tout E/S kernel
    InvalidData,       // données mal formées (UTF-8, parsing) — pas d'Errno
    Other,
}

impl AirError {
    /// Crée une erreur avec catégorie + message.
    pub fn new(kind: AirErrorKind, message: impl Into<String>) -> Self;
    /// Catégorie.
    pub fn kind(&self) -> AirErrorKind;
    /// Message (sans la chaîne de causalité).
    pub fn message(&self) -> &str;
    /// L'`Errno` kernel d'origine s'il existe (remonte la chaîne de `source`).
    pub fn errno(&self) -> Option<Errno>;
    /// Attache une source (chaînage de causalité, `Principe 4`).
    pub fn with_source(self, source: impl Into<Box<dyn core::error::Error + Send + Sync + 'static>>) -> Self;
}

impl From<Errno> for AirError { /* mappe l'errno → kind, conserve l'Errno en source */ }
impl core::fmt::Display for AirError { /* message + « : » + chaîne source */ }
impl core::error::Error for AirError {
    fn source(&self) -> Option<&(dyn core::error::Error + 'static)>;
}
}

Attache de contexte ergonomique

#![allow(unused)]
fn main() {
/// Extension sur `Result` pour attacher du contexte sans cérémonie (Principe 4).
pub trait ResultContextExt<T> {
    /// Enrobe l'erreur courante d'un message de contexte (la place en `source`).
    fn context(self, message: impl Into<String>) -> AirResult<T>;
    /// Variante paresseuse (message construit seulement en cas d'erreur).
    fn with_context<F, S>(self, f: F) -> AirResult<T>
    where F: FnOnce() -> S, S: Into<String>;
}

impl<T, E> ResultContextExt<T> for Result<T, E>
where E: Into<Box<dyn core::error::Error + Send + Sync + 'static>> { /* … */ }
}

Mapping Errno → AirErrorKind. Table déterministe (ENOENT→NotFound, EACCES/EPERM→PermissionDenied, EEXIST→AlreadyExists, EINVAL→InvalidInput, EINTR→Interrupted, EAGAIN→WouldBlock, ENOSYS/EOPNOTSUPP→Unsupported, ETIMEDOUT→TimedOut, ENOMEM→OutOfMemory, EPIPE/ECONNRESET→BrokenResource, défaut→Io). L’Errno exact reste toujours récupérable via errno() (placé en source), pour le matching fin (réagir à EAGAIN/EINTR — cf. ADR-019 alternative 2 rejetée : on ne perd jamais le code précis).

Note (extension errno couche 0, prompt 074). La table ci-dessus est désormais satisfiable par nom : ETIMEDOUT, EPIPE, ECONNRESET (et l’alias EWOULDBLOCK ≡ EAGAIN) ont été ajoutés aux constantes air_sys_types::Errno (extension additive du sceau couche 0, pilotée par ce contrat). Le mapping n’a donc pas besoin de numéros bruts en couche 1.

# Errors. AirError n’échoue pas à se construire. La construction d’AirError::new est infaillible.

Exemple.

#![allow(unused)]
fn main() {
use air_base_lib::{AirResult, ResultContextExt};

fn open_config(path: &AirPath) -> AirResult<Vec<u8>> {
    let fd = air_sys_syscall::fs::openat(/* … */)        // -> Result<_, Errno>
        .with_context(|| format!("ouverture de la config {path}"))?; // Errno -> AirError + contexte
    // …
}
}

Tests. Table de mapping Errno→kind (chaque entrée) ; errno() remonte bien la chaîne ; Display rend la chaîne complète ; context/with_context (paresseux non évalué en cas de succès) ; source() chaîné sur ≥ 2 niveaux.


Section 2 — Chaînes & chemins

2.1 AirLocale — identifiant de locale (BCP 47)

Frontière de couche (décision). ADR-016 place la cascade de fallback et les formats culturels (AirNumber, AirCurrency, formatage localisé) en couche 2. Mais les opérations locale-aware d’AirString (casing, collation) et AirDateTime ont besoin du type de locale. On place donc le type AirLocale (parsing/représentation BCP 47, enrobage de icu4x ::Locale) en couche 1 ; la résolution de la locale effective (système → utilisateur → application, cascade macOS-style) et le formatage culturel restent en couche 2. Couche 1 = le type ; couche 2 = la politique.

#![allow(unused)]
fn main() {
/// Locale BCP 47 (enrobe `icu::locid::Locale`). Pas POSIX (ADR-016).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AirLocale { /* … */ }

impl AirLocale {
    /// Parse un identifiant BCP 47 (`"fr-FR"`, `"zh-Hans"`, `"ar"`).
    /// # Errors
    /// `AirError { kind: InvalidData }` si l'identifiant est mal formé.
    pub fn parse(tag: &str) -> AirResult<Self>;
    /// La locale racine « und » (undetermined), neutre.
    pub fn root() -> Self;
    /// Rend l'identifiant BCP 47 canonique.
    pub fn to_bcp47(&self) -> String;
    pub fn language(&self) -> &str;
    pub fn region(&self) -> Option<&str>;
}
}

2.2 AirString — chaîne Unicode (UTF-8 garanti)

Surface Unicode complète (décision de périmètre), adossée à icu4x.

#![allow(unused)]
fn main() {
/// Chaîne UTF-8 valide, Unicode-aware. Enrobe `String`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AirString { /* String interne, invariant : UTF-8 valide */ }

impl AirString {
    // --- Construction ---
    pub fn new() -> Self;
    pub fn from_str(s: &str) -> Self;                 // infaillible (déjà UTF-8)
    /// Depuis des octets externes (validation, Principe 3).
    /// # Errors `InvalidData` si non-UTF-8.
    pub fn from_utf8(bytes: Vec<u8>) -> AirResult<Self>;
    /// Conversion avec remplacement U+FFFD (jamais d'erreur).
    pub fn from_utf8_lossy(bytes: &[u8]) -> Self;
    pub fn as_str(&self) -> &str;
    pub fn into_bytes(self) -> Vec<u8>;

    // --- Normalisation Unicode (icu4x) ---
    pub fn normalize(&self, form: NormalizationForm) -> AirString; // NFC/NFD/NFKC/NFKD
    pub fn is_normalized(&self, form: NormalizationForm) -> bool;

    // --- Casing locale-aware (icu4x) ---
    pub fn to_uppercase_in_locale(&self, locale: &AirLocale) -> AirString;
    pub fn to_lowercase_in_locale(&self, locale: &AirLocale) -> AirString;
    pub fn to_titlecase_in_locale(&self, locale: &AirLocale) -> AirString;

    // --- Segmentation (icu4x) ---
    pub fn graphemes(&self) -> GraphemeIterator<'_>;  // clusters de graphèmes étendus
    pub fn words(&self) -> WordIterator<'_>;
    pub fn sentences(&self) -> SentenceIterator<'_>;
    pub fn grapheme_count(&self) -> usize;            // ≠ .len() (octets) ≠ chars

    // --- Comparaison locale-aware (icu4x collator) ---
    pub fn compare_in_locale(&self, other: &AirString, locale: &AirLocale) -> core::cmp::Ordering;
    pub fn eq_ignore_case_in_locale(&self, other: &AirString, locale: &AirLocale) -> bool;
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NormalizationForm { Nfc, Nfd, Nfkc, Nfkd }
}

Invariants. UTF-8 valide par construction (le seul point d’entrée faillible est from_utf8). Eq/Hash portent sur les octets (égalité binaire) ; la comparaison Unicode/locale passe explicitement par compare_in_locale (jamais d’égalité « magique » normalisée — l’appelant normalize() d’abord s’il le veut). grapheme_count est distinct de la longueur en octets et du nombre de char — documenté pour éviter le piège classique.

Performance. from_str/as_str triviaux. Normalisation/segmentation/ collation : coût icu4x (données chargées à la demande). Pas d’allocation cachée sur as_str/graphemes (itérateurs empruntés).

2.3 AirPath — chemin (octets, potentiellement non-UTF-8)

Frontière de couche (décision). AirPath ne fait que de la manipulation lexicale (pas d’accès disque). La canonicalisation (résolution de symlinks, vérification de confinement) exige des syscalls → elle vit dans air-filesystem (couche 1, autre crate), pas ici. air-base-lib::AirPath = chemin valeur + opérations pures + pont vers la couche 0.

#![allow(unused)]
fn main() {
/// Chemin de fichier : octets, **peut contenir du non-UTF-8** (Unix). Enrobe `PathBuf`/`OsString`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AirPath { /* … */ }

impl AirPath {
    pub fn from_str(s: &str) -> Self;
    pub fn from_bytes(bytes: &[u8]) -> Self;          // jamais d'erreur (octets bruts)
    pub fn as_bytes(&self) -> &[u8];

    // --- Conversion explicite vers/depuis AirString (Principe 3) ---
    /// # Errors `InvalidData` si le chemin n'est pas UTF-8.
    pub fn to_air_string(&self) -> AirResult<AirString>;
    pub fn to_air_string_lossy(&self) -> AirString;   // U+FFFD
    pub fn from_air_string(s: &AirString) -> Self;

    // --- Manipulation lexicale (aucun syscall) ---
    pub fn join(&self, component: &AirPath) -> AirPath;
    pub fn parent(&self) -> Option<AirPath>;
    pub fn file_name(&self) -> Option<&[u8]>;
    pub fn extension(&self) -> Option<&[u8]>;
    pub fn components(&self) -> ComponentIterator<'_>;
    pub fn is_absolute(&self) -> bool;
    /// Normalisation **lexicale** (résout `.`/`..` sans toucher au disque).
    pub fn normalize_lexically(&self) -> AirPath;

    // --- Pont couche 0 (frontière syscall) ---
    /// `CString` consommable par les wrappers couche 0 (`openat`…).
    /// # Errors `InvalidInput` si le chemin contient un octet NUL interne.
    pub fn to_c_string(&self) -> AirResult<CString>;
}
}

Décision. Le pont to_c_string (rejet du NUL interne) est ce que consommeront air-filesystem/air-process pour appeler la couche 0 : il est ici parce qu’il est purement lexical (pas de syscall), et centralise la validation NUL une fois pour toutes.

2.4 AirOsStr / AirOsString — chaîne du système (octets opaques)

Distinct d’AirPath (décision, remodel pré-sceau env/args). Une variable d’environnement, un argument de programme (argv), une cible de readlink : ce sont des suites d’octets (non garanties UTF-8) qui ne sont pas des chemins — leur imposer AirPath (qui porte une sémantique lexicale de chemin : séparateurs /, ./.., join, parent) serait un abus de type. On introduit le pendant Air de std::ffi::OsStr/OsString : la « chaîne-OS » sans aucune sémantique au-delà d’être des octets opaques. Deux types, patron std exact : AirOsStr emprunté (#[repr(transparent)] sur [u8]) et AirOsString possédé (Vec<u8>). Zone 2 ADR-029 : noms miroir de std (OsStr/OsString).

#![allow(unused)]
fn main() {
/// Chaîne-OS empruntée : octets, potentiellement non-UTF-8. Pendant de `std::ffi::OsStr`.
#[repr(transparent)]
#[derive(PartialEq, Eq, Hash)] // + Debug échappé (aucune présomption UTF-8)
pub struct AirOsStr { /* [u8] */ }

impl AirOsStr {
    pub fn from_bytes(bytes: &[u8]) -> &AirOsStr;   // sûre ; unsafe repr(transparent) INTERNE, sans copie
    pub fn as_bytes(&self) -> &[u8];
    pub fn to_os_string(&self) -> AirOsString;
    pub fn is_empty(&self) -> bool;
    pub fn len(&self) -> usize;
}

/// Chaîne-OS possédée. Pendant de `std::ffi::OsString`. `Deref<Target = AirOsStr>`.
#[derive(Clone, PartialEq, Eq, Hash)] // + Debug échappé, Default (vide)
pub struct AirOsString { /* Vec<u8> */ }

impl AirOsString {
    pub fn new() -> AirOsString;                    // vide
    pub fn from_bytes(bytes: &[u8]) -> AirOsString; // copie
    pub fn as_os_str(&self) -> &AirOsStr;
    pub fn as_bytes(&self) -> &[u8];
    pub fn into_bytes(self) -> Vec<u8>;
}
}

Consommateurs (signatures figées pré-sceau). air_runtime::args::getOption<&'static AirOsStr> (zéro-copie ; argv immortel, jamais muté) ; air_env::get(&AirOsStr) -> Option<AirOsString> (copie possédée race-safe, façon std::env::var_os : aucune référence rendue ne survit à un futur set_var) ; air_env::vars() en paires (AirOsString, AirOsString) ; AirCommand::env(&AirOsStr, &AirOsStr). Aucune fonction unsafe exposée (l’unsafe repr(transparent) est interne à from_bytes, avec // SAFETY:).

Tests (section 2). Round-trips AirString::from_utf8 (valide/invalide), from_utf8_lossy (U+FFFD), normalisation idempotente (normalize(NFC)normalize(NFC) = normalize(NFC)) et vecteurs Unicode connus ; casing turc (i/İ) locale-dépendant (tr vs en) ; grapheme_count sur emoji ZWJ / combinaisons ; collation fr (accents) ; AirPath non-UTF-8 (octets 0x80), to_c_string rejette le NUL interne, join/parent/normalize_lexically inverses. Fuzzing : AirString::from_utf8 et AirPath::from_bytes + normalize (données externes, Principe 3) — jamais de panique.


Section 3 — Temps

Distinction cardinale (macro-architecture). Temps monotone (AirInstant, pour mesurer des durées, ne recule jamais, sans rapport au calendrier) vs temps calendaire (AirDateTime, mur, sujet aux sauts d’horloge/NTP/DST). On ne les confond jamais : pas d’« instant » converti en date sans passer par une horloge mur explicite.

Frontière de couche (décision). Le formatage localisé d’une date (rendu en chaîne selon une locale et un motif culturel) relève de la couche 2 (ADR-016, formats culturels via air-runtime). En couche 1, AirDateTime est le type valeur calendaire (champs, arithmétique, conversion de calendriers via icu4x), sans formatage localisé. La surface icu4x « complète » au sens couche 1 = normalisation/segmentation/collation (section 2) + calendriers (ici) ; le formatage reste couche 2.

3.1 AirInstant / AirDuration

#![allow(unused)]
fn main() {
/// Point monotone (consomme l'horloge `CLOCK_MONOTONIC` de la couche 0).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AirInstant { /* … */ }

/// Durée signée bornée (enrobe une représentation ns en i128 ou (secs,nanos)).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AirDuration { /* … */ }

impl AirInstant {
    /// Maintenant (monotone). Consomme la couche 0.
    /// # Errors `AirError` si l'horloge kernel échoue (très rare).
    pub fn now() -> AirResult<Self>;
    /// Durée écoulée depuis `earlier` (saturating si `self < earlier`).
    pub fn duration_since(&self, earlier: AirInstant) -> AirDuration;
    pub fn checked_add(&self, d: AirDuration) -> Option<AirInstant>;  // Principe 2
    pub fn saturating_add(&self, d: AirDuration) -> AirInstant;
}

impl AirDuration {
    pub const ZERO: Self;
    pub fn from_secs(s: u64) -> Self;
    pub fn from_millis(ms: u64) -> Self;
    pub fn from_nanos(ns: u64) -> Self;
    pub fn as_secs_f64(&self) -> f64;
    pub fn checked_add(&self, other: AirDuration) -> Option<AirDuration>;
    pub fn checked_sub(&self, other: AirDuration) -> Option<AirDuration>;
}

/// Point d'horloge murale (temps depuis l'époque UNIX, `CLOCK_REALTIME`) —
/// équivalent brut de `std::time::SystemTime`. Distinct d'`AirInstant`
/// (monotone) et d'`AirDateTime` (calendaire, au-dessus). Peut **reculer**.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AirSystemTime { /* nanos: i128 depuis l'époque UNIX */ }

impl AirSystemTime {
    pub const UNIX_EPOCH: AirSystemTime;
    /// Maintenant (horloge murale). Consomme la couche 0 (`CLOCK_REALTIME`).
    /// # Errors `AirError` si l'horloge kernel échoue (très rare).
    pub fn now() -> AirResult<Self>;
    /// Durée écoulée depuis `earlier`, ou **erreur** si l'horloge a reculé
    /// (`earlier > self`) — pas de négatif silencieux, pas de panique.
    /// # Errors `InvalidInput` si `earlier > self`.
    pub fn duration_since(&self, earlier: AirSystemTime) -> AirResult<AirDuration>;
    pub fn checked_add(&self, d: AirDuration) -> Option<AirSystemTime>; // Principe 2
    pub fn checked_sub(&self, d: AirDuration) -> Option<AirSystemTime>; // Principe 2
    /// Composantes époque UNIX (comme `timespec` / `SystemTime`).
    pub fn from_unix(seconds: i64, nanoseconds: u32) -> Self;
    pub fn as_unix_seconds(&self) -> i64;   // division euclidienne
    pub fn subsec_nanos(&self) -> u32;      // reste euclidien ∈ [0, 1e9)
}

/// Sommeil (libc `nanosleep` / PAL `thread::sleep`), via `clock_nanosleep`
/// (couche 0) contre une échéance **absolue monotone** (pas de dérive).
/// # Errors `InvalidInput` (durée/échéance hors amplitude) ou `AirError`
/// mappée depuis l'`Errno` couche 0 (hors `EINTR`, retenté).
pub fn sleep(duration: AirDuration) -> AirResult<()>;
pub fn sleep_until(deadline: AirInstant) -> AirResult<()>;
}

Décisions. AirInstant ne s’expose pas en valeur absolue (pas d’« epoch monotone » comparable entre processus) — uniquement des différences. Arithmétique checked_*/saturating_* explicite (Principe 2), jamais d’opérateur nu qui panique.

Horloge murale (AirSystemTime). Socle brut sous le calendaire AirDateTime (qu’il ne remplace pas). Contrairement au monotone, elle peut reculer (NTP, réglage manuel) : duration_since renvoie donc un AirResult et refuse honnêtement une durée négative (InvalidInput) plutôt que de mentir ou de paniquer. Les composantes brutes (from_unix/as_unix_seconds/subsec_nanos, découpage euclidien ⇒ sous-seconde toujours dans [0, 1e9), même avant 1970) alimentent les deux faces : timespec/time_t côté libc, SystemTime côté PAL.

Sommeil (sleep/sleep_until). Attente absolue contre CLOCK_MONOTONIC (sleep(d) = sleep_until(now + d)) : viser un point fixe élimine la dérive. Retry EINTR = responsabilité de la couche 1 : la couche 0 remonte EINTR sans jamais retenter (ADR-021, convention 2) ; la boucle de sleep_until re-appelle clock_nanosleep avec la même échéance absolue jusqu’au succès (correct par construction, aucune dérive). Une échéance déjà passée rend immédiatement Ok(()). no_std-propre (zéro alloc), exposable aux deux faces.

3.2 AirDateTime / AirCalendar

#![allow(unused)]
fn main() {
/// Calendrier (icu4x). Grégorien par défaut, autres disponibles (ADR-016).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AirCalendar { Gregorian, Buddhist, Japanese, Islamic, Hebrew, Persian, /* … icu4x */ }

/// Date-heure calendaire (mur). Enrobe un `icu::calendar::DateTime`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AirDateTime { /* … */ }

impl AirDateTime {
    /// Maintenant, horloge mur (`CLOCK_REALTIME`, couche 0), calendrier grégorien UTC.
    /// # Errors `AirError` si l'horloge kernel échoue.
    pub fn now_utc() -> AirResult<Self>;
    /// Construit depuis des composantes, dans un calendrier donné.
    /// # Errors `InvalidInput` si la date est invalide (ex. 30 février).
    pub fn from_components(cal: AirCalendar, year: i32, month: u8, day: u8,
                           hour: u8, minute: u8, second: u8) -> AirResult<Self>;
    /// Convertit vers un autre calendrier (même instant, représentation différente).
    pub fn to_calendar(&self, cal: AirCalendar) -> AirDateTime;
    // Accès aux champs
    pub fn year(&self) -> i32;
    pub fn month(&self) -> u8;
    pub fn day(&self) -> u8;
    pub fn hour(&self) -> u8;
    pub fn minute(&self) -> u8;
    pub fn second(&self) -> u8;
    // Arithmétique calendaire (déléguée à icu4x, gère les irrégularités de calendrier)
    pub fn add_days(&self, days: i64) -> AirResult<AirDateTime>;
    // PAS de méthode de formatage localisé ici → couche 2.
}
}

Tests (section 3). Monotonie d’AirInstant::now (deux appels successifs non décroissants) ; duration_since saturant ; arithmétique checked_* (débordement → None) ; AirDateTime round-trip de composantes ; rejet du 30 février ; conversion grégorien↔autre calendrier (vecteurs icu4x connus) ; absence de formatage localisé (vérifier qu’aucune API de rendu localisé n’est exposée). Property-based : from_secs(n).as_secs_f64() ≈ n.

Horloge murale. AirSystemTime::now > UNIX_EPOCH et plausible (postérieur à une date récente) ; duration_since cas normal et cas reculé (earlier > self → erreur) construits déterministe­ment via from_unix ; checked_add/checked_sub (normal + débordement → None) ; round-trip des composantes époque (positives et négatives).

Sommeil. sleep(petite durée) mesuré la durée demandée ; sleep_until d’une échéance déjà passée rend immédiatement Ok ; rejet des durées/échéances hors amplitude. Branche retry EINTR couverte par injection de faute (doctrine Air) : un signal réel (SIGUSR1 via tgkill, handler sans SA_RESTART) tombe en plein clock_nanosleep ; la boucle re-appelle avec la même échéance absolue et termine à l’heure malgré l’interruption. Les helpers de classification/mapping d’erreur de sommeil sont testés unitairement (cross-clock EINVAL, variantes SleepError).


Section 4 — Encodage : base64 / hex (encoding)

Extension du périmètre du cœur, actée le 2026-06-15. Le module encoding (sous-modules base64 et hex) entre dans le cœur d’air-base-lib, std-only et sans dépendance.

Rationale. L’analyse « AirData / CFData » a conclu qu’Air n’introduit pas de wrapper d’octets : std couvre déjà le besoin (&[u8]/Vec<u8>, chunks, from_utf8, to/from_{le,be}_bytes). Le seul trou réel côté octets est l’absence de base64 et de hex ; on les ajoute donc ici, et rien d’autre. Le vocabulaire d’octets reste celui de std (&[u8]/Vec<u8>/impl AsRef<[u8]>).

#![allow(unused)]
fn main() {
pub mod base64 {
    /// RFC 4648 §4 (standard, alphabet `+/`) ou §5 (URL-safe, alphabet `-_`).
    pub enum Base64Alphabet { Standard, UrlSafe }
    /// Bourrage `=` : émis/exigé (`Padded`) ou absent/interdit (`Unpadded`).
    pub enum Padding { Padded, Unpadded }
    /// Total (infaillible) ; sortie ASCII.
    pub fn encode(input: impl AsRef<[u8]>, alphabet: Base64Alphabet, padding: Padding) -> String;
    /// # Errors `InvalidData` (caractère hors alphabet, longueur/padding invalides, bits de fin non nuls).
    pub fn decode(input: &str, alphabet: Base64Alphabet, padding: Padding) -> AirResult<Vec<u8>>;
}
pub mod hex {
    /// Total (infaillible) ; sortie **minuscule** (`0-9 a-f`).
    pub fn encode(input: impl AsRef<[u8]>) -> String;
    /// Accepte maj. et min. # Errors `InvalidData` (longueur impaire, caractère non hexadécimal).
    pub fn decode(input: &str) -> AirResult<Vec<u8>>;
}
}

Choix retenus (Passe 1 — à valider). Encodage fait main (table d’alphabet explicite), std-only (règle des 80 %, ADR-024) ; variante base64 + politique de padding explicites à l’appel (pas de défaut caché, Principe 7) ; décodage strict (« parse, don’t validate », Principe 4) → erreurs en AirErrorKind::InvalidData (ADR-019, variant existant réutilisé) ; familles exposées namespacées (encoding::base64 / encoding::hex, pas d’aplatissement pour éviter la collision encode/decode).

Délibérément hors v1. Encodage incrémental/streaming (one-shot ; le chunking reste l’affaire des slices std) ; autres bases (base32/58/85) ; constant-time — base64/hex ne sont pas des primitives crypto et ne garantissent pas un temps constant ; l’encodage de secrets sensibles au canal temporel relève d’air-crypto. Ces non-objectifs sont assumés, pas des oublis.

Tests (section 4 — Passe 2). Vecteurs RFC 4648 (KAT) base64 standard/URL-safe padded/unpadded et hex ; property-based round-trip decode(encode(x)) == x ; fuzzing du décodage (données externes, Principe 3) ; rejet exhaustif des entrées invalides (alphabet, longueur, padding).


Récapitulatif du cœur air-base-lib

DomaineTypes / API principaux
ErreursAirError, AirResult, AirErrorKind, From<Errno>, ResultContextExt::{context, with_context}
LocaleAirLocale (type seul ; résolution/formatage → couche 2)
ChaînesAirString (UTF-8, normalisation, casing locale, segmentation, collation), NormalizationForm
CheminsAirPath (octets, lexical, pont to_c_string), conversions AirStringAirPath
TempsAirInstant, AirDuration (monotone) ; AirSystemTime (horloge murale) ; sleep/sleep_until (nanosleep, retry EINTR couche 1) ; AirDateTime, AirCalendar (calendaire, sans formatage)
Encodageencoding::base64 (Base64Alphabet, Padding, encode/decode), encoding::hex (encode/decode) — std-only, sans dépendance

Différé : AirLog, AirUuid/AirId128/AirMonotonicId, AirConfig, et la surface ABI C.

Stratégie de tests (cœur)

  • Couverture 100 % lignes + branches (Principe 1, couche fondatrice), mesurée comme la couche 0.
  • Unitaires : table de mapping Errno→AirErrorKind exhaustive ; invariants AirString/AirPath ; arithmétique temporelle (bords checked_*).
  • Property-based (proptest) : normalisation idempotente ; from_utf8into_bytes round-trip ; join/parent ; durées.
  • Fuzzing (cargo-fuzz) : AirString::from_utf8, AirPath::from_bytes, AirString::normalize, AirLocale::parse (données externes, Principe 3).
  • Vecteurs icu4x : casing turc, collation française, segmentation emoji/ZWJ, conversions de calendriers — comparés à des valeurs de référence.
  • Doctests : chaque exemple de la spec compile et passe.

Décisions de fond (cœur air-base-lib)

  1. AirError générique réconcilie ADR-019 : monnaie commune + futur porteur ABI C ; les enums par-crate thiserror restent l’idiome au-dessus ; l’Errno exact est toujours récupérable (errno()), on ne perd jamais le code kernel.
  2. AirError écrit à la main (pas de thiserror dans air-base-lib) pour rester simple à exposer en ABI C (macro-architecture).
  3. AirLocale (type) en couche 1, résolution + formatage en couche 2 : couche 1 = le type, couche 2 = la politique de cascade (ADR-016).
  4. AirString (UTF-8) ≠ AirPath (octets) strictement distincts (Principe 3) ; conversions explicites, jamais implicites ; Eq binaire, comparaison Unicode/locale explicite.
  5. AirPath purement lexical ; la canonicalisation (syscalls) est dans air-filesystem. Pont to_c_string (validation NUL) centralisé ici.
  6. Temps monotone vs calendaire jamais confondus ; pas de formatage de date localisé en couche 1 (→ couche 2) ; arithmétique défensive (Principe 2).
  7. icu4x = unique dépendance externe structurante du cœur (exception 80 % déjà actée, ADR-016).

Décision enregistrée pour la spec AirLog (différée)

AirLog écrira vers journald via le protocole socket natif (/run/systemd/journal/socket), sans dépendre d’une bibliothèque systemd → la couche 1 reste pure (pas de dépendance vers la couche 2). À détailler dans la spec logging à venir.

Travail à reprendre

  • Specs air-base-lib suivantes : logging (AirLog, décision journald-socket ci-dessus), identifiants (AirUuid v7, AirId128, AirMonotonicId), configuration (AirConfig TOML/XDG).
  • Surface ABI C (libair-base.so, air_*, versioned symbols, conformité ABI — ADR-012/ADR-027), une fois l’API Rust du cœur stabilisée.
  • Autres crates couche 1 : air-filesystem, air-process, air-socket, air-crypto, air-device, air-thread, air-memory.

Licence du document : MPL 2.0 Statut : Spécification technique du cœur de air-base-lib (couche 1). API Rust ; ABI C différée. Établit le format de spec couche 1.