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-octetsAirPath, tempsAirInstant/AirDuration(monotone),AirSystemTime(horloge murale) +sleep/sleep_until(nanosleep), encodages, log) a été extrait dans la crateair-base-core(no_std, zéroicu4x) pour débloquer la fermeture runtime*-linux-air.air-base-libconserve l’i18n (AirString/AirLocale, temps calendaireAirDateTime/AirCalendar), dépend d’air-base-coreet 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ésormaisair-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) :
- Erreurs —
AirError,AirResult, l’absorption d’Errno(couche 0). - Chaînes & chemins —
AirString(Unicode, viaicu4x),AirPath(octets, potentiellement non-UTF-8),AirLocale. - Temps —
AirInstant(monotone),AirDuration,AirDateTime+AirCalendar(calendaire, viaicu4x).
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
-
Rust idiomatique, aucun
unsafeexposé. La frontière couche 0 → couche 1 est « API Rust sûre,unsafeconfiné en couche 0 » (macro-architecture §frontières).air-base-libn’expose aucune fonctionunsafe; toutunsafeinterne éventuel porte un// SAFETY:(vérifié CI). -
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).
-
Nommage explicite (ADR-029). Surface verbeuse, sans abréviation (
AirString,AirDateTime,normalize,to_lowercase_in_locale…). Noms d’autorité conservés : typesstd(OsString,Path), termesicu4xréexposés, vocabulaire Unicode (NFC,grapheme). -
Arithmétique défensive (Principe 2). Toute opération temporelle pouvant déborder utilise
checked_*/saturating_*explicitement ; conversions lossy parTryFrom, jamaisas. -
Zéro présomption d’encodage (Principe 3). Distinction stricte
AirString(UTF-8 garanti) vsAirPath(octets, peut être non-UTF-8). Toute construction depuis des octets externes passe par une validation faillible. -
Pas de proc-macro Air ici (macro-architecture §air-base-lib) : la crate doit rester simple à exposer en ABI C.
AirErrorest écrit à la main (pas dethiserrordansair-base-lib) ;thiserrorreste 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. Composantsicu_*tirés à la carte (modularité native d’icu4x).air-sys-types(couche 0) : pour absorberErrno.air-sys-syscall(couche 0) : pourAirInstant::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 :
Errnominimaliste en couche 0 ; un type d’erreur enrichi par crate (viathiserror) au-dessus. La macro-architecture pose en plus unAirErrorgénérique dansair-base-lib. On réconcilie ainsi, sans contredire ADR-019 :
AirErrorest la monnaie d’erreur commune d’air-base-libet le futur porteur de la frontière ABI C (où l’on ne peut pas exposer les enums Rust par-crate). Il absorbeErrnoet 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/depuisAirErroraux frontières (From).AirErrorne 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’aliasEWOULDBLOCK ≡ EAGAIN) ont été ajoutés aux constantesair_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) etAirDateTimeont besoin du type de locale. On place donc le typeAirLocale(parsing/représentation BCP 47, enrobage deicu4x ::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).
AirPathne 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 dansair-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 dereadlink: ce sont des suites d’octets (non garanties UTF-8) qui ne sont pas des chemins — leur imposerAirPath(qui porte une sémantique lexicale de chemin : séparateurs/,./..,join,parent) serait un abus de type. On introduit le pendant Air destd::ffi::OsStr/OsString: la « chaîne-OS » sans aucune sémantique au-delà d’être des octets opaques. Deux types, patronstdexact :AirOsStremprunté (#[repr(transparent)]sur[u8]) etAirOsStringpossédé (Vec<u8>). Zone 2 ADR-029 : noms miroir destd(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::get
→ Option<&'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,AirDateTimeest le type valeur calendaire (champs, arithmétique, conversion de calendriers viaicu4x), sans formatage localisé. La surfaceicu4x« 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éterministement 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
| Domaine | Types / API principaux |
|---|---|
| Erreurs | AirError, AirResult, AirErrorKind, From<Errno>, ResultContextExt::{context, with_context} |
| Locale | AirLocale (type seul ; résolution/formatage → couche 2) |
| Chaînes | AirString (UTF-8, normalisation, casing locale, segmentation, collation), NormalizationForm |
| Chemins | AirPath (octets, lexical, pont to_c_string), conversions AirString↔AirPath |
| Temps | AirInstant, AirDuration (monotone) ; AirSystemTime (horloge murale) ; sleep/sleep_until (nanosleep, retry EINTR couche 1) ; AirDateTime, AirCalendar (calendaire, sans formatage) |
| Encodage | encoding::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→AirErrorKindexhaustive ; invariantsAirString/AirPath; arithmétique temporelle (bordschecked_*). - Property-based (proptest) : normalisation idempotente ;
from_utf8∘into_bytesround-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)
AirErrorgénérique réconcilie ADR-019 : monnaie commune + futur porteur ABI C ; les enums par-cratethiserrorrestent l’idiome au-dessus ; l’Errnoexact est toujours récupérable (errno()), on ne perd jamais le code kernel.AirErrorécrit à la main (pas dethiserrordansair-base-lib) pour rester simple à exposer en ABI C (macro-architecture).AirLocale(type) en couche 1, résolution + formatage en couche 2 : couche 1 = le type, couche 2 = la politique de cascade (ADR-016).AirString(UTF-8) ≠AirPath(octets) strictement distincts (Principe 3) ; conversions explicites, jamais implicites ;Eqbinaire, comparaison Unicode/locale explicite.AirPathpurement lexical ; la canonicalisation (syscalls) est dansair-filesystem. Pontto_c_string(validation NUL) centralisé ici.- Temps monotone vs calendaire jamais confondus ; pas de formatage de date localisé en couche 1 (→ couche 2) ; arithmétique défensive (Principe 2).
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-libsuivantes : logging (AirLog, décision journald-socket ci-dessus), identifiants (AirUuidv7,AirId128,AirMonotonicId), configuration (AirConfigTOML/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.