commit 2556698dd39d15ca9e3ceb381f6a35f1eff254ba Author: cloudion-labo Date: Sun Aug 16 01:07:31 2026 +0000 🩀 bions-rust vague 1 : 6 briques build-your-own-x — on ne les rebuild plus jamais Principe RS-7 : « dĂšs qu'on build un truc, plus personne n'a Ă  le rebuild — la seule chose Ă  faire est l'optimisation. » (nexus/RepoVerse) - bion-vc : horloges vectorielles + MvReg fork-visible (LA spec xion-relativiste-v0 enfin codĂ©e — CRDT testĂ© par permutations) - bion-triplet : l'Adressage GĂ©nĂ©ratif (gen_hash BLAKE3, coords, rĂ©sidu ; rĂ©sidu vide quand dĂ©jĂ -su ; align dĂ©cidable au bit) - bion-tsoinlog: journal append-only rejouable (CRC32 maison, crash-recovery) - bion-kv : magasin clĂ©-valeur bitcask (compaction atomique, tombstones) - bion-regex : moteur Thompson NFA linĂ©aire (jamais exponentiel — Russ Cox) - bion-git : mini-git content-addressed (SHA-1 maison + vecteurs officiels, branches divergentes = le fork visible) 129 tests verts, clippy 0 warning, doc française = chaque bion est un cours. Co-Authored-By: Claude Fable 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fb2b3b8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,105 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "bion-git" +version = "0.1.0" + +[[package]] +name = "bion-kv" +version = "0.1.0" + +[[package]] +name = "bion-regex" +version = "0.1.0" + +[[package]] +name = "bion-triplet" +version = "0.1.0" +dependencies = [ + "blake3", +] + +[[package]] +name = "bion-tsoinlog" +version = "0.1.0" + +[[package]] +name = "bion-vc" +version = "0.1.0" + +[[package]] +name = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2b4cb1f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +# bions-rust — les briques Rust du xerboxion qu'on ne rebuild PLUS JAMAIS. +# Principe RS-7 (16.08) : « dĂšs qu'on build un truc, que plus personne d'autre +# n'aie Ă  le build — crĂ©er des blocs pour que la seule chose Ă  faire soit +# l'optimisation, pas de recrĂ©er la roue Ă  chaque fois. » (le principe mĂȘme +# du nexus et de RepoVerse). Inspiration pĂ©dagogique : build-your-own-x, +# rĂ©implĂ©mentĂ© en Rust, orientĂ© besoins rĂ©els du projet. +[workspace] +resolver = "2" +members = [ + "bion-vc", + "bion-triplet", + "bion-tsoinlog", + "bion-kv", + "bion-regex", + "bion-git", +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3582141 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# bions-rust — les briques qu'on ne rebuild PLUS JAMAIS + +> **Principe RS-7 (16.08.2026)** : « dĂšs qu'on build un truc, que plus personne +> d'autre n'aie Ă  le build — crĂ©er des blocs pour que la seule chose Ă  faire +> soit **l'optimisation**, pas de recrĂ©er la roue Ă  chaque fois. » +> +> C'est le principe mĂȘme du nexus et de RepoVerse : chaque bion est construit +> **une fois**, depuis les principes, puis vit pour toujours. On ne le remplace +> pas — on le **fork pour l'optimiser**. + +PĂ©dagogie : [build-your-own-x](https://github.com/codecrafters-io/build-your-own-x) — +chaque crate est rĂ©implĂ©mentĂ© **depuis les principes** (pas de wrapper de lib), +en Rust 2021, **std-only**, zĂ©ro `unsafe`, avec des doc-comments français riches : +chaque bion est aussi **un cours**. + +## Les 6 bions (vague 1) + +| Bion | Quoi | Inspiration | État | +|---|---|---|---| +| [`bion-vc`](bion-vc/) | Horloges vectorielles + Multi-Value Register « fork visible » — l'algo de merge causal de `xion-relativiste-v0` | CRDT / Dynamo / papiers Lamport-Fidge-Mattern | ✅ tests verts | +| [`bion-triplet`](bion-triplet/) | Le triplet d'Adressage GĂ©nĂ©ratif : `(hash_gĂ©nĂ©rateur, coordonnĂ©es, hash_rĂ©sidu)` — une donnĂ©e = une adresse dans un espace gĂ©nĂ©ratif | Adressage GĂ©nĂ©ratif (spine cataploxion) / Kolmogorov | ✅ tests verts | +| [`bion-tsoinlog`](bion-tsoinlog/) | Journal d'Ă©vĂ©nements append-only rejouable — la primitive de la machine Ă  tsoins | build-your-own *event log* / Kafka minimal | ✅ tests verts | +| [`bion-kv`](bion-kv/) | Magasin clĂ©-valeur append-only : log + index mĂ©moire + compaction + CRC32 maison | build-your-own **database** (Bitcask) | ✅ tests verts | +| [`bion-regex`](bion-regex/) | Moteur regex NFA de Thompson, simulation par ensembles d'Ă©tats : temps **linĂ©aire garanti**, jamais de backtracking exponentiel | build-your-own **regex** (Russ Cox) | ✅ tests verts | +| [`bion-git`](bion-git/) | Mini-git : objets content-addressed blob/tree/commit, SHA-1 maison, branches — le bion du principe « fork = libertĂ© » | build-your-own **git** | ✅ tests verts | + +Chaque crate a son `README.md` : quoi / pourquoi / exemple / liens +build-your-own-x / **comment l'optimiser** (l'invitation au fork). + +## Utiliser + +```bash +cargo test --workspace # tout doit ĂȘtre vert, toujours +cargo test -p bion-regex # un seul bion +cargo doc --workspace --open # le cours complet +``` + +## Contribuer (le nexus) + +On ne recrĂ©e pas la roue : on **fork et on optimise**. + +1. Fork le repo sur **git.j0bot.ch** (RepoVerse, le nexus) — la contribution + passe par les repos du nexus, pas par GitHub. +2. **L'API publique est STABLE pour toujours** (rĂ©trocompatibilitĂ© = dogme) : + on optimise l'intĂ©rieur, on ajoute — on ne casse jamais un appelant. +3. RĂšgles : Rust 2021, std-only, zĂ©ro `unsafe` non justifiĂ©, ≄8 tests + unitaires par crate (cas limites inclus), doc-comments français. +4. Chaque README de crate se termine par « comment l'optimiser » : commence lĂ . + +## Vague 2 (candidats, liste build-your-own-x) + +- **bion-shell** — un shell minimal : parse, fork/exec, pipes, redirections + (*build-your-own-shell*). +- **bion-http-parser** — parseur HTTP/1.1 incrĂ©mental sans allocation inutile + (*build-your-own-web-server*, la moitiĂ© parsing). +- **bion-bencode / torrent** — codec bencode + mĂ©tainfo torrent, la porte vers + le P2P souverain (torrion/torrax) (*build-your-own-bittorrent*). +- **bion-interpreter** — lexer → parser → tree-walking interpreter d'un + petit langage (le chemin vers xerlang) (*build-your-own-programming-language*). + +MĂȘme contrat : depuis les principes, std-only, un cours dans les doc-comments, +et une fois vert — plus jamais rebuild, seulement optimisĂ©. diff --git a/bion-git/Cargo.toml b/bion-git/Cargo.toml new file mode 100644 index 0000000..d2d1f71 --- /dev/null +++ b/bion-git/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "bion-git" +version = "0.1.0" +edition = "2021" +description = "Mini-git from scratch : objets content-addressed blob/tree/commit, SHA-1 maison, branches — le bion du principe « fork = libertĂ© »" +license = "AGPL-3.0-only" + +# std only — aucun crate externe, c'est le dogme des bions : +# chaque brique est relisible de bout en bout, comme un cours. +# MĂȘme le SHA-1 est implĂ©mentĂ© ici (src/sha1.rs, ~80 lignes). +[dependencies] diff --git a/bion-git/README.md b/bion-git/README.md new file mode 100644 index 0000000..68ea8b7 --- /dev/null +++ b/bion-git/README.md @@ -0,0 +1,116 @@ +# bion-git — mini-git from scratch, le bion du principe « fork = libertĂ© » + +## Quoi + +Le noyau conceptuel de git, rĂ©implĂ©mentĂ© depuis les principes en **Rust std-only, +zĂ©ro dĂ©pendance, zĂ©ro unsafe** — mĂȘme le SHA-1 est maison (`src/sha1.rs`, ~90 +lignes, vĂ©rifiĂ© contre les vecteurs officiels du NIST) : + +- **magasin d'objets content-addressed** : `blob` / `tree` / `commit`, nommĂ©s + par le SHA-1 de leur contenu, stockĂ©s dans `.bgit/objects/ab/cdef
` + (2 caractĂšres de rĂ©pertoire / 38 de fichier, comme git) ; +- **instantanĂ©s** : `write_tree` photographie un rĂ©pertoire, `checkout_tree` + le rematĂ©rialise n'importe oĂč ; +- **histoire** : commits chaĂźnĂ©s (0, 1 ou N parents — le merge est + reprĂ©sentable), `log` en remontant le premier parent ; +- **branches** : 41 octets dans `refs/heads/` — forker est gratuit. + +Les ids de **blobs et trees sont bit-Ă -bit ceux du vrai git** (testĂ© contre +`git hash-object` et `git write-tree`, tri des entrĂ©es compris). + +## Pourquoi + +C'est le principe RS-7 fait structure de donnĂ©es : *« dĂšs qu'on build un truc, +plus personne n'a Ă  le rebuild — des blocs, la seule chose Ă  faire est +l'optimisation »*. Le content-addressing garantit exactement ça : un contenu +dĂ©jĂ  construit porte un nom absolu, universel, immuable — le rebuild est +impossible par construction, on ne peut que rĂ©fĂ©rencer ou optimiser. + +**Le lien avec le xerboxion** : une branche est un **chemin** dans l'espace des +Ă©tats ; un fork une **bifurcation visible**. Deux branches divergentes partagent +tout leur passĂ© commun sans copier un octet — la divergence n'Ă©crase rien, ne +cache rien : elle *existe* dans le graphe, on peut la montrer, la comparer, la +refermer par un merge commit tracĂ©. C'est la sĂ©mantique gelĂ©e par RS-7 +(« divergence = branche, jamais d'Ă©crasement silencieux » — voir `bion-vc` +pour son pendant causal/CRDT) et le socle du nexus/RepoVerse : contribution += repo, reprise = fork, rĂ©plication = pull des ids manquants. + +## Exemple + +```rust +use bion_git::Repo; + +let repo = Repo::init(dir)?; // .bgit/ +let tree = repo.write_tree(dir)?; // photographier +let racine = repo.commit(tree, &[], "premier instant", "rs-1")?; +repo.branch("main", racine)?; +repo.branch("fork", racine)?; // forker = 41 octets + +// diverger
 +let cm = repo.commit(tree2, &[racine], "chemin main", "rs-1")?; +let cf = repo.commit(tree3, &[racine], "bifurcation", "rs-7")?; +repo.branch("main", cm)?; +repo.branch("fork", cf)?; + +for c in repo.log(repo.branch_target("fork")?)? { // l'histoire + println!("{} {} — {}", c.id, c.author, c.message); +} +repo.checkout_tree(&tree, dest)?; // rematĂ©rialiser +``` + +API complĂšte : `Repo::{init, open, hash_object, cat_object, write_tree, +read_tree, checkout_tree, commit, commit_at, read_commit, log, branch, +branch_target, branches}` + `Id`, `Kind`, `TreeEntry`, `Commit`, +`sha1::{sha1, to_hex}`. Cette API est **stable pour toujours** (dogme des +bions) : on peut lui ajouter, jamais lui retirer. + +## Écarts assumĂ©s avec le vrai git + +| Écart | Pourquoi | +|---|---| +| Objets **non compressĂ©s** (pas de zlib) | std-only et lisible ; git compresse en deflate *aprĂšs* le hash, donc les ids restent identiques — seul l'octet sur disque diffĂšre. C'est LE point d'optimisation invitĂ© (voir plus bas). | +| Commits : auteur = chaĂźne libre, fuseau figĂ© `+0000`, `committer` = copie d'`author` | mĂȘme format textuel que git, mais sans imposer `Nom ` ; les ids de commits ne coĂŻncident avec git que si on reproduit son format exact | +| Pas d'index/staging | `write_tree` photographie le rĂ©pertoire directement | +| Pas de packfiles, refs non packĂ©es | un objet = un fichier, un ref = un fichier | +| Modes `100644` et `40000` seulement | ni exĂ©cutables ni symlinks (refus explicite) | +| `log` = premier parent | le graphe complet est dans `Commit::parents`, Ă  l'appelant d'explorer | + +## build-your-own-x + +Correspond au chapitre **« Build your own Git »** de +[codecrafters-io/build-your-own-x](https://github.com/codecrafters-io/build-your-own-x#build-your-own-git) : + +- *Write yourself a Git!* (thblt) — la rĂ©fĂ©rence dont ce bion suit le pĂ©rimĂštre ; +- *Git Internals — Plumbing and Porcelain* (Pro Git, ch. 10) — le format d'objets ; +- CodeCrafters « Build your own Git » — mĂȘmes Ă©tapes (init, hash-object, + cat-file, write-tree, commit-tree). + +Et pour le SHA-1 : FIPS 180-1 (NIST, 1995) — l'implĂ©mentation suit la spec +pas Ă  pas, commentĂ©e en français. + +## Comment l'optimiser (l'invitation au fork) + +Le contrat est l'API + le format d'objets ; tout le reste est forkable : + +1. **zlib/deflate** : compresser les objets Ă  l'Ă©criture (ids inchangĂ©s !) — + c'est l'Ă©cart n°1 avec git, et un « build your own zlib » pĂ©dagogique en soi ; +2. **packfiles + delta-encoding** : regrouper N objets en un fichier, encoder + les versions successives en deltas — le vrai gain d'espace de git ; +3. **SHA-256** : brancher un second hash (git fait sa propre migration) ; +4. **index/staging** : un cache `(chemin, mtime, id)` pour ne pas re-hasher + les fichiers inchangĂ©s Ă  chaque `write_tree` ; +5. **transport** : `pull(remote)` = Ă©numĂ©rer les ids manquants et les copier — + la rĂ©plication pull/apply du xerboxion tombe naturellement du + content-addressing ; +6. **merge de trees** : diff3 entre deux trees et leur ancĂȘtre commun, avec + conflits *visibles* (jamais d'arbitrage silencieux — brancher `bion-vc`). + +## Tests + +`cargo test -p bion-git` — 15 tests unitaires + 2 doc-tests : vecteurs SHA-1 +officiels (dont le million de « a »), ids identiques au vrai git (blobs, tree +vide, tree imbriquĂ© vĂ©rifiĂ© contre `git write-tree`), roundtrips +blob/tree/commit, log d'une chaĂźne, **fork de deux branches divergentes depuis +un parent commun**, merge Ă  deux parents, dĂ©tection de corruption +(le magasin est auto-vĂ©rifiant), refus des traversĂ©es de chemin et de +l'injection dans le format commit. diff --git a/bion-git/src/lib.rs b/bion-git/src/lib.rs new file mode 100644 index 0000000..81f9eb7 --- /dev/null +++ b/bion-git/src/lib.rs @@ -0,0 +1,986 @@ +//! # bion-git — un mini-git from scratch, le bion du principe « fork = libertĂ© » +//! +//! Ce bion rĂ©implĂ©mente **le noyau conceptuel de git** depuis les principes +//! (esprit *build-your-own-x*), en std-only : un magasin d'objets +//! **content-addressed** (blob / tree / commit), des commits chaĂźnĂ©s qui +//! forment un graphe, et des branches qui ne sont que des noms posĂ©s sur +//! des commits. +//! +//! ## Le cours en trois minutes +//! +//! Git n'est pas un « gestionnaire de versions » : c'est un **graphe de +//! contenus immuables**. Trois types d'objets suffisent : +//! +//! - **blob** — le contenu brut d'un fichier, rien d'autre (pas de nom !) ; +//! - **tree** — un rĂ©pertoire : une liste triĂ©e d'entrĂ©es `(mode, nom, id)` +//! qui pointent vers des blobs ou d'autres trees ; +//! - **commit** — un instantanĂ© : un tree racine, zĂ©ro ou plusieurs parents, +//! un auteur, un message. +//! +//! Chaque objet est nommĂ© par le **SHA-1 de son contenu** (prĂ©fixĂ© d'un +//! en-tĂȘte `"{type} {taille}\0"`). ConsĂ©quences magiques : +//! +//! - deux contenus identiques n'existent **qu'une fois** (dĂ©duplication) ; +//! - un objet ne peut pas ĂȘtre modifiĂ© sans changer de nom (immuabilitĂ©) ; +//! - un commit scelle *transitivement* toute son histoire : son id dĂ©pend +//! de son tree ET de ses parents, donc de tout le passĂ© (chaĂźne de Merkle, +//! la mĂȘme idĂ©e que les blockchains — git l'a fait en 2005) ; +//! - **forker est gratuit** : une branche n'est qu'un fichier de 41 octets +//! contenant un id. Deux branches divergentes partagent tout leur passĂ© +//! commun sans copier un seul octet. +//! +//! Dans le vocabulaire du xerboxion : une branche est un **chemin** dans +//! l'espace des Ă©tats, un fork une **bifurcation visible** — le principe +//! gelĂ© par RS-7 (« divergence = branche, jamais d'Ă©crasement silencieux »), +//! ici incarnĂ© par la structure de donnĂ©es elle-mĂȘme. C'est le socle +//! conceptuel du nexus/RepoVerse : contribution = repo, reprise = fork. +//! +//! ## Écarts assumĂ©s avec le vrai git (documentĂ©s, pĂ©dagogiques) +//! +//! - **Pas de zlib** : les objets sont stockĂ©s *non compressĂ©s* dans +//! `.bgit/objects/ab/cdef
`. Le vrai git compresse chaque objet (deflate) +//! avant Ă©criture — mais le hash porte sur le contenu *dĂ©compressĂ©*, donc +//! nos identifiants de **blobs et trees sont exactement ceux de git** +//! (`git hash-object` donne les mĂȘmes ids, vĂ©rifiĂ© dans les tests). +//! - **Commits** : mĂȘme structure textuelle que git (`tree`/`parent`/ +//! `author`/`committer` puis message), mais l'auteur est une chaĂźne libre +//! et le fuseau est figĂ© Ă  `+0000` — les ids de commits ne coĂŻncident +//! donc avec git que si on reproduit son format d'auteur exact. +//! - **Pas d'index (staging), pas de packfiles, pas de merge automatique** : +//! [`Repo::write_tree`] photographie un rĂ©pertoire directement, et le +//! graphe accepte plusieurs parents (le merge est *reprĂ©sentable*, sa +//! rĂ©solution est le travail d'un autre bion — voir `bion-vc` pour la +//! sĂ©mantique causale du fork visible). +//! - **Modes** : seuls `100644` (fichier) et `40000` (rĂ©pertoire) sont +//! produits ; ni exĂ©cutables, ni symlinks. +//! +//! ## Exemple complet +//! +//! ``` +//! use bion_git::{Repo, Kind}; +//! +//! let dir = std::env::temp_dir().join(format!("bgit-doc-{}", std::process::id())); +//! std::fs::create_dir_all(dir.join("src")).unwrap(); +//! std::fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap(); +//! +//! let repo = Repo::init(&dir).unwrap(); +//! +//! // Photographier le rĂ©pertoire → un tree content-addressed. +//! let tree = repo.write_tree(&dir).unwrap(); +//! let c1 = repo.commit(tree, &[], "premier instant", "rs-1").unwrap(); +//! repo.branch("main", c1).unwrap(); +//! +//! // Le fork visible : une deuxiĂšme branche sur le mĂȘme commit. +//! repo.branch("fork", c1).unwrap(); +//! +//! // RematĂ©rialiser l'instantanĂ© ailleurs. +//! let dest = dir.join("restaurĂ©"); +//! repo.checkout_tree(&tree, &dest).unwrap(); +//! assert_eq!(std::fs::read_to_string(dest.join("src/main.rs")).unwrap(), "fn main() {}\n"); +//! # std::fs::remove_dir_all(&dir).unwrap(); +//! ``` + +pub mod sha1; + +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Identifiant d'objet : le SHA-1 (20 octets) du contenu prĂ©fixĂ© de son +/// en-tĂȘte. S'affiche en 40 caractĂšres hexadĂ©cimaux, comme git. +/// +/// C'est un *nom absolu* : le mĂȘme contenu a le mĂȘme [`Id`] dans tous les +/// dĂ©pĂŽts de l'univers — c'est ce qui rend la rĂ©plication triviale +/// (pull/apply : on ne transfĂšre que les ids manquants). +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Id(pub [u8; 20]); + +impl Id { + /// Reconstruit un [`Id`] depuis ses 40 caractĂšres hexadĂ©cimaux. + /// + /// Erreur (`InvalidInput`) si la longueur ou un caractĂšre est invalide. + pub fn from_hex(s: &str) -> io::Result { + let s = s.trim(); + if s.len() != 40 { + return Err(bad_input(format!("id : 40 hex attendus, reçu {}", s.len()))); + } + let mut out = [0u8; 20]; + for (i, byte) in out.iter_mut().enumerate() { + let hi = hex_val(s.as_bytes()[2 * i])?; + let lo = hex_val(s.as_bytes()[2 * i + 1])?; + *byte = (hi << 4) | lo; + } + Ok(Id(out)) + } + + /// Les 40 caractĂšres hexadĂ©cimaux de l'identifiant. + pub fn to_hex(&self) -> String { + sha1::to_hex(&self.0) + } +} + +impl fmt::Display for Id { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_hex()) + } +} + +impl fmt::Debug for Id { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Id({})", self.to_hex()) + } +} + +impl FromStr for Id { + type Err = io::Error; + fn from_str(s: &str) -> io::Result { + Id::from_hex(s) + } +} + +fn hex_val(c: u8) -> io::Result { + match c { + b'0'..=b'9' => Ok(c - b'0'), + b'a'..=b'f' => Ok(c - b'a' + 10), + b'A'..=b'F' => Ok(c - b'A' + 10), + _ => Err(bad_input(format!( + "caractĂšre hex invalide : {:?}", + c as char + ))), + } +} + +fn bad_input(msg: String) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, msg) +} + +fn bad_data(msg: String) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, msg) +} + +/// Les trois natures d'objet du magasin — les mĂȘmes que git. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Kind { + /// Contenu brut d'un fichier (sans nom : le nom vit dans le tree parent). + Blob, + /// Un rĂ©pertoire : liste triĂ©e d'entrĂ©es `(mode, nom, id)`. + Tree, + /// Un instantanĂ© datĂ© et signĂ© d'un tree, chaĂźnĂ© Ă  ses parents. + Commit, +} + +impl Kind { + /// Le mot-clĂ© exact utilisĂ© dans l'en-tĂȘte d'objet (compatible git). + pub fn as_str(&self) -> &'static str { + match self { + Kind::Blob => "blob", + Kind::Tree => "tree", + Kind::Commit => "commit", + } + } + + fn from_bytes(b: &[u8]) -> io::Result { + match b { + b"blob" => Ok(Kind::Blob), + b"tree" => Ok(Kind::Tree), + b"commit" => Ok(Kind::Commit), + _ => Err(bad_data(format!( + "type d'objet inconnu : {:?}", + String::from_utf8_lossy(b) + ))), + } + } +} + +/// Une entrĂ©e de tree : `(mode, nom, id)` — un fichier ou un sous-rĂ©pertoire. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct TreeEntry { + /// Mode façon git : `"100644"` pour un fichier, `"40000"` pour un tree. + pub mode: String, + /// Nom *local* (sans `/`) — le chemin complet Ă©merge de la rĂ©cursion. + pub name: String, + /// L'objet pointĂ© (blob ou tree). + pub id: Id, +} + +/// Un commit relu depuis le magasin — l'instantanĂ© et sa place dans le graphe. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Commit { + /// L'identifiant du commit lui-mĂȘme. + pub id: Id, + /// Le tree racine : l'Ă©tat complet du monde Ă  cet instant. + pub tree: Id, + /// ZĂ©ro parent = racine ; un = pas ordinaire ; deux ou plus = merge. + pub parents: Vec, + /// Auteur, chaĂźne libre (le xerboxion signe en RS, pas en nom de personne). + pub author: String, + /// Secondes depuis l'epoch Unix (fuseau figĂ© `+0000`). + pub timestamp: u64, + /// Le message, restituĂ© verbatim. + pub message: String, +} + +/// Un dĂ©pĂŽt bion-git : un rĂ©pertoire de travail et son magasin `.bgit/`. +/// +/// Layout sur disque (calquĂ© sur git) : +/// +/// ```text +/// .bgit/ +/// HEAD → "ref: refs/heads/main\n" +/// objects/ab/cdef
 → objets bruts (2 chars / 38 chars), NON compressĂ©s +/// refs/heads/ → 40 hex + '\n' +/// ``` +pub struct Repo { + bgit: PathBuf, +} + +impl Repo { + /// CrĂ©e (ou rĂ©utilise) le magasin `.bgit/` dans `dir` et ouvre le dĂ©pĂŽt. + /// + /// Idempotent : rĂ©-initialiser un dĂ©pĂŽt existant ne dĂ©truit rien + /// (mĂȘme contrat que `git init`). + pub fn init(dir: &Path) -> io::Result { + let bgit = dir.join(".bgit"); + fs::create_dir_all(bgit.join("objects"))?; + fs::create_dir_all(bgit.join("refs/heads"))?; + let head = bgit.join("HEAD"); + if !head.exists() { + fs::write(&head, "ref: refs/heads/main\n")?; + } + Ok(Repo { bgit }) + } + + /// Ouvre un dĂ©pĂŽt dĂ©jĂ  initialisĂ© ; `NotFound` si `dir/.bgit` n'existe pas. + pub fn open(dir: &Path) -> io::Result { + let bgit = dir.join(".bgit"); + if !bgit.join("objects").is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("pas de dĂ©pĂŽt bion-git dans {}", dir.display()), + )); + } + Ok(Repo { bgit }) + } + + /// Chemin de l'objet `id` : `objects/ab/cdef
` (2 caractĂšres de + /// rĂ©pertoire, 38 de fichier — pour ne pas entasser des millions de + /// fichiers dans un seul dossier). + fn object_path(&self, id: &Id) -> PathBuf { + let hex = id.to_hex(); + self.bgit.join("objects").join(&hex[..2]).join(&hex[2..]) + } + + /// Hache `data` comme un objet de type `kind`, l'Ă©crit dans le magasin + /// et rend son [`Id`] — l'Ă©quivalent de `git hash-object -w`. + /// + /// Le hash porte sur `"{kind} {len}\0" + data` : c'est l'en-tĂȘte qui + /// fait qu'un blob vide et un tree vide ont des ids diffĂ©rents. + /// Écriture atomique (fichier temporaire puis `rename`) et idempotente : + /// si l'objet existe dĂ©jĂ , il est dĂ©jĂ  correct par construction. + pub fn hash_object(&self, data: &[u8], kind: Kind) -> io::Result { + let mut obj = Vec::with_capacity(data.len() + 16); + obj.extend_from_slice(kind.as_str().as_bytes()); + obj.push(b' '); + obj.extend_from_slice(data.len().to_string().as_bytes()); + obj.push(0); + obj.extend_from_slice(data); + + let id = Id(sha1::sha1(&obj)); + let path = self.object_path(&id); + if !path.exists() { + fs::create_dir_all(path.parent().unwrap())?; + // AtomicitĂ© : jamais d'objet Ă  moitiĂ© Ă©crit visible sous son nom final. + let tmp = path.with_extension(format!("tmp{}", std::process::id())); + fs::write(&tmp, &obj)?; + fs::rename(&tmp, &path)?; + } + Ok(id) + } + + /// Relit un objet : rend `(type, contenu)` — l'Ă©quivalent de + /// `git cat-file`. + /// + /// VĂ©rifie l'intĂ©gritĂ© : le SHA-1 des octets lus doit redonner `id` + /// (un magasin content-addressed est *auto-vĂ©rifiant* — c'est le fsck + /// gratuit). `InvalidData` si l'objet est corrompu ou malformĂ©. + pub fn cat_object(&self, id: &Id) -> io::Result<(Kind, Vec)> { + let obj = fs::read(self.object_path(id))?; + if Id(sha1::sha1(&obj)) != *id { + return Err(bad_data(format!( + "objet {id} corrompu : le hash ne correspond plus" + ))); + } + let nul = obj + .iter() + .position(|&b| b == 0) + .ok_or_else(|| bad_data(format!("objet {id} : en-tĂȘte sans NUL")))?; + let header = &obj[..nul]; + let space = header + .iter() + .position(|&b| b == b' ') + .ok_or_else(|| bad_data(format!("objet {id} : en-tĂȘte sans espace")))?; + let kind = Kind::from_bytes(&header[..space])?; + let len: usize = std::str::from_utf8(&header[space + 1..]) + .ok() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| bad_data(format!("objet {id} : taille illisible")))?; + let data = obj[nul + 1..].to_vec(); + if data.len() != len { + return Err(bad_data(format!( + "objet {id} : taille annoncĂ©e {len}, rĂ©elle {}", + data.len() + ))); + } + Ok((kind, data)) + } + + // ------------------------------------------------------------------ + // Trees : photographier et rematĂ©rialiser un rĂ©pertoire + // ------------------------------------------------------------------ + + /// Photographie rĂ©cursivement le rĂ©pertoire `dir_snapshot` : chaque + /// fichier devient un blob, chaque rĂ©pertoire un tree, et l'[`Id`] du + /// tree racine est rendu — l'Ă©quivalent de `git write-tree`, sans index. + /// + /// RĂšgles (celles de git) : + /// - `.bgit` est ignorĂ© (le magasin ne se photographie pas lui-mĂȘme) ; + /// - les rĂ©pertoires vides ne produisent pas d'entrĂ©e (git ne suit pas + /// les dossiers vides) — sauf la racine, qui peut ĂȘtre le tree vide ; + /// - les entrĂ©es sont triĂ©es par octets du nom, les rĂ©pertoires comptant + /// comme `nom + "/"` (la subtilitĂ© de tri qui garantit qu'un mĂȘme + /// contenu donne toujours le mĂȘme id, quel que soit l'ordre du FS) ; + /// - noms non-UTF-8 et types exotiques (symlinks
) → refus explicite. + pub fn write_tree(&self, dir_snapshot: &Path) -> io::Result { + self.write_tree_inner(dir_snapshot) + } + + fn write_tree_inner(&self, dir: &Path) -> io::Result { + // (clĂ© de tri, octets d'entrĂ©e) — la clĂ© traite un rĂ©pertoire comme "nom/". + let mut entries: Vec<(Vec, Vec)> = Vec::new(); + + for dirent in fs::read_dir(dir)? { + let dirent = dirent?; + let name = dirent + .file_name() + .into_string() + .map_err(|n| bad_data(format!("nom non-UTF-8 : {:?}", n)))?; + if name == ".bgit" { + continue; + } + let ftype = dirent.file_type()?; + + let (mode, id, is_dir) = if ftype.is_dir() { + let sub = self.write_tree_inner(&dirent.path())?; + // Sous-rĂ©pertoire vide → pas d'entrĂ©e (comme git). + let (_, data) = self.cat_object(&sub)?; + if data.is_empty() { + continue; + } + ("40000", sub, true) + } else if ftype.is_file() { + let blob = self.hash_object(&fs::read(dirent.path())?, Kind::Blob)?; + ("100644", blob, false) + } else { + return Err(bad_data(format!( + "type de fichier non gĂ©rĂ© (symlink ?) : {}", + dirent.path().display() + ))); + }; + + let mut key = name.as_bytes().to_vec(); + if is_dir { + key.push(b'/'); + } + let mut raw = Vec::with_capacity(name.len() + 28); + raw.extend_from_slice(mode.as_bytes()); + raw.push(b' '); + raw.extend_from_slice(name.as_bytes()); + raw.push(0); + raw.extend_from_slice(&id.0); + entries.push((key, raw)); + } + + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut payload = Vec::new(); + for (_, raw) in entries { + payload.extend_from_slice(&raw); + } + self.hash_object(&payload, Kind::Tree) + } + + /// DĂ©code un objet tree en ses entrĂ©es `(mode, nom, id)`. + pub fn read_tree(&self, id: &Id) -> io::Result> { + let (kind, data) = self.cat_object(id)?; + if kind != Kind::Tree { + return Err(bad_data(format!( + "{id} est un {}, pas un tree", + kind.as_str() + ))); + } + let mut entries = Vec::new(); + let mut rest = &data[..]; + while !rest.is_empty() { + let space = rest + .iter() + .position(|&b| b == b' ') + .ok_or_else(|| bad_data(format!("tree {id} : entrĂ©e sans espace")))?; + let mode = std::str::from_utf8(&rest[..space]) + .map_err(|_| bad_data(format!("tree {id} : mode non-UTF-8")))? + .to_string(); + rest = &rest[space + 1..]; + let nul = rest + .iter() + .position(|&b| b == 0) + .ok_or_else(|| bad_data(format!("tree {id} : entrĂ©e sans NUL")))?; + let name = std::str::from_utf8(&rest[..nul]) + .map_err(|_| bad_data(format!("tree {id} : nom non-UTF-8")))? + .to_string(); + rest = &rest[nul + 1..]; + if rest.len() < 20 { + return Err(bad_data(format!("tree {id} : id tronquĂ©"))); + } + let mut oid = [0u8; 20]; + oid.copy_from_slice(&rest[..20]); + rest = &rest[20..]; + entries.push(TreeEntry { + mode, + name, + id: Id(oid), + }); + } + Ok(entries) + } + + /// RematĂ©rialise le tree `id` dans le rĂ©pertoire `dest` (créé si besoin) — + /// l'inverse de [`Repo::write_tree`], l'Ă©quivalent d'un checkout. + /// + /// SĂ©curitĂ© : les noms d'entrĂ©e contenant `/`, `\`, `..`, `.` ou vides + /// sont refusĂ©s — un objet forgĂ© ne peut pas Ă©crire hors de `dest`. + pub fn checkout_tree(&self, id: &Id, dest: &Path) -> io::Result<()> { + fs::create_dir_all(dest)?; + for entry in self.read_tree(id)? { + if entry.name.is_empty() + || entry.name == "." + || entry.name == ".." + || entry.name.contains('/') + || entry.name.contains('\\') + { + return Err(bad_data(format!( + "nom d'entrĂ©e dangereux refusĂ© : {:?}", + entry.name + ))); + } + let path = dest.join(&entry.name); + match entry.mode.as_str() { + "40000" | "040000" => self.checkout_tree(&entry.id, &path)?, + _ => { + let (kind, data) = self.cat_object(&entry.id)?; + if kind != Kind::Blob { + return Err(bad_data(format!("{} n'est pas un blob", entry.id))); + } + fs::write(&path, data)?; + } + } + } + Ok(()) + } + + // ------------------------------------------------------------------ + // Commits : chaĂźner les instantanĂ©s + // ------------------------------------------------------------------ + + /// CrĂ©e un commit datĂ© de *maintenant*. Voir [`Repo::commit_at`]. + pub fn commit(&self, tree: Id, parents: &[Id], msg: &str, author: &str) -> io::Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| bad_data(format!("horloge avant l'epoch : {e}")))? + .as_secs(); + self.commit_at(tree, parents, msg, author, now) + } + + /// CrĂ©e un commit avec un horodatage explicite (secondes Unix) — + /// la variante **dĂ©terministe** : mĂȘmes entrĂ©es → mĂȘme [`Id`], toujours. + /// (RejouabilitĂ© : la mĂȘme vertu que les tsoins.) + /// + /// Format textuel, calquĂ© sur git : + /// + /// ```text + /// tree <40 hex> + /// parent <40 hex> (une ligne par parent, dans l'ordre donnĂ©) + /// author +0000 + /// committer +0000 + /// + /// + /// ``` + /// + /// `author` ne doit pas contenir de saut de ligne (`InvalidInput` sinon) ; + /// le message, lui, peut ĂȘtre multi-lignes — il est restituĂ© tel quel. + pub fn commit_at( + &self, + tree: Id, + parents: &[Id], + msg: &str, + author: &str, + timestamp: u64, + ) -> io::Result { + if author.contains('\n') { + return Err(bad_input( + "l'auteur ne peut pas contenir de saut de ligne".into(), + )); + } + let (kind, _) = self.cat_object(&tree)?; + if kind != Kind::Tree { + return Err(bad_input(format!( + "{tree} est un {}, pas un tree", + kind.as_str() + ))); + } + let mut text = String::new(); + text.push_str(&format!("tree {tree}\n")); + for p in parents { + text.push_str(&format!("parent {p}\n")); + } + text.push_str(&format!("author {author} {timestamp} +0000\n")); + text.push_str(&format!("committer {author} {timestamp} +0000\n")); + text.push('\n'); + text.push_str(msg); + self.hash_object(text.as_bytes(), Kind::Commit) + } + + /// Relit et dĂ©code un commit du magasin. + pub fn read_commit(&self, id: &Id) -> io::Result { + let (kind, data) = self.cat_object(id)?; + if kind != Kind::Commit { + return Err(bad_data(format!( + "{id} est un {}, pas un commit", + kind.as_str() + ))); + } + let text = + String::from_utf8(data).map_err(|_| bad_data(format!("commit {id} : non-UTF-8")))?; + let (header, message) = text + .split_once("\n\n") + .ok_or_else(|| bad_data(format!("commit {id} : pas de ligne vide avant le message")))?; + + let mut tree = None; + let mut parents = Vec::new(); + let mut author = None; + let mut timestamp = None; + for line in header.lines() { + if let Some(v) = line.strip_prefix("tree ") { + tree = Some(Id::from_hex(v)?); + } else if let Some(v) = line.strip_prefix("parent ") { + parents.push(Id::from_hex(v)?); + } else if let Some(v) = line.strip_prefix("author ") { + // "author +0000" → on dĂ©tache les 2 derniers mots. + let no_tz = v + .rsplit_once(' ') + .ok_or_else(|| bad_data(format!("commit {id} : ligne author malformĂ©e")))? + .0; + let (name, ts) = no_tz + .rsplit_once(' ') + .ok_or_else(|| bad_data(format!("commit {id} : ligne author malformĂ©e")))?; + author = Some(name.to_string()); + timestamp = Some( + ts.parse::() + .map_err(|_| bad_data(format!("commit {id} : timestamp illisible")))?, + ); + } + // "committer" : redondant avec author dans ce bion, ignorĂ© Ă  la lecture. + } + Ok(Commit { + id: *id, + tree: tree.ok_or_else(|| bad_data(format!("commit {id} : pas de tree")))?, + parents, + author: author.ok_or_else(|| bad_data(format!("commit {id} : pas d'author")))?, + timestamp: timestamp.unwrap_or(0), + message: message.to_string(), + }) + } + + /// Remonte l'histoire depuis `from` en suivant le **premier parent** de + /// chaque commit, et rend la liste du plus rĂ©cent au plus ancien — + /// l'Ă©quivalent de `git log --first-parent`. + /// + /// (Suivre le premier parent suffit Ă  raconter *une* ligne d'histoire ; + /// explorer tout le graphe d'un merge est laissĂ© Ă  l'appelant, qui a + /// `parents` sous la main.) + pub fn log(&self, from: Id) -> io::Result> { + let mut out = Vec::new(); + let mut cursor = Some(from); + let mut vus = std::collections::HashSet::new(); + while let Some(id) = cursor { + if !vus.insert(id) { + return Err(bad_data(format!( + "cycle dans l'histoire Ă  {id} (magasin corrompu)" + ))); + } + let c = self.read_commit(&id)?; + cursor = c.parents.first().copied(); + out.push(c); + } + Ok(out) + } + + // ------------------------------------------------------------------ + // Branches : des noms posĂ©s sur des commits + // ------------------------------------------------------------------ + + /// Pose (ou dĂ©place) la branche `name` sur le commit `at`. + /// + /// Une branche n'est *que ça* : 41 octets dans `refs/heads/`. + /// C'est pourquoi forker est gratuit. Le nom doit rester simple + /// (alphanumĂ©rique, `-`, `_`, `.`) — pas de `/` ni de traversĂ©e. + pub fn branch(&self, name: &str, at: Id) -> io::Result<()> { + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + || name.starts_with('.') + { + return Err(bad_input(format!("nom de branche invalide : {name:?}"))); + } + fs::write(self.bgit.join("refs/heads").join(name), format!("{at}\n")) + } + + /// Lit l'[`Id`] pointĂ© par la branche `name` ; `NotFound` si elle n'existe pas. + pub fn branch_target(&self, name: &str) -> io::Result { + let s = fs::read_to_string(self.bgit.join("refs/heads").join(name))?; + Id::from_hex(&s) + } + + /// Liste les branches existantes, triĂ©es par nom. + pub fn branches(&self) -> io::Result> { + let mut out: Vec = fs::read_dir(self.bgit.join("refs/heads"))? + .filter_map(|e| e.ok()?.file_name().into_string().ok()) + .collect(); + out.sort(); + Ok(out) + } +} + +// ---------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static COMPTEUR: AtomicUsize = AtomicUsize::new(0); + + /// Un rĂ©pertoire temporaire unique par test (std-only, pas de crate tempfile). + fn dir_temporaire() -> PathBuf { + let n = COMPTEUR.fetch_add(1, Ordering::SeqCst); + let d = std::env::temp_dir().join(format!("bion-git-test-{}-{}", std::process::id(), n)); + fs::create_dir_all(&d).unwrap(); + d + } + + struct Nettoyeur(PathBuf); + impl Drop for Nettoyeur { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn repo_test() -> (Repo, PathBuf, Nettoyeur) { + let d = dir_temporaire(); + let repo = Repo::init(&d).unwrap(); + let gardien = Nettoyeur(d.clone()); + (repo, d, gardien) + } + + /// Nos identifiants de blobs et de trees sont EXACTEMENT ceux de git — + /// vecteurs bien connus (`git hash-object`, tree vide). + #[test] + fn ids_identiques_a_git() { + let (repo, _d, _g) = repo_test(); + // blob vide + assert_eq!( + repo.hash_object(b"", Kind::Blob).unwrap().to_hex(), + "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + ); + // `echo "hello world" | git hash-object --stdin` + assert_eq!( + repo.hash_object(b"hello world\n", Kind::Blob) + .unwrap() + .to_hex(), + "3b18e512dba79e4c8300dd08aeb37f8e728b8dad" + ); + // le tree vide, le plus cĂ©lĂšbre des ids de git + assert_eq!( + repo.hash_object(b"", Kind::Tree).unwrap().to_hex(), + "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + ); + } + + /// Le layout disque est bien objects/ab/cdef
 (2 / 38). + #[test] + fn layout_objets_2_38() { + let (repo, d, _g) = repo_test(); + let id = repo.hash_object(b"xerboxion", Kind::Blob).unwrap(); + let hex = id.to_hex(); + let chemin = d.join(".bgit/objects").join(&hex[..2]).join(&hex[2..]); + assert!(chemin.is_file(), "objet absent de {}", chemin.display()); + assert_eq!(hex[..2].len(), 2); + assert_eq!(hex[2..].len(), 38); + } + + /// Aller-retour blob : ce qu'on Ă©crit est ce qu'on relit, type compris. + /// Et l'Ă©criture est idempotente (dĂ©duplication par construction). + #[test] + fn roundtrip_blob() { + let (repo, _d, _g) = repo_test(); + let data = b"contenu \x00 binaire \xff aussi".to_vec(); + let id1 = repo.hash_object(&data, Kind::Blob).unwrap(); + let id2 = repo.hash_object(&data, Kind::Blob).unwrap(); + assert_eq!(id1, id2, "mĂȘme contenu → mĂȘme id (dĂ©dup)"); + let (kind, relu) = repo.cat_object(&id1).unwrap(); + assert_eq!(kind, Kind::Blob); + assert_eq!(relu, data); + } + + /// Le magasin est auto-vĂ©rifiant : un objet corrompu sur disque est + /// dĂ©tectĂ© Ă  la lecture (le hash ne correspond plus). + #[test] + fn corruption_detectee() { + let (repo, d, _g) = repo_test(); + let id = repo.hash_object(b"important", Kind::Blob).unwrap(); + let hex = id.to_hex(); + let chemin = d.join(".bgit/objects").join(&hex[..2]).join(&hex[2..]); + fs::write(&chemin, b"blob 7\0sabotee").unwrap(); + let err = repo.cat_object(&id).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + /// Photographier un rĂ©pertoire imbriquĂ© puis le rematĂ©rialiser ailleurs : + /// contenu identique, et le mĂȘme contenu redonne le mĂȘme id de tree + /// (dĂ©terminisme, quel que soit l'ordre de crĂ©ation des fichiers). + #[test] + fn roundtrip_tree_imbrique() { + let (repo, d, _g) = repo_test(); + let src = d.join("monde"); + fs::create_dir_all(src.join("src/coeur")).unwrap(); + fs::write(src.join("lisez-moi.txt"), "un bion = un cours\n").unwrap(); + fs::write(src.join("src/main.rs"), "fn main() {}\n").unwrap(); + fs::write(src.join("src/coeur/bion.rs"), "// cƓur\n").unwrap(); + fs::create_dir_all(src.join("vide")).unwrap(); // ignorĂ©, comme git + + let tree = repo.write_tree(&src).unwrap(); + // Vecteur vĂ©rifiĂ© contre le VRAI git (`git write-tree` sur le mĂȘme + // contenu) : nos trees sont bit-Ă -bit compatibles, tri compris. + assert_eq!(tree.to_hex(), "15c93b9663e95c58becfa3172ed753e14dbef718"); + + // MĂȘme contenu recréé dans un AUTRE ordre → mĂȘme id. + let src2 = d.join("monde-bis"); + fs::create_dir_all(src2.join("src/coeur")).unwrap(); + fs::write(src2.join("src/coeur/bion.rs"), "// cƓur\n").unwrap(); + fs::write(src2.join("src/main.rs"), "fn main() {}\n").unwrap(); + fs::write(src2.join("lisez-moi.txt"), "un bion = un cours\n").unwrap(); + assert_eq!( + repo.write_tree(&src2).unwrap(), + tree, + "content-addressing : mĂȘme monde, mĂȘme nom" + ); + + let dest = d.join("restaure"); + repo.checkout_tree(&tree, &dest).unwrap(); + assert_eq!( + fs::read_to_string(dest.join("lisez-moi.txt")).unwrap(), + "un bion = un cours\n" + ); + assert_eq!( + fs::read_to_string(dest.join("src/main.rs")).unwrap(), + "fn main() {}\n" + ); + assert_eq!( + fs::read_to_string(dest.join("src/coeur/bion.rs")).unwrap(), + "// cƓur\n" + ); + assert!( + !dest.join("vide").exists(), + "les rĂ©pertoires vides ne sont pas suivis" + ); + assert!( + !dest.join(".bgit").exists(), + "le magasin ne se photographie pas lui-mĂȘme" + ); + } + + /// Aller-retour commit : tous les champs se relisent exactement, + /// message multi-lignes compris. Et commit_at est dĂ©terministe. + #[test] + fn roundtrip_commit() { + let (repo, d, _g) = repo_test(); + fs::write(d.join("a.txt"), "a").unwrap(); + let tree = repo.write_tree(&d).unwrap(); + let msg = "premier instant\n\navec un corps\nmulti-lignes"; + let id = repo + .commit_at(tree, &[], msg, "rs-1 ", 1_755_300_000) + .unwrap(); + let id_bis = repo + .commit_at(tree, &[], msg, "rs-1 ", 1_755_300_000) + .unwrap(); + assert_eq!(id, id_bis, "commit_at est dĂ©terministe"); + + let c = repo.read_commit(&id).unwrap(); + assert_eq!(c.id, id); + assert_eq!(c.tree, tree); + assert!(c.parents.is_empty()); + assert_eq!(c.author, "rs-1 "); + assert_eq!(c.timestamp, 1_755_300_000); + assert_eq!(c.message, msg); + } + + /// log d'une chaĂźne de trois commits : du plus rĂ©cent au plus ancien. + #[test] + fn log_chaine() { + let (repo, d, _g) = repo_test(); + fs::write(d.join("a.txt"), "v1").unwrap(); + let t1 = repo.write_tree(&d).unwrap(); + let c1 = repo.commit_at(t1, &[], "un", "rs-1", 100).unwrap(); + fs::write(d.join("a.txt"), "v2").unwrap(); + let t2 = repo.write_tree(&d).unwrap(); + let c2 = repo.commit_at(t2, &[c1], "deux", "rs-1", 200).unwrap(); + fs::write(d.join("a.txt"), "v3").unwrap(); + let t3 = repo.write_tree(&d).unwrap(); + let c3 = repo.commit_at(t3, &[c2], "trois", "rs-1", 300).unwrap(); + + let histoire = repo.log(c3).unwrap(); + assert_eq!(histoire.len(), 3); + assert_eq!( + histoire + .iter() + .map(|c| c.message.as_str()) + .collect::>(), + ["trois", "deux", "un"] + ); + assert_eq!(histoire[2].parents, Vec::::new()); + } + + /// LE test du principe : deux branches qui divergent depuis un parent + /// commun — le fork VISIBLE. Les deux histoires partagent leur racine + /// sans copier un octet, et chaque branche se relit par son nom. + #[test] + fn fork_deux_branches_divergentes() { + let (repo, d, _g) = repo_test(); + fs::write(d.join("monde.txt"), "commun").unwrap(); + let racine_tree = repo.write_tree(&d).unwrap(); + let racine = repo + .commit_at(racine_tree, &[], "racine", "rs-1", 100) + .unwrap(); + + // Branche main : le chemin d'origine continue. + fs::write(d.join("monde.txt"), "chemin main").unwrap(); + let tm = repo.write_tree(&d).unwrap(); + let cm = repo + .commit_at(tm, &[racine], "suite sur main", "rs-1", 200) + .unwrap(); + repo.branch("main", cm).unwrap(); + + // Branche fork : la bifurcation, depuis LE MÊME parent. + fs::write(d.join("monde.txt"), "chemin fork").unwrap(); + let tf = repo.write_tree(&d).unwrap(); + let cf = repo + .commit_at(tf, &[racine], "bifurcation", "rs-7", 200) + .unwrap(); + repo.branch("fork", cf).unwrap(); + + assert_ne!( + cm, cf, + "deux contenus, deux ids : la divergence est visible" + ); + assert_eq!(repo.branch_target("main").unwrap(), cm); + assert_eq!(repo.branch_target("fork").unwrap(), cf); + assert_eq!(repo.branches().unwrap(), ["fork", "main"]); + + // Les deux histoires convergent (en remontant) vers la mĂȘme racine. + let log_main = repo.log(repo.branch_target("main").unwrap()).unwrap(); + let log_fork = repo.log(repo.branch_target("fork").unwrap()).unwrap(); + assert_eq!(log_main.last().unwrap().id, racine); + assert_eq!(log_fork.last().unwrap().id, racine); + // 
et le passĂ© commun n'est stockĂ© qu'une fois : mĂȘme objet racine. + assert_eq!(log_main.last().unwrap(), log_fork.last().unwrap()); + } + + /// Un merge est reprĂ©sentable : un commit Ă  deux parents, que log + /// remonte par son premier parent. + #[test] + fn merge_deux_parents() { + let (repo, d, _g) = repo_test(); + fs::write(d.join("x"), "0").unwrap(); + let t0 = repo.write_tree(&d).unwrap(); + let racine = repo.commit_at(t0, &[], "racine", "rs-1", 1).unwrap(); + let a = repo.commit_at(t0, &[racine], "a", "rs-1", 2).unwrap(); + let b = repo.commit_at(t0, &[racine], "b", "rs-7", 2).unwrap(); + let m = repo + .commit_at(t0, &[a, b], "retrouvailles", "rs-1", 3) + .unwrap(); + let c = repo.read_commit(&m).unwrap(); + assert_eq!(c.parents, vec![a, b]); + let histoire = repo.log(m).unwrap(); + assert_eq!( + histoire + .iter() + .map(|c| c.message.as_str()) + .collect::>(), + ["retrouvailles", "a", "racine"] + ); + } + + /// Cas limites d'Id : parsing hex strict, aller-retour Display/FromStr. + #[test] + fn id_hex_cas_limites() { + assert!(Id::from_hex("abc").is_err(), "trop court"); + assert!(Id::from_hex(&"z".repeat(40)).is_err(), "non-hex"); + let id = Id::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(); + assert_eq!(id.to_string().parse::().unwrap(), id); + // Les majuscules sont acceptĂ©es en entrĂ©e, normalisĂ©es en sortie. + let maj = Id::from_hex("4B825DC642CB6EB9A060E54BF8D69288FBEE4904").unwrap(); + assert_eq!(maj, id); + } + + /// Erreurs propres : objet inconnu, mauvais type au commit, + /// branche au nom dangereux, dĂ©pĂŽt inexistant. + #[test] + fn erreurs_propres() { + let (repo, d, _g) = repo_test(); + let fantome = Id([0u8; 20]); + assert_eq!( + repo.cat_object(&fantome).unwrap_err().kind(), + io::ErrorKind::NotFound + ); + assert!(repo.checkout_tree(&fantome, &d.join("nulle-part")).is_err()); + + // Un commit doit pointer un tree, pas un blob. + let blob = repo.hash_object(b"x", Kind::Blob).unwrap(); + assert!(repo.commit(blob, &[], "m", "rs-1").is_err()); + + // Pas de traversĂ©e par le nom de branche. + let tree = repo.hash_object(b"", Kind::Tree).unwrap(); + let c = repo.commit_at(tree, &[], "m", "rs-1", 1).unwrap(); + assert!(repo.branch("../evasion", c).is_err()); + assert!(repo.branch("a/b", c).is_err()); + assert!(repo.branch("", c).is_err()); + assert!(repo.branch_target("inconnue").is_err()); + + // open() exige un dĂ©pĂŽt initialisĂ©. + assert!(Repo::open(&d.join("pas-un-depot")).is_err()); + } + + /// L'auteur ne peut pas casser le format textuel du commit + /// (injection de ligne « parent » via un \n). + #[test] + fn auteur_sans_injection() { + let (repo, _d, _g) = repo_test(); + let tree = repo.hash_object(b"", Kind::Tree).unwrap(); + let e = repo + .commit_at(tree, &[], "m", "rs-1\nparent 0000", 1) + .unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidInput); + } +} diff --git a/bion-git/src/sha1.rs b/bion-git/src/sha1.rs new file mode 100644 index 0000000..bc5ca97 --- /dev/null +++ b/bion-git/src/sha1.rs @@ -0,0 +1,161 @@ +//! # SHA-1 maison — le condensat qui donne son adresse Ă  chaque objet +//! +//! Git nomme chaque objet par le SHA-1 de son contenu (prĂ©fixĂ© d'un petit +//! en-tĂȘte) : c'est le **content-addressing**. Deux contenus identiques ont +//! le mĂȘme nom, partout, pour toujours — c'est ce qui rend le fork gratuit +//! et la dĂ©duplication automatique. +//! +//! SHA-1 (FIPS 180-1, 1995) est aujourd'hui cassĂ© pour la *rĂ©sistance aux +//! collisions* (attaque SHAttered, 2017) — git lui-mĂȘme migre vers SHA-256. +//! Ici il reste parfait pour son rĂŽle pĂ©dagogique **et** pour la +//! compatibilitĂ© : les identifiants produits par ce bion sont exactement +//! ceux de `git hash-object`. +//! +//! ## L'algorithme en quatre temps +//! +//! 1. **Padding** : on ajoute un bit `1` (l'octet `0x80`), puis des zĂ©ros +//! jusqu'Ă  ce que la longueur soit ≡ 56 (mod 64), puis la longueur du +//! message *en bits* sur 8 octets big-endian. Le message fait alors un +//! nombre entier de blocs de 512 bits. +//! 2. **État initial** : cinq mots de 32 bits, constants (h0..h4). +//! 3. **Compression** : chaque bloc de 64 octets est Ă©tendu en 80 mots, +//! puis 80 rondes mĂ©langent l'Ă©tat avec des fonctions non linĂ©aires +//! (`Ch`, `Parity`, `Maj`) et quatre constantes. +//! 4. **Sortie** : les cinq mots d'Ă©tat, concatĂ©nĂ©s big-endian → 20 octets. +//! +//! Tout est en arithmĂ©tique modulaire 32 bits (`wrapping_add`) et rotations +//! (`rotate_left`) — aucun `unsafe`, aucune table prĂ©calculĂ©e. + +/// Calcule le condensat SHA-1 (20 octets) de `data`, en un seul passage. +/// +/// ImplĂ©mentation directe de FIPS 180-1. VĂ©rifiĂ©e contre les vecteurs de +/// test officiels du NIST (voir les tests du module). +/// +/// ``` +/// let d = bion_git::sha1::sha1(b"abc"); +/// assert_eq!(bion_git::sha1::to_hex(&d), "a9993e364706816aba3e25717850c26c9cd0d89d"); +/// ``` +pub fn sha1(data: &[u8]) -> [u8; 20] { + // 2. État initial (h0..h4), figĂ© par la spec. + let mut h: [u32; 5] = [ + 0x6745_2301, + 0xEFCD_AB89, + 0x98BA_DCFE, + 0x1032_5476, + 0xC3D2_E1F0, + ]; + + // 1. Padding : bit 1, zĂ©ros, longueur en bits sur 64 bits big-endian. + let bit_len = (data.len() as u64).wrapping_mul(8); + let mut msg = Vec::with_capacity(data.len() + 72); + msg.extend_from_slice(data); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_be_bytes()); + + // 3. Compression, bloc par bloc de 512 bits. + for chunk in msg.chunks_exact(64) { + // Expansion : 16 mots lus big-endian, Ă©tendus en 80. + let mut w = [0u32; 80]; + for (i, word) in chunk.chunks_exact(4).enumerate() { + w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); + } + for i in 16..80 { + w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); + } + + // 80 rondes : quatre phases de 20, chacune avec sa fonction et sa constante. + let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]); + for (i, &wi) in w.iter().enumerate() { + let (f, k) = match i { + 0..=19 => ((b & c) | (!b & d), 0x5A82_7999), // Ch + 20..=39 => (b ^ c ^ d, 0x6ED9_EBA1), // Parity + 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC), // Maj + _ => (b ^ c ^ d, 0xCA62_C1D6), // Parity + }; + let tmp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(wi); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = tmp; + } + h[0] = h[0].wrapping_add(a); + h[1] = h[1].wrapping_add(b); + h[2] = h[2].wrapping_add(c); + h[3] = h[3].wrapping_add(d); + h[4] = h[4].wrapping_add(e); + } + + // 4. Sortie : l'Ă©tat, big-endian. + let mut out = [0u8; 20]; + for (i, word) in h.iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + out +} + +/// Encode 20 octets en 40 caractĂšres hexadĂ©cimaux minuscules — la forme +/// sous laquelle git (et ce bion) affiche tous les identifiants. +pub fn to_hex(digest: &[u8; 20]) -> String { + let mut s = String::with_capacity(40); + for b in digest { + s.push(char::from_digit((b >> 4) as u32, 16).unwrap()); + s.push(char::from_digit((b & 0xf) as u32, 16).unwrap()); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Vecteurs officiels FIPS 180-1 / NIST. + #[test] + fn vecteurs_officiels() { + assert_eq!( + to_hex(&sha1(b"")), + "da39a3ee5e6b4b0d3255bfef95601890afd80709" + ); + assert_eq!( + to_hex(&sha1(b"abc")), + "a9993e364706816aba3e25717850c26c9cd0d89d" + ); + assert_eq!( + to_hex(&sha1( + b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq" + )), + "84983e441c3bd26ebaae4aa1f95129e5e54670f1" + ); + } + + /// Le vecteur « un million de a » du NIST — teste le padding sur + /// beaucoup de blocs. + #[test] + fn vecteur_million_de_a() { + let m = vec![b'a'; 1_000_000]; + assert_eq!( + to_hex(&sha1(&m)), + "34aa973cd4c4daa4f61eeb2bdbad27316534016f" + ); + } + + /// Les longueurs autour de la frontiĂšre de padding (55/56/57 octets et + /// 63/64/65) sont les cas limites classiques : on vĂ©rifie qu'aucune ne + /// panique et que chaque longueur donne un condensat distinct. + #[test] + fn frontieres_de_padding() { + let mut vus = std::collections::HashSet::new(); + for n in [0usize, 1, 55, 56, 57, 63, 64, 65, 127, 128] { + let d = sha1(&vec![0x42; n]); + assert!(vus.insert(d), "collision inattendue Ă  n={n}"); + } + } +} diff --git a/bion-kv/Cargo.toml b/bion-kv/Cargo.toml new file mode 100644 index 0000000..411d62f --- /dev/null +++ b/bion-kv/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "bion-kv" +version = "0.1.0" +edition = "2021" +description = "Magasin clĂ©-valeur append-only style Bitcask : log + index mĂ©moire + compaction. std-only, build-your-own-database." +license = "MIT" + +[dependencies] diff --git a/bion-kv/README.md b/bion-kv/README.md new file mode 100644 index 0000000..ff05fb6 --- /dev/null +++ b/bion-kv/README.md @@ -0,0 +1,93 @@ +# bion-kv đŸ—ƒïž + +Magasin clĂ©-valeur durable, **std-only, zĂ©ro dĂ©pendance**, style +[Bitcask](https://riak.com/assets/bitcask-intro.pdf) : un log append-only sur +disque + un index en mĂ©moire + une compaction atomique. La brique +*build-your-own-database* du xerboxion — construite une fois, plus jamais +rebĂątie, seulement optimisĂ©e (principe RS-7). + +## Quoi + +- `Kv::open(dir)` — ouvre/crĂ©e le magasin, reconstruit l'index en scannant le log +- `put(k, v)` — un append sĂ©quentiel, O(1) +- `get(k) -> Option>` — un lookup mĂ©moire + au plus un seek/read +- `delete(k)` — appose un **tombstone** (on n'efface jamais le passĂ©) +- `compact()` — réécrit uniquement les donnĂ©es vivantes, bascule **atomique par `rename`** +- `sync()` — fsync explicite quand on veut la garantie coupure-de-courant +- CRC32 (IEEE) **implĂ©mentĂ© maison** (`bion_kv::crc32`, table `const` calculĂ©e Ă  la compilation) + +## Pourquoi Bitcask + +1. **Rapide en Ă©criture** : tout est un append sĂ©quentiel — le motif d'E/S le + plus rapide sur disque comme sur SSD. Pas d'arbre Ă  rééquilibrer, pas de + page Ă  réécrire. +2. **Robuste** : le passĂ© est immuable ; un crash ne peut abĂźmer que la + *queue* du log. À la rĂ©ouverture, chaque record est vĂ©rifiĂ© par CRC32 et + la queue malade est tronquĂ©e — les donnĂ©es saines survivent toujours. +3. **Simple** : le format tient en une ligne + (`crc32 | klen | vlen | clĂ© | valeur`, tombstone = `vlen == 0xFFFFFFFF`), + la rĂ©cupĂ©ration = relire le log. Tout le moteur tient dans un fichier + source lisible en une soirĂ©e : c'est aussi un cours. + +Le compromis assumĂ© : la RAM porte l'index (proportionnel au **nombre de +clĂ©s**, pas au volume des valeurs), et le log grossit avec les versions +mortes — d'oĂč `compact()`. + +## Lien avec le boxion store + +Le boxion store (`jOSBoxion`) expose dĂ©jĂ  un KV par-utilisateur aux ploxions. +`bion-kv` en est le moteur cĂŽtĂ© **core Rust** : mĂȘme contrat +(put/get/delete durable et rĂ©ouvrable), mais embarquable partout — dans +`xerboxion-rt`, dans l'OS bare-metal, dans un bion WASM. On ne rĂ©invente pas +le KV Ă  chaque Ă©tage : on branche ce bloc. + +## Exemple + +```rust +let mut kv = bion_kv::Kv::open("/tmp/mon-magasin")?; +kv.put(b"xer", b"renderer")?; +assert_eq!(kv.get(b"xer")?.as_deref(), Some(&b"renderer"[..])); +kv.delete(b"xer")?; // tombstone dans le log +assert_eq!(kv.get(b"xer")?, None); +kv.compact()?; // le log ne garde que le vivant +# Ok::<(), std::io::Error>(()) +``` + +## Build-your-own-x + +MĂȘme famille que ces guides du dĂ©pĂŽt +[codecrafters-io/build-your-own-x](https://github.com/codecrafters-io/build-your-own-x) +(section *Build your own Database*) : + +- *Build Your Own Fast, Persistent KV Store in Rust* — exactement ce crate +- l'article fondateur : **Bitcask — A Log-Structured Hash Table for Fast + Key/Value Data** (Riak) +- cousins de design : les WAL de SQLite/Postgres, les SSTables de + LevelDB/RocksDB (Bitcask = le cas dĂ©gĂ©nĂ©rĂ© Ă  un seul niveau) + +## Comment l'optimiser (l'invitation au fork) + +L'API est **stable pour toujours** ; tout ce qui suit se fait dessous, sans +casser un seul appelant : + +- **Fichiers multiples + hint files** (le vrai Bitcask) : fermer le segment + actif Ă  N Mo, compacter segment par segment, et Ă©crire un *hint file* + (clĂ© → offset) pour rouvrir sans relire les valeurs. +- **Compaction incrĂ©mentale** : aujourd'hui `compact()` copie tout le vivant + d'un coup ; en segments, on ne compacte que les segments les plus « morts ». +- **Batching/`put_many`** : grouper plusieurs records dans un seul `write_all` + + un seul fsync. +- **CRC matĂ©riel** : remplacer la table par l'instruction `crc32c` (SSE4.2) + ou du SIMD — le format ne change pas si on garde le polynĂŽme IEEE. +- **Index plus dense** : `HashMap, _>` → arĂšne de clĂ©s + table Ă  + adressage ouvert, ou un ART pour les scans par prĂ©fixe. +- **`get` sans `&mut`** : `pread`/`read_at` (FileExt) pour des lectures + concurrentes sans dĂ©placer le curseur. + +## Tests + +`cargo test -p bion-kv` — 17 tests unitaires + 2 doc-tests : put/get/delete, +Ă©crasements, clĂ©s/valeurs binaires et vides, persistance aprĂšs rĂ©ouverture, +tombstones rejouĂ©s, compaction (taille rĂ©duite, donnĂ©es intactes, rĂ©ouverture), +queue corrompue (bruit, record tronquĂ©, bit-flip dĂ©tectĂ© par CRC), fichier +Ă©tranger refusĂ©, brouillon de compaction orphelin ignorĂ©, 500 clĂ©s. diff --git a/bion-kv/src/crc32.rs b/bion-kv/src/crc32.rs new file mode 100644 index 0000000..cd00983 --- /dev/null +++ b/bion-kv/src/crc32.rs @@ -0,0 +1,137 @@ +//! # CRC32 (IEEE 802.3) — implĂ©mentĂ© depuis les principes +//! +//! Un CRC (*Cyclic Redundancy Check*) est le reste d'une division polynomiale +//! dans **GF(2)** — le corps Ă  deux Ă©lĂ©ments oĂč l'addition est un XOR. +//! On voit le message comme un gros polynĂŽme Ă  coefficients binaires, on le +//! divise par un polynĂŽme gĂ©nĂ©rateur fixĂ©, et le reste (32 bits ici) sert +//! d'empreinte : la moindre rafale d'erreurs ≀ 32 bits change le reste. +//! +//! ## Le polynĂŽme +//! +//! CRC-32/IEEE utilise `xÂłÂČ + xÂČ⁶ + xÂČÂł + xÂČÂČ + xÂč⁶ + xÂčÂČ + xÂčÂč + xÂč⁰ + x⁞ + +//! x⁷ + x⁔ + x⁎ + xÂČ + x + 1`. En reprĂ©sentation *reflected* (bit de poids +//! faible = plus haut degrĂ©), cela donne la constante `0xEDB8_8320`. +//! +//! ## L'algorithme table-driven +//! +//! Diviser bit Ă  bit coĂ»te 8 itĂ©rations par octet. L'astuce classique : +//! prĂ©-calculer, pour chacun des 256 octets possibles, l'effet de ces +//! 8 itĂ©rations. La table est construite **Ă  la compilation** (`const fn`), +//! donc zĂ©ro coĂ»t Ă  l'exĂ©cution et zĂ©ro dĂ©pendance. +//! +//! ## Conventions (celles de zlib, PNG, gzip, Ethernet
) +//! +//! * registre initialisĂ© Ă  `0xFFFF_FFFF` ; +//! * bits rĂ©flĂ©chis (on traite le LSB d'abord) ; +//! * complĂ©ment final (`XOR 0xFFFF_FFFF`). +//! +//! Vecteur de test canonique : `crc32(b"123456789") == 0xCBF4_3926`. + +/// PolynĂŽme CRC-32/IEEE, forme *reflected*. +const POLY: u32 = 0xEDB8_8320; + +/// Table des 256 restes partiels, construite Ă  la compilation. +const TABLE: [u32; 256] = build_table(); + +const fn build_table() -> [u32; 256] { + let mut table = [0u32; 256]; + let mut i = 0; + while i < 256 { + let mut crc = i as u32; + let mut bit = 0; + while bit < 8 { + // Si le bit sortant vaut 1, on « soustrait » (XOR) le polynĂŽme. + crc = if crc & 1 != 0 { + (crc >> 1) ^ POLY + } else { + crc >> 1 + }; + bit += 1; + } + table[i] = crc; + i += 1; + } + table +} + +/// Calcule le CRC32 (IEEE, conventions zlib) de `data` en une passe. +/// +/// ``` +/// assert_eq!(bion_kv::crc32::crc32(b"123456789"), 0xCBF4_3926); +/// ``` +pub fn crc32(data: &[u8]) -> u32 { + let mut h = Hasher::new(); + h.update(data); + h.finish() +} + +/// Hachage incrĂ©mental : permet de sommer plusieurs tranches sans les +/// concatĂ©ner (utile pour `en-tĂȘte + clĂ© + valeur` dans le log). +#[derive(Debug, Clone)] +pub struct Hasher { + state: u32, +} + +impl Hasher { + /// Nouveau calcul, registre initialisĂ© Ă  `0xFFFF_FFFF`. + pub fn new() -> Self { + Self { state: 0xFFFF_FFFF } + } + + /// Absorbe une tranche d'octets. + pub fn update(&mut self, data: &[u8]) { + for &b in data { + let idx = ((self.state ^ b as u32) & 0xFF) as usize; + self.state = (self.state >> 8) ^ TABLE[idx]; + } + } + + /// Termine et renvoie le CRC (complĂ©ment final). + pub fn finish(&self) -> u32 { + self.state ^ 0xFFFF_FFFF + } +} + +impl Default for Hasher { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vecteur_canonique() { + // LE vecteur de test du CRC-32/IEEE, prĂ©sent dans toutes les RFC. + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + } + + #[test] + fn vide_et_connus() { + assert_eq!(crc32(b""), 0x0000_0000); + assert_eq!(crc32(b"a"), 0xE8B7_BE43); + assert_eq!(crc32(b"abc"), 0x3524_41C2); + assert_eq!( + crc32(b"The quick brown fox jumps over the lazy dog"), + 0x414F_A339 + ); + } + + #[test] + fn incremental_equivaut_une_passe() { + let mut h = Hasher::new(); + h.update(b"123"); + h.update(b"456"); + h.update(b"789"); + assert_eq!(h.finish(), crc32(b"123456789")); + } + + #[test] + fn sensible_au_moindre_bit() { + let a = crc32(b"bion-kv"); + let b = crc32(b"bion-kw"); // un seul bit d'Ă©cart sur le dernier octet + assert_ne!(a, b); + } +} diff --git a/bion-kv/src/lib.rs b/bion-kv/src/lib.rs new file mode 100644 index 0000000..394c6e5 --- /dev/null +++ b/bion-kv/src/lib.rs @@ -0,0 +1,712 @@ +//! # bion-kv — le magasin clĂ©-valeur *build-your-own-database*, style Bitcask +//! +//! ## Pourquoi Bitcask ? +//! +//! Bitcask (le moteur historique de Riak) repose sur une idĂ©e d'une simplicitĂ© +//! radicale : **on n'Ă©crit jamais au milieu d'un fichier**. Toute mutation — +//! `put` comme `delete` — est *ajoutĂ©e Ă  la fin* d'un log. Un index en mĂ©moire +//! (`HashMap` clĂ© → position dans le fichier) dit oĂč vit la derniĂšre version +//! de chaque clĂ©. ConsĂ©quences : +//! +//! * **rapide en Ă©criture** : une Ă©criture = un `append` sĂ©quentiel, le motif +//! d'E/S le plus rapide qui existe (disque comme SSD) ; +//! * **robuste** : le passĂ© n'est jamais modifiĂ©, donc un crash ne peut +//! corrompre au pire que la *queue* du fichier — et la rĂ©ouverture la +//! dĂ©tecte (CRC32) et la tronque proprement ; +//! * **simple** : `get` = un seek + un read ; la rĂ©cupĂ©ration = relire le log +//! du dĂ©but Ă  la fin pour reconstruire l'index. +//! +//! Le prix Ă  payer : le log grossit avec les versions mortes (valeurs +//! Ă©crasĂ©es, clĂ©s supprimĂ©es). D'oĂč [`Kv::compact`], qui réécrit uniquement +//! les donnĂ©es *vivantes* dans un nouveau fichier puis bascule **atomiquement** +//! par `rename` — Ă  tout instant, il existe sur disque un log complet et +//! valide. +//! +//! ## Format du log (`bion-kv.log`) +//! +//! ```text +//! [ en-tĂȘte "BIONKV1\n" (8 octets) ] +//! [ record ]* +//! +//! record : +//! crc32 : u32 LE — CRC32 (IEEE) de tout ce qui suit (klen‖vlen‖clé‖valeur) +//! klen : u32 LE — longueur de la clĂ© +//! vlen : u32 LE — longueur de la valeur, ou 0xFFFF_FFFF = TOMBSTONE +//! clĂ© : [u8; klen] +//! valeur : [u8; vlen] (absente pour un tombstone) +//! ``` +//! +//! Un **tombstone** (pierre tombale) est le record qui matĂ©rialise une +//! suppression : on ne peut pas « retirer » une clĂ© d'un log append-only, +//! alors on ajoute un record qui dit « cette clĂ© est morte ». À la +//! reconstruction de l'index, le tombstone retire la clĂ© ; Ă  la compaction, +//! clĂ© et tombstone disparaissent physiquement. +//! +//! ## Lien avec le boxion store +//! +//! Le boxion store (`jOSBoxion`) offre dĂ©jĂ  un KV par-utilisateur cĂŽtĂ© +//! ploxions ; `bion-kv` en est la **brique moteur cĂŽtĂ© core Rust** : le mĂȘme +//! contrat (`put`/`get`/`delete`, durable, rĂ©ouvrable) mais au niveau du +//! mĂ©tal, embarquable dans `xerboxion-rt`, un OS bare-metal ou un binaire +//! WASM. On le construit UNE fois ; ensuite on ne fait plus que l'optimiser +//! (principe RS-7 : des blocs, pas des roues rĂ©inventĂ©es). +//! +//! ## Exemple +//! +//! ``` +//! let dir = std::env::temp_dir().join(format!("bion-kv-doc-{}", std::process::id())); +//! let mut kv = bion_kv::Kv::open(&dir).unwrap(); +//! kv.put(b"xer", b"renderer").unwrap(); +//! assert_eq!(kv.get(b"xer").unwrap().as_deref(), Some(&b"renderer"[..])); +//! kv.delete(b"xer").unwrap(); +//! assert_eq!(kv.get(b"xer").unwrap(), None); +//! # drop(kv); std::fs::remove_dir_all(&dir).ok(); +//! ``` + +pub mod crc32; + +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +/// Nom du fichier de log dans le dossier du magasin. +const LOG_NAME: &str = "bion-kv.log"; +/// Fichier temporaire de compaction (basculĂ© par `rename`). +const COMPACT_NAME: &str = "bion-kv.log.compact"; +/// En-tĂȘte magique : identifie le format et sa version. Versionner le format +/// dans le fichier lui-mĂȘme, c'est ce qui permet la rĂ©trocompatibilitĂ© +/// Ă©ternelle : un futur `BIONKV2` saura toujours relire un `BIONKV1`. +const MAGIC: &[u8; 8] = b"BIONKV1\n"; +/// Sentinelle `vlen` marquant un tombstone (suppression). +const TOMBSTONE: u32 = u32::MAX; +/// Taille de l'en-tĂȘte fixe d'un record : crc(4) + klen(4) + vlen(4). +const REC_HDR: u64 = 12; + +/// Position de la *valeur* d'une clĂ© vivante dans le log. +#[derive(Debug, Clone, Copy)] +struct Slot { + /// Offset du premier octet de la valeur dans le fichier. + value_off: u64, + /// Longueur de la valeur. + value_len: u32, +} + +/// Le magasin clĂ©-valeur. Une instance = un dossier sur disque. +/// +/// Toutes les clĂ©s vivantes tiennent dans l'index mĂ©moire (des `Vec`), +/// les valeurs restent sur disque : c'est le compromis Bitcask — RAM +/// proportionnelle au *nombre de clĂ©s*, pas au volume de donnĂ©es. +pub struct Kv { + /// Dossier du magasin (contient le log). + dir: PathBuf, + /// Le log, ouvert en lecture + append. + file: File, + /// Longueur logique du fichier = offset du prochain record. + write_pos: u64, + /// L'index : clĂ© → position de sa derniĂšre valeur vivante. + index: HashMap, Slot>, +} + +impl Kv { + /// Ouvre (ou crĂ©e) un magasin dans `dir`. + /// + /// Scanne le log du dĂ©but Ă  la fin pour reconstruire l'index. Si la + /// **queue** du fichier est corrompue (crash en pleine Ă©criture : record + /// tronquĂ© ou CRC invalide), elle est **tronquĂ©e** et l'ouverture rĂ©ussit + /// avec toutes les donnĂ©es saines qui prĂ©cĂšdent — c'est le contrat de + /// rĂ©cupĂ©ration de Bitcask. + pub fn open>(dir: P) -> io::Result { + let dir = dir.as_ref().to_path_buf(); + fs::create_dir_all(&dir)?; + // Un `.compact` orphelin = compaction interrompue AVANT le rename : + // le log principal est intact, le brouillon est simplement jetĂ©. + let _ = fs::remove_file(dir.join(COMPACT_NAME)); + + let path = dir.join(LOG_NAME); + let mut file = OpenOptions::new() + .read(true) + .append(true) + .create(true) + .open(&path)?; + + let len = file.metadata()?.len(); + if len == 0 { + file.write_all(MAGIC)?; + } else { + let mut magic = [0u8; 8]; + file.seek(SeekFrom::Start(0))?; + if len < 8 || { + file.read_exact(&mut magic)?; + &magic != MAGIC + } { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bion-kv : en-tĂȘte de log inconnu (fichier Ă©tranger ?)", + )); + } + } + + let mut kv = Kv { + dir, + file, + write_pos: 0, + index: HashMap::new(), + }; + kv.rebuild_index()?; + Ok(kv) + } + + /// Écrit (ou remplace) la valeur associĂ©e Ă  `key`. + /// + /// Un `put` = un seul `append` au log + une entrĂ©e d'index : O(1), + /// sĂ©quentiel, jamais de réécriture en place. + pub fn put(&mut self, key: &[u8], value: &[u8]) -> io::Result<()> { + let vlen = u32::try_from(value.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "bion-kv : valeur > 4 Gio"))?; + if vlen == TOMBSTONE { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "bion-kv : valeur > 4 Gio", + )); + } + let rec_off = self.append_record(key, Some(value))?; + self.index.insert( + key.to_vec(), + Slot { + value_off: rec_off + REC_HDR + key.len() as u64, + value_len: vlen, + }, + ); + Ok(()) + } + + /// Lit la derniĂšre valeur associĂ©e Ă  `key`, ou `None` si absente/supprimĂ©e. + /// + /// Un `get` = une consultation de l'index mĂ©moire puis, au plus, **un** + /// seek + un read sur disque. + pub fn get(&mut self, key: &[u8]) -> io::Result>> { + let slot = match self.index.get(key) { + Some(s) => *s, + None => return Ok(None), + }; + let mut buf = vec![0u8; slot.value_len as usize]; + self.file.seek(SeekFrom::Start(slot.value_off))?; + self.file.read_exact(&mut buf)?; + Ok(Some(buf)) + } + + /// Supprime `key` en ajoutant un **tombstone** au log. + /// + /// No-op silencieux si la clĂ© n'existe pas (rien Ă  tuer, rien Ă  Ă©crire). + pub fn delete(&mut self, key: &[u8]) -> io::Result<()> { + if !self.index.contains_key(key) { + return Ok(()); + } + self.append_record(key, None)?; + self.index.remove(key); + Ok(()) + } + + /// Réécrit le log en ne gardant que les donnĂ©es **vivantes**, puis bascule + /// atomiquement via `rename`. + /// + /// DĂ©roulĂ© crash-sĂ»r : + /// 1. Ă©crire toutes les paires vivantes dans `bion-kv.log.compact` ; + /// 2. `sync_all()` — le brouillon est durable ; + /// 3. `rename(compact, log)` — atomique sur un mĂȘme systĂšme de fichiers : + /// avant, l'ancien log est complet ; aprĂšs, le nouveau l'est. Aucune + /// fenĂȘtre oĂč le magasin serait invalide ; + /// 4. rouvrir le fichier et rebrancher l'index sur les nouveaux offsets. + pub fn compact(&mut self) -> io::Result<()> { + let tmp_path = self.dir.join(COMPACT_NAME); + let log_path = self.dir.join(LOG_NAME); + + // 1. Brouillon : en-tĂȘte + records vivants, en calculant au passage + // les futurs offsets de l'index. + let mut tmp = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp_path)?; + tmp.write_all(MAGIC)?; + let mut pos = MAGIC.len() as u64; + let mut new_index: HashMap, Slot> = HashMap::with_capacity(self.index.len()); + // (tri des clĂ©s = sortie dĂ©terministe, agrĂ©able pour tester/diff-er) + let mut keys: Vec> = self.index.keys().cloned().collect(); + keys.sort(); + for key in keys { + let value = self + .get(&key)? + .expect("clĂ© indexĂ©e forcĂ©ment vivante avant compaction"); + let rec = encode_record(&key, Some(&value)); + tmp.write_all(&rec)?; + new_index.insert( + key.clone(), + Slot { + value_off: pos + REC_HDR + key.len() as u64, + value_len: value.len() as u32, + }, + ); + pos += rec.len() as u64; + } + + // 2. DurabilitĂ© du brouillon avant de s'y engager. + tmp.sync_all()?; + drop(tmp); + + // 3. La bascule atomique. + fs::rename(&tmp_path, &log_path)?; + // Durcir aussi l'entrĂ©e de rĂ©pertoire (le rename lui-mĂȘme). + if let Ok(d) = File::open(&self.dir) { + let _ = d.sync_all(); + } + + // 4. Le handle ouvert pointe encore sur l'ancien inode : rouvrir. + self.file = OpenOptions::new().read(true).append(true).open(&log_path)?; + self.write_pos = pos; + self.index = new_index; + Ok(()) + } + + /// Nombre de clĂ©s vivantes. + pub fn len(&self) -> usize { + self.index.len() + } + + /// `true` si aucune clĂ© vivante. + pub fn is_empty(&self) -> bool { + self.index.is_empty() + } + + /// Les clĂ©s vivantes, dans un ordre arbitraire (celui du `HashMap`). + pub fn keys(&self) -> impl Iterator { + self.index.keys().map(|k| k.as_slice()) + } + + /// Force l'Ă©criture physique du log sur le support (`fsync`). + /// + /// `put`/`delete` laissent l'OS vider son cache quand il veut (comme + /// Bitcask par dĂ©faut) : appeler `sync` quand on veut la garantie + /// « survivra Ă  une coupure de courant maintenant ». + pub fn sync(&mut self) -> io::Result<()> { + self.file.sync_all() + } + + // ---------------------------------------------------------------- privĂ© + + /// Ajoute un record au log (valeur `None` = tombstone) et renvoie l'offset + /// oĂč il commence. + fn append_record(&mut self, key: &[u8], value: Option<&[u8]>) -> io::Result { + if u32::try_from(key.len()).is_err() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "bion-kv : clĂ© > 4 Gio", + )); + } + let rec = encode_record(key, value); + let off = self.write_pos; + // Le fichier est en mode append : write_all Ă©crit toujours Ă  la fin, + // mĂȘme si un `get` a dĂ©placĂ© le curseur entre-temps. + self.file.write_all(&rec)?; + self.write_pos += rec.len() as u64; + Ok(off) + } + + /// Rejoue tout le log pour reconstruire l'index. Tronque la queue au + /// premier record invalide (crash en cours d'Ă©criture). + fn rebuild_index(&mut self) -> io::Result<()> { + let file_len = self.file.metadata()?.len(); + let mut pos = MAGIC.len() as u64; + self.index.clear(); + self.file.seek(SeekFrom::Start(pos))?; + // Lecture bufferisĂ©e : un scan sĂ©quentiel, pas un syscall par champ. + let mut reader = io::BufReader::new(&mut self.file); + + loop { + // Assez d'octets pour un en-tĂȘte de record ? + if file_len - pos < REC_HDR { + break; + } + let mut hdr = [0u8; REC_HDR as usize]; + reader.read_exact(&mut hdr)?; + let crc_lu = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]); + let klen = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]); + let vlen = u32::from_le_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]); + + let data_len = klen as u64 + if vlen == TOMBSTONE { 0 } else { vlen as u64 }; + // Longueurs annoncĂ©es > octets restants ⇒ record tronquĂ© (ou + // en-tĂȘte de bruit) ⇒ queue corrompue. On vĂ©rifie AVANT d'allouer + // pour qu'un `klen` fantaisiste ne fasse pas exploser la RAM. + if data_len > file_len - pos - REC_HDR { + break; + } + let mut data = vec![0u8; data_len as usize]; + reader.read_exact(&mut data)?; + + let mut h = crc32::Hasher::new(); + h.update(&hdr[4..]); + h.update(&data); + if h.finish() != crc_lu { + break; // corruption : on s'arrĂȘte au dernier record sain + } + + let key = data[..klen as usize].to_vec(); + if vlen == TOMBSTONE { + self.index.remove(&key); + } else { + self.index.insert( + key, + Slot { + value_off: pos + REC_HDR + klen as u64, + value_len: vlen, + }, + ); + } + pos += REC_HDR + data_len; + } + + drop(reader); + if pos < file_len { + // Amputer la queue malade pour que les prochains appends + // repartent d'une base 100 % saine. + self.file.set_len(pos)?; + } + self.write_pos = pos; + Ok(()) + } +} + +impl std::fmt::Debug for Kv { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Kv") + .field("dir", &self.dir) + .field("keys", &self.index.len()) + .field("log_bytes", &self.write_pos) + .finish() + } +} + +/// SĂ©rialise un record complet (CRC compris). `None` = tombstone. +fn encode_record(key: &[u8], value: Option<&[u8]>) -> Vec { + let klen = key.len() as u32; + let vlen = match value { + Some(v) => v.len() as u32, + None => TOMBSTONE, + }; + let value = value.unwrap_or(&[]); + + let mut rec = Vec::with_capacity(REC_HDR as usize + key.len() + value.len()); + rec.extend_from_slice(&[0u8; 4]); // place du CRC, rempli Ă  la fin + rec.extend_from_slice(&klen.to_le_bytes()); + rec.extend_from_slice(&vlen.to_le_bytes()); + rec.extend_from_slice(key); + rec.extend_from_slice(value); + + let crc = crc32::crc32(&rec[4..]); + rec[..4].copy_from_slice(&crc.to_le_bytes()); + rec +} + +// ============================================================== TESTS ===== + +#[cfg(test)] +mod tests { + use super::*; + + /// Dossier de test unique, nettoyĂ© au drop. + struct TmpDir(PathBuf); + impl TmpDir { + fn new(tag: &str) -> Self { + let p = std::env::temp_dir().join(format!( + "bion-kv-test-{}-{}-{:?}", + tag, + std::process::id(), + std::thread::current().id(), + )); + let _ = fs::remove_dir_all(&p); + TmpDir(p) + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn log_len(dir: &Path) -> u64 { + fs::metadata(dir.join(LOG_NAME)).unwrap().len() + } + + #[test] + fn put_get_delete() { + let t = TmpDir::new("base"); + let mut kv = Kv::open(&t.0).unwrap(); + assert!(kv.is_empty()); + kv.put(b"a", b"1").unwrap(); + kv.put(b"b", b"2").unwrap(); + assert_eq!(kv.get(b"a").unwrap().as_deref(), Some(&b"1"[..])); + assert_eq!(kv.get(b"b").unwrap().as_deref(), Some(&b"2"[..])); + assert_eq!(kv.get(b"absent").unwrap(), None); + assert_eq!(kv.len(), 2); + + kv.delete(b"a").unwrap(); + assert_eq!(kv.get(b"a").unwrap(), None); + assert_eq!(kv.len(), 1); + // delete d'une clĂ© absente : no-op sans erreur + kv.delete(b"jamais-vue").unwrap(); + } + + #[test] + fn ecrasement_derniere_valeur_gagne() { + let t = TmpDir::new("overwrite"); + let mut kv = Kv::open(&t.0).unwrap(); + kv.put(b"k", b"v1").unwrap(); + kv.put(b"k", b"v2").unwrap(); + kv.put(b"k", b"la-bonne").unwrap(); + assert_eq!(kv.get(b"k").unwrap().as_deref(), Some(&b"la-bonne"[..])); + assert_eq!(kv.len(), 1); + } + + #[test] + fn cles_et_valeurs_binaires_et_vides() { + let t = TmpDir::new("binaire"); + let mut kv = Kv::open(&t.0).unwrap(); + let key = [0u8, 255, 10, 13, 0]; // NUL, non-UTF-8, retours ligne + kv.put(&key, b"").unwrap(); // valeur vide = lĂ©gitime + kv.put(b"", b"valeur-de-la-cle-vide").unwrap(); + assert_eq!(kv.get(&key).unwrap().as_deref(), Some(&b""[..])); + assert_eq!( + kv.get(b"").unwrap().as_deref(), + Some(&b"valeur-de-la-cle-vide"[..]) + ); + } + + #[test] + fn persistance_apres_reouverture() { + let t = TmpDir::new("persist"); + { + let mut kv = Kv::open(&t.0).unwrap(); + kv.put(b"xer", b"renderer").unwrap(); + kv.put(b"kion", b"3d").unwrap(); + kv.put(b"xer", b"renderer-v2").unwrap(); // Ă©crasement prĂ©-fermeture + } + let mut kv = Kv::open(&t.0).unwrap(); + assert_eq!(kv.len(), 2); + assert_eq!( + kv.get(b"xer").unwrap().as_deref(), + Some(&b"renderer-v2"[..]) + ); + assert_eq!(kv.get(b"kion").unwrap().as_deref(), Some(&b"3d"[..])); + } + + #[test] + fn tombstone_survit_a_la_reouverture() { + let t = TmpDir::new("tombstone"); + { + let mut kv = Kv::open(&t.0).unwrap(); + kv.put(b"mort", b"bientot").unwrap(); + kv.put(b"vif", b"toujours").unwrap(); + kv.delete(b"mort").unwrap(); + } + let mut kv = Kv::open(&t.0).unwrap(); + assert_eq!(kv.get(b"mort").unwrap(), None, "le tombstone doit rejouer"); + assert_eq!(kv.get(b"vif").unwrap().as_deref(), Some(&b"toujours"[..])); + assert_eq!(kv.len(), 1); + } + + #[test] + fn compaction_reduit_et_preserve() { + let t = TmpDir::new("compact"); + let mut kv = Kv::open(&t.0).unwrap(); + // Beaucoup de versions mortes : 50 Ă©crasements + des suppressions. + for i in 0..50u32 { + kv.put(b"chaud", format!("version-{i}").as_bytes()).unwrap(); + } + kv.put(b"stable", b"inchangee").unwrap(); + kv.put(b"ephemere", b"grosse-valeur-condamnee-aaaaaaaaaaaa") + .unwrap(); + kv.delete(b"ephemere").unwrap(); + + let avant = log_len(&t.0); + kv.compact().unwrap(); + let apres = log_len(&t.0); + assert!( + apres < avant, + "compaction doit rĂ©duire : {apres} >= {avant}" + ); + + // DonnĂ©es intactes, tombstones physiquement disparus. + assert_eq!( + kv.get(b"chaud").unwrap().as_deref(), + Some(&b"version-49"[..]) + ); + assert_eq!( + kv.get(b"stable").unwrap().as_deref(), + Some(&b"inchangee"[..]) + ); + assert_eq!(kv.get(b"ephemere").unwrap(), None); + assert_eq!(kv.len(), 2); + + // Et le magasin reste pleinement utilisable aprĂšs compaction
 + kv.put(b"apres", b"compaction").unwrap(); + assert_eq!( + kv.get(b"apres").unwrap().as_deref(), + Some(&b"compaction"[..]) + ); + drop(kv); + // 
 y compris aprĂšs rĂ©ouverture (les offsets rebranchĂ©s sont bons). + let mut kv = Kv::open(&t.0).unwrap(); + assert_eq!( + kv.get(b"chaud").unwrap().as_deref(), + Some(&b"version-49"[..]) + ); + assert_eq!( + kv.get(b"apres").unwrap().as_deref(), + Some(&b"compaction"[..]) + ); + assert_eq!(kv.len(), 3); + } + + #[test] + fn queue_corrompue_garbage_toleree() { + let t = TmpDir::new("garbage"); + { + let mut kv = Kv::open(&t.0).unwrap(); + kv.put(b"sain", b"avant-le-crash").unwrap(); + } + // Simule un crash : des octets de bruit atterrissent en fin de log. + let path = t.0.join(LOG_NAME); + let mut f = OpenOptions::new().append(true).open(&path).unwrap(); + f.write_all(&[ + 0xDE, 0xAD, 0xBE, 0xEF, 0x42, 0x42, 0x42, 0x42, 1, 2, 3, 4, 5, + ]) + .unwrap(); + drop(f); + + let mut kv = Kv::open(&t.0).unwrap(); + assert_eq!( + kv.get(b"sain").unwrap().as_deref(), + Some(&b"avant-le-crash"[..]) + ); + // La queue a Ă©tĂ© tronquĂ©e : on peut rĂ©-Ă©crire par-dessus sans souci. + kv.put(b"repart", b"proprement").unwrap(); + drop(kv); + let mut kv = Kv::open(&t.0).unwrap(); + assert_eq!( + kv.get(b"repart").unwrap().as_deref(), + Some(&b"proprement"[..]) + ); + assert_eq!(kv.len(), 2); + } + + #[test] + fn queue_corrompue_record_tronque() { + let t = TmpDir::new("tronque"); + { + let mut kv = Kv::open(&t.0).unwrap(); + kv.put(b"complet", b"ok").unwrap(); + kv.put(b"coupe", b"cette-valeur-va-perdre-sa-fin").unwrap(); + } + // Ampute les 5 derniers octets : le dernier record devient invalide. + let path = t.0.join(LOG_NAME); + let len = fs::metadata(&path).unwrap().len(); + let f = OpenOptions::new().write(true).open(&path).unwrap(); + f.set_len(len - 5).unwrap(); + drop(f); + + let mut kv = Kv::open(&t.0).unwrap(); + assert_eq!(kv.get(b"complet").unwrap().as_deref(), Some(&b"ok"[..])); + assert_eq!( + kv.get(b"coupe").unwrap(), + None, + "le record amputĂ© est perdu" + ); + assert_eq!(kv.len(), 1); + } + + #[test] + fn crc_detecte_un_bit_flip_au_milieu_de_la_queue() { + let t = TmpDir::new("bitflip"); + { + let mut kv = Kv::open(&t.0).unwrap(); + kv.put(b"avant", b"intacte").unwrap(); + kv.put(b"victime", b"un bit va sauter ici").unwrap(); + } + // Flip d'un bit dans la VALEUR du dernier record (pas dans l'en-tĂȘte). + let path = t.0.join(LOG_NAME); + let mut bytes = fs::read(&path).unwrap(); + let n = bytes.len(); + bytes[n - 3] ^= 0x01; + fs::write(&path, &bytes).unwrap(); + + let mut kv = Kv::open(&t.0).unwrap(); + assert_eq!(kv.get(b"avant").unwrap().as_deref(), Some(&b"intacte"[..])); + assert_eq!( + kv.get(b"victime").unwrap(), + None, + "CRC doit rejeter le record" + ); + } + + #[test] + fn fichier_etranger_refuse() { + let t = TmpDir::new("etranger"); + fs::create_dir_all(&t.0).unwrap(); + fs::write(t.0.join(LOG_NAME), b"PAS-UN-LOG-BIONKV-DU-TOUT").unwrap(); + let err = Kv::open(&t.0).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn brouillon_de_compaction_orphelin_ignore() { + let t = TmpDir::new("orphelin"); + { + let mut kv = Kv::open(&t.0).unwrap(); + kv.put(b"k", b"v").unwrap(); + } + // Simule une compaction interrompue avant le rename. + fs::write(t.0.join(COMPACT_NAME), b"brouillon a moitie ecrit").unwrap(); + let mut kv = Kv::open(&t.0).unwrap(); + assert_eq!(kv.get(b"k").unwrap().as_deref(), Some(&b"v"[..])); + assert!( + !t.0.join(COMPACT_NAME).exists(), + "le brouillon doit ĂȘtre jetĂ©" + ); + } + + #[test] + fn keys_et_sync() { + let t = TmpDir::new("keys"); + let mut kv = Kv::open(&t.0).unwrap(); + kv.put(b"a", b"1").unwrap(); + kv.put(b"b", b"2").unwrap(); + kv.delete(b"a").unwrap(); + let ks: Vec<&[u8]> = kv.keys().collect(); + assert_eq!(ks, vec![&b"b"[..]]); + kv.sync().unwrap(); // fsync explicite : ne doit pas Ă©chouer + } + + #[test] + fn gros_volume_et_reouverture() { + let t = TmpDir::new("volume"); + { + let mut kv = Kv::open(&t.0).unwrap(); + for i in 0..500u32 { + kv.put(format!("cle-{i}").as_bytes(), &i.to_le_bytes()) + .unwrap(); + } + for i in (0..500u32).step_by(2) { + kv.delete(format!("cle-{i}").as_bytes()).unwrap(); + } + kv.compact().unwrap(); + } + let mut kv = Kv::open(&t.0).unwrap(); + assert_eq!(kv.len(), 250); + assert_eq!(kv.get(b"cle-0").unwrap(), None); + assert_eq!( + kv.get(b"cle-499").unwrap().as_deref(), + Some(&499u32.to_le_bytes()[..]) + ); + } +} diff --git a/bion-regex/Cargo.toml b/bion-regex/Cargo.toml new file mode 100644 index 0000000..23dc5ac --- /dev/null +++ b/bion-regex/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "bion-regex" +version = "0.1.0" +edition = "2021" +description = "Moteur d'expressions rĂ©guliĂšres from scratch, NFA de Thompson + simulation par ensembles d'Ă©tats : temps linĂ©aire garanti, jamais de backtracking exponentiel. std-only, build-your-own-regex." +license = "MIT" + +[dependencies] diff --git a/bion-regex/README.md b/bion-regex/README.md new file mode 100644 index 0000000..74b401d --- /dev/null +++ b/bion-regex/README.md @@ -0,0 +1,126 @@ +# bion-regex đŸ”€ + +Moteur d'expressions rĂ©guliĂšres **from scratch, std-only, zĂ©ro dĂ©pendance, +zĂ©ro unsafe** — LE classique *build-your-own-regex*, version **NFA de +Thompson** simulĂ© par ensembles d'Ă©tats. La brique regex du xerboxion : +construite une fois, plus jamais rebĂątie, seulement optimisĂ©e (principe RS-7). + +RĂ©fĂ©rence fondatrice : Russ Cox, **« Regular Expression Matching Can Be +Simple And Fast »** — . À lire +avec le code sous les yeux : ce crate en est une implĂ©mentation Rust fidĂšle +et commentĂ©e. Voir aussi la section *build-your-own-x* correspondante : +. + +## Quoi + +Pipeline en trois Ă©tapes, un fichier par Ă©tape : + +``` +motif ──parse──▶ AST ──compile──▶ NFA ──simulation──▶ oui/non + position + src/parser.rs src/nfa.rs src/nfa.rs (ensembles d'Ă©tats) +``` + +- `Regex::new(pattern) -> Result` — compile une fois pour toutes +- `is_match(text) -> bool` — le motif apparaĂźt-il quelque part ? +- `find(text) -> Option<(usize, usize)>` — premiĂšre occurrence, + *leftmost-longest*, offsets en **octets** (`&text[start..end]` = le match) +- Erreurs de syntaxe **localisĂ©es** (position en caractĂšres) et en français + +Langage supportĂ© : littĂ©raux Unicode · `.` (tout sauf `\n`) · `*` `+` `?` · +`|` · groupes `(
)` (prioritĂ© seulement, pas de capture — choix assumĂ© pour +garder l'API minuscule) · classes `[a-z]`, `[^
]`, `]`/`-` littĂ©raux aux +positions classiques · `\d \D \w \W \s \S` · `\n \t \r` · mĂ©tacaractĂšres +Ă©chappĂ©s (`\.` `\(` 
) · ancres `^` `$`. + +## Pourquoi Thompson (et pas du backtracking) + +C'est **le** point pĂ©dagogique du crate. Un moteur Ă  backtracking (Perl, +PCRE, `re` de Python
) essaie les alternatives une par une et revient en +arriĂšre : sur `a*a*a*
a*b` face Ă  `aaaa
a` (sans `b`), il doit explorer +~2ⁿ dĂ©coupages avant d'avouer l'Ă©chec — 30 `a` suffisent Ă  le figer des +secondes, 40 des heures. C'est la racine des CVE « ReDoS ». + +L'approche Thompson (1968), ressuscitĂ©e par l'article de Russ Cox : + +1. **Compilation** : chaque nƓud de l'AST devient un fragment de NFA d'au + plus UN Ă©tat (`Char`, `Split`, assertions). Le NFA fait O(m) Ă©tats pour + un motif de m caractĂšres — jamais plus. +2. **Simulation par ensembles d'Ă©tats** : on lit le texte UNE fois ; Ă  chaque + caractĂšre on maintient *l'ensemble de tous les Ă©tats oĂč le NFA pourrait + ĂȘtre* (≀ n Ă©tats, dĂ©dupliquĂ©s). C'est la dĂ©terminisation « Ă  la volĂ©e », + sans matĂ©rialiser le DFA. + +RĂ©sultat : **O(texte × motif) garanti, pour tout motif, tout texte**. Le +motif pathologique ci-dessus rĂ©pond en microsecondes — c'est testĂ©, +chronomĂštre Ă  l'appui (`pathologique_a_star_reste_instantane`). + +Les ancres `^`/`$` sont des Ă©tats-assertions Ă©valuĂ©s pendant la fermeture +epsilon (elles ne consomment rien), donc elles marchent aussi au milieu +d'une alternance (`^dĂ©but|fin$`). + +## Exemple + +```rust +use bion_regex::Regex; + +let re = Regex::new(r"^\w+@\w+\.[a-z]+$").unwrap(); +assert!(re.is_match("rs7@xerion.ch")); + +let re = Regex::new(r"\d+").unwrap(); +let texte = "il y a 285 bions"; +let (s, e) = re.find(texte).unwrap(); +assert_eq!(&texte[s..e], "285"); + +// Le piĂšge qui tue un backtracker — instantanĂ© ici : +let patho = Regex::new(&format!("{}b", "a*".repeat(30))).unwrap(); +assert!(!patho.is_match(&"a".repeat(30))); +``` + +## Le bug classique (vĂ©cu, puis testĂ©) + +PremiĂšre version : la fermeture epsilon marquait les Ă©tats `Split` comme +« vus » mais ne les dĂ©marquait pas entre deux caractĂšres → les boucles +(`a+`, `(ab)*`) n'Ă©taient traversables qu'**une seule fois** (`ab+c` +matchait `abc` mais pas `abbc`). Le fix : l'ensemble d'Ă©tats garde la liste +de TOUS les Ă©tats marquĂ©s (pas seulement les stables) pour tout dĂ©marquer au +`clear`. Si tu rĂ©implĂ©mentes ce moteur, tu feras ce bug — le test +`plus_exige_au_moins_un` t'attend. + +## ComplĂ©ment, pas doublon + +Le core (`~/xerboxion-rt`) n'a aucun moteur de motifs ; les ploxions font du +`match exact only` (cf. tsoin engine / `config/ploxions.php`). `bion-regex` +est la brique qui manque : filtrage de tsoins, routes, validation d'entrĂ©es — +embarquable partout (std-only, compile en WASM sans rien changer). + +## Comment l'optimiser (l'invitation au fork) + +Le moteur est volontairement la version *simple et juste*. Pistes, par ordre +de rendement (toutes dans les articles suivants de Russ Cox, +[regexp2](https://swtch.com/~rsc/regexp/regexp2.html) et +[regexp3](https://swtch.com/~rsc/regexp/regexp3.html)) : + +1. **`find` en un seul passage** — aujourd'hui `find` relance une simulation + ancrĂ©e par position de dĂ©part (O(nÂČ·m) au pire). La VM de Pike attache la + position de dĂ©part Ă  chaque « thread » : leftmost-longest en O(n·m). +2. **Captures** — mĂȘme VM de Pike : chaque thread porte ses positions de + sous-groupes. C'est l'Ă©tape qui transforme `(
)` en vraies captures. +3. **Cache DFA Ă  la RE2** — mĂ©moĂŻser les ensembles d'Ă©tats rencontrĂ©s : + chaque caractĂšre devient UN lookup de table (c'est ce que fait `grep`). +4. **Octets plutĂŽt que chars** — compiler les classes Unicode en automate + sur les octets UTF-8 : plus de dĂ©codage Ă  l'exĂ©cution. +5. **LittĂ©raux prĂ©fixes** — `memchr` sur le premier octet obligatoire avant + de lancer le NFA (l'optimisation qui rend `ripgrep` rapide). + +L'API (`new` / `is_match` / `find` / `pattern`) ne bouge pas : optimiser = +remplacer l'intĂ©rieur, jamais casser l'extĂ©rieur. + +## Tests + +``` +cargo test -p bion-regex # 34 tests : 12 unitaires + 20 intĂ©gration + 2 doc +``` + +Couvre : les piĂšges pathologiques (chronomĂ©trĂ©s), boucles vides imbriquĂ©es +`((a*)*)*`, classes nĂ©gatives, `]`/`-` littĂ©raux, ancres en alternance, +matchs vides, offsets Unicode, et huit erreurs de syntaxe localisĂ©es. diff --git a/bion-regex/src/lib.rs b/bion-regex/src/lib.rs new file mode 100644 index 0000000..87b190f --- /dev/null +++ b/bion-regex/src/lib.rs @@ -0,0 +1,162 @@ +//! # bion-regex — un moteur d'expressions rĂ©guliĂšres from scratch +//! +//! Le classique *build-your-own-regex*, version **NFA de Thompson** : jamais +//! de backtracking, donc jamais l'explosion exponentielle des moteurs Ă  la +//! Perl/PCRE. RĂ©fĂ©rence pĂ©dagogique : Russ Cox, *Regular Expression Matching +//! Can Be Simple And Fast* (). +//! +//! ## Le pipeline en trois Ă©tapes +//! +//! ```text +//! motif ──parse──▶ AST ──compile──▶ NFA ──simulation──▶ oui/non + position +//! parser.rs nfa.rs nfa.rs (ensembles d'Ă©tats) +//! ``` +//! +//! 1. **Parse** (`parser.rs`) : descente rĂ©cursive → arbre de syntaxe. +//! 2. **Compile** (`nfa.rs`) : construction de Thompson — chaque nƓud de +//! l'AST devient un fragment de NFA d'au plus un Ă©tat ; le NFA fait O(m) +//! Ă©tats pour un motif de m caractĂšres. +//! 3. **Simule** : on parcourt le texte UNE fois en maintenant *l'ensemble* +//! des Ă©tats possibles. CoĂ»t **O(texte × motif), garanti** — le motif +//! pathologique `a*a*a*
` qui fige un backtracker reste instantanĂ© ici +//! (c'est testĂ©, chronomĂštre Ă  l'appui). +//! +//! ## Langage supportĂ© +//! +//! littĂ©raux Unicode · `.` (tout sauf `\n`) · `*` `+` `?` · `|` · groupes +//! `(
)` (prioritĂ© seulement, pas de capture) · classes `[a-z]`, `[^
]` · +//! `\d \D \w \W \s \S`, `\n \t \r`, mĂ©tacaractĂšres Ă©chappĂ©s (`\.` `\(`
) · +//! ancres `^` `$` (dĂ©but/fin de texte). +//! +//! ## Exemple +//! +//! ``` +//! use bion_regex::Regex; +//! +//! let re = Regex::new(r"^\w+@\w+\.[a-z]+$").unwrap(); +//! assert!(re.is_match("rs7@xerion.ch")); +//! assert!(!re.is_match("pas un mail")); +//! +//! let re = Regex::new(r"\d+").unwrap(); +//! assert_eq!(re.find("abc 42 def"), Some((4, 6))); // offsets en octets +//! assert_eq!(&"abc 42 def"[4..6], "42"); +//! ``` + +mod nfa; +mod parser; + +use std::fmt; + +/// Une expression rĂ©guliĂšre **compilĂ©e** : le motif a Ă©tĂ© parsĂ© et traduit +/// en NFA une fois pour toutes — les recherches ne re-parsent jamais. +/// +/// Construire avec [`Regex::new`], interroger avec [`Regex::is_match`] et +/// [`Regex::find`]. La compilation est O(m) en temps et en mĂ©moire, la +/// recherche est linĂ©aire dans le texte : aucun motif ne peut rendre ce +/// moteur exponentiel. +#[derive(Debug)] +pub struct Regex { + pattern: String, + nfa: nfa::Nfa, +} + +impl Regex { + /// Compile `pattern`. Erreur descriptive (avec position **en caractĂšres** + /// dans le motif, 0-basĂ©) si la syntaxe est invalide. + /// + /// ``` + /// use bion_regex::{Regex, Error}; + /// assert!(Regex::new("a(b|c)*d").is_ok()); + /// assert_eq!(Regex::new("(ab").unwrap_err(), Error::UnbalancedParen { pos: 0 }); + /// ``` + pub fn new(pattern: &str) -> Result { + let ast = parser::parse(pattern)?; + let nfa = nfa::compile(&ast); + Ok(Regex { + pattern: pattern.to_string(), + nfa, + }) + } + + /// Le motif apparaĂźt-il **quelque part** dans `text` ? (recherche non + /// ancrĂ©e ; utiliser `^`/`$` dans le motif pour ancrer). Un seul passage + /// sur le texte, O(texte × motif). + pub fn is_match(&self, text: &str) -> bool { + self.nfa.is_match(text) + } + + /// PremiĂšre occurrence du motif : `Some((start, end))` en **offsets + /// d'octets**, `end` exclu — `&text[start..end]` est le texte reconnu. + /// SĂ©mantique *leftmost-longest* (POSIX) : le match qui commence le plus + /// tĂŽt, et Ă  cette position, le plus long possible. + /// + /// Un motif pouvant reconnaĂźtre la chaĂźne vide (ex. `a*`) trouve un + /// match vide : `find` peut renvoyer `Some((i, i))`. + pub fn find(&self, text: &str) -> Option<(usize, usize)> { + self.nfa.find(text) + } + + /// Le motif d'origine, tel que passĂ© Ă  [`Regex::new`]. + pub fn pattern(&self) -> &str { + &self.pattern + } +} + +/// Erreur de syntaxe dans un motif. Chaque variante localise le problĂšme : +/// `pos` compte en **caractĂšres** (pas en octets) depuis le dĂ©but du motif, +/// 0-basĂ©. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Error { + /// `*`, `+` ou `?` sans rien Ă  rĂ©pĂ©ter devant (ex. `*a`, `(|*)`). + DanglingQuantifier { pos: usize }, + /// Quantificateur appliquĂ© Ă  une ancre (ex. `^*`) : ça n'a pas de sens. + QuantifierOnAnchor { pos: usize }, + /// `(` jamais fermĂ©e (pos = la parenthĂšse ouvrante) ou `)` orpheline + /// (pos = la parenthĂšse fermante). + UnbalancedParen { pos: usize }, + /// `[` jamais fermĂ© (pos = le crochet ouvrant). + UnclosedClass { pos: usize }, + /// Intervalle de classe invalide, ex. `[z-a]` (pos = la borne basse). + InvalidClassRange { pos: usize }, + /// Échappement inconnu, ex. `\q` (pos = le caractĂšre aprĂšs le `\`). + UnknownEscape { c: char, pos: usize }, + /// Le motif se termine sur un `\` seul. + TrailingBackslash, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::DanglingQuantifier { pos } => { + write!(f, "quantificateur sans opĂ©rande au caractĂšre {pos}") + } + Error::QuantifierOnAnchor { pos } => { + write!(f, "quantificateur sur une ancre au caractĂšre {pos}") + } + Error::UnbalancedParen { pos } => { + write!(f, "parenthĂšse non appariĂ©e au caractĂšre {pos}") + } + Error::UnclosedClass { pos } => { + write!( + f, + "classe de caractĂšres jamais fermĂ©e (ouverte au caractĂšre {pos})" + ) + } + Error::InvalidClassRange { pos } => { + write!( + f, + "intervalle de classe invalide au caractĂšre {pos} (borne basse > haute)" + ) + } + Error::UnknownEscape { c, pos } => { + write!(f, "Ă©chappement inconnu \\{c} au caractĂšre {pos}") + } + Error::TrailingBackslash => { + write!(f, "le motif se termine sur un '\\' seul") + } + } + } +} + +impl std::error::Error for Error {} diff --git a/bion-regex/src/nfa.rs b/bion-regex/src/nfa.rs new file mode 100644 index 0000000..cf3a73f --- /dev/null +++ b/bion-regex/src/nfa.rs @@ -0,0 +1,445 @@ +//! Étapes 2 et 3 du pipeline : **AST → NFA de Thompson**, puis **simulation +//! par ensembles d'Ă©tats** — le cƓur de l'article de Russ Cox, *Regular +//! Expression Matching Can Be Simple And Fast* +//! (). +//! +//! # La construction de Thompson (1968) +//! +//! Chaque nƓud de l'AST devient un petit **fragment** de NFA : un Ă©tat +//! d'entrĂ©e + une liste de flĂšches de sortie *pendantes* (pas encore +//! branchĂ©es). On assemble les fragments par rĂ©cursion structurelle, puis on +//! « soude » (`patch`) les flĂšches pendantes du fragment final sur l'Ă©tat +//! `Match`. Chaque construction ajoute au plus un Ă©tat : +//! +//! ```text +//! a : ─▶[a]─▶ 
 (1 Ă©tat Char) +//! e1 e2 : frag(e1) ─▶ frag(e2) (0 Ă©tat, on soude) +//! e1|e2 : ─▶[Split]─▶ frag(e1) (1 Ă©tat Split) +//! └──▶ frag(e2) +//! e* : ─▶[Split]─▶ frag(e) ──┐ (1 Ă©tat, boucle sur le Split) +//! └──▶ 
 ◀────┘ +//! e+ : ─▶ frag(e) ─▶[Split]─▶ 
 (comme e* mais on entre par e) +//! â–Č────────┘ +//! e? : ─▶[Split]─▶ frag(e) ─▶ 
 (les deux sorties pendantes) +//! └──▶ 
 +//! ``` +//! +//! Le NFA a donc **O(m)** Ă©tats pour un motif de m caractĂšres. Aucune +//! epsilon-transition explicite : les `Split` en tiennent lieu. +//! +//! # La simulation par ensembles d'Ă©tats +//! +//! Au lieu d'explorer le NFA par backtracking (exponentiel dans le pire cas, +//! cf. `a*a*a*
` — c'est LE piĂšge de Perl/PCRE que ce crate Ă©vite par +//! construction), on avance dans le texte **caractĂšre par caractĂšre** en +//! maintenant *l'ensemble de tous les Ă©tats oĂč le NFA pourrait ĂȘtre*. C'est +//! exactement la dĂ©terminisation « Ă  la volĂ©e », sans jamais matĂ©rialiser le +//! DFA. L'ensemble contient au plus n Ă©tats (n = taille du NFA), donc chaque +//! caractĂšre coĂ»te O(n) : **temps total O(m·n), garanti, quel que soit le +//! motif**. +//! +//! Les ancres `^`/`$` sont des Ă©tats-assertions traversĂ©s pendant la +//! fermeture epsilon : elles ne consomment rien, elles vĂ©rifient seulement +//! la position courante. + +use crate::parser::{Ast, Matcher}; + +/// Sentinelle « flĂšche pas encore soudĂ©e ». AprĂšs compilation, plus aucune +/// flĂšche ne pointe dessus (vĂ©rifiĂ© par les tests). +const DANGLING: usize = usize::MAX; + +/// Un Ă©tat du NFA. Les flĂšches sont des indices dans `Nfa::states` — pas de +/// pointeurs, pas d'unsafe : l'arĂšne `Vec` possĂšde tout. +#[derive(Debug)] +pub(crate) enum State { + /// Consomme un caractĂšre si `m` l'accepte, puis va en `out`. + Char { m: Matcher, out: usize }, + /// Ne consomme rien : le NFA est dans les DEUX branches Ă  la fois. + Split { out1: usize, out2: usize }, + /// Assertion `^` : franchissable seulement en dĂ©but de texte. + AssertStart { out: usize }, + /// Assertion `$` : franchissable seulement en fin de texte. + AssertEnd { out: usize }, + /// L'Ă©tat acceptant : y ĂȘtre = le motif a reconnu. + Match, +} + +/// Le NFA compilĂ© — la forme exĂ©cutable d'un motif. +#[derive(Debug)] +pub(crate) struct Nfa { + states: Vec, + start: usize, + /// Indice de l'unique Ă©tat `Match` (test d'acceptation en O(1)). + match_id: usize, +} + +/// Un fragment en cours d'assemblage : son Ă©tat d'entrĂ©e et ses flĂšches de +/// sortie pendantes `(Ă©tat, n° de slot)` — slot 0 = `out`/`out1`, slot 1 = +/// `out2` d'un `Split`. +struct Frag { + start: usize, + outs: Vec<(usize, u8)>, +} + +/// Compile l'AST en NFA. Infaillible : toute la validation a eu lieu au +/// parsing — un AST bien formĂ© donne toujours un NFA bien formĂ©. +pub(crate) fn compile(ast: &Ast) -> Nfa { + let mut states: Vec = Vec::new(); + let frag = build(ast, &mut states); + let match_id = states.len(); + states.push(State::Match); + patch(&mut states, &frag.outs, match_id); + debug_assert!(no_dangling(&states), "flĂšche pendante aprĂšs compilation"); + Nfa { + states, + start: frag.start, + match_id, + } +} + +/// Soude chaque flĂšche pendante de `outs` vers `target`. +fn patch(states: &mut [State], outs: &[(usize, u8)], target: usize) { + for &(id, slot) in outs { + match (&mut states[id], slot) { + (State::Char { out, .. }, _) + | (State::AssertStart { out }, _) + | (State::AssertEnd { out }, _) => *out = target, + (State::Split { out1, .. }, 0) => *out1 = target, + (State::Split { out2, .. }, _) => *out2 = target, + (State::Match, _) => unreachable!("Match n'a pas de sortie"), + } + } +} + +fn no_dangling(states: &[State]) -> bool { + states.iter().all(|s| match s { + State::Char { out, .. } | State::AssertStart { out } | State::AssertEnd { out } => { + *out != DANGLING + } + State::Split { out1, out2 } => *out1 != DANGLING && *out2 != DANGLING, + State::Match => true, + }) +} + +/// La traduction structurelle AST → fragments (schĂ©mas en tĂȘte de module). +fn build(ast: &Ast, states: &mut Vec) -> Frag { + match ast { + Ast::Empty => { + // Un Split dont les deux sorties pendantes seront soudĂ©es au mĂȘme + // endroit : une pure epsilon-transition. + let i = states.len(); + states.push(State::Split { + out1: DANGLING, + out2: DANGLING, + }); + Frag { + start: i, + outs: vec![(i, 0), (i, 1)], + } + } + Ast::Char(m) => { + let i = states.len(); + states.push(State::Char { + m: m.clone(), + out: DANGLING, + }); + Frag { + start: i, + outs: vec![(i, 0)], + } + } + Ast::AnchorStart => { + let i = states.len(); + states.push(State::AssertStart { out: DANGLING }); + Frag { + start: i, + outs: vec![(i, 0)], + } + } + Ast::AnchorEnd => { + let i = states.len(); + states.push(State::AssertEnd { out: DANGLING }); + Frag { + start: i, + outs: vec![(i, 0)], + } + } + Ast::Concat(a, b) => { + let fa = build(a, states); + let fb = build(b, states); + patch(states, &fa.outs, fb.start); + Frag { + start: fa.start, + outs: fb.outs, + } + } + Ast::Alt(a, b) => { + let fa = build(a, states); + let fb = build(b, states); + let i = states.len(); + states.push(State::Split { + out1: fa.start, + out2: fb.start, + }); + let mut outs = fa.outs; + outs.extend(fb.outs); + Frag { start: i, outs } + } + Ast::Star(a) => { + let fa = build(a, states); + let i = states.len(); + states.push(State::Split { + out1: fa.start, + out2: DANGLING, + }); + patch(states, &fa.outs, i); // la boucle + Frag { + start: i, + outs: vec![(i, 1)], + } + } + Ast::Plus(a) => { + let fa = build(a, states); + let i = states.len(); + states.push(State::Split { + out1: fa.start, + out2: DANGLING, + }); + patch(states, &fa.outs, i); // la boucle — mais on ENTRE par fa + Frag { + start: fa.start, + outs: vec![(i, 1)], + } + } + Ast::Quest(a) => { + let fa = build(a, states); + let i = states.len(); + states.push(State::Split { + out1: fa.start, + out2: DANGLING, + }); + let mut outs = fa.outs; + outs.push((i, 1)); + Frag { start: i, outs } + } + } +} + +// --------------------------------------------------------------------------- +// Simulation +// --------------------------------------------------------------------------- + +/// L'ensemble d'Ă©tats courant : une liste dense pour itĂ©rer + un tableau de +/// marques pour dĂ©dupliquer en O(1). Les marques garantissent AUSSI la +/// terminaison de la fermeture epsilon face aux boucles vides (`(a*)*`). +struct StateSet { + /// Les Ă©tats « stables » (Char/Match) — ceux que `step` fait avancer. + list: Vec, + /// TOUS les Ă©tats marquĂ©s (y compris les Split/assertions traversĂ©s) : + /// indispensable pour les dĂ©marquer au `clear` suivant. Premier bug + /// classique de ce moteur : ne dĂ©marquer que `list`, et les boucles ne + /// sont alors traversables qu'une seule fois. + marked: Vec, + seen: Vec, +} + +impl StateSet { + fn new(n: usize) -> Self { + StateSet { + list: Vec::with_capacity(n), + marked: Vec::with_capacity(n), + seen: vec![false; n], + } + } + fn clear(&mut self) { + for &id in &self.marked { + self.seen[id] = false; + } + self.marked.clear(); + self.list.clear(); + } +} + +impl Nfa { + /// Ajoute `id` **et toute sa fermeture epsilon** Ă  `set`. `at_start` / + /// `at_end` dĂ©crivent la position courante dans le texte : c'est lĂ  que + /// les assertions `^`/`$` sont tranchĂ©es. ItĂ©ratif (pile explicite) pour + /// ĂȘtre insensible Ă  la profondeur du motif. + fn add_closure(&self, id: usize, at_start: bool, at_end: bool, set: &mut StateSet) { + let mut stack = vec![id]; + while let Some(id) = stack.pop() { + if set.seen[id] { + continue; + } + set.seen[id] = true; + set.marked.push(id); + match &self.states[id] { + State::Split { out1, out2 } => { + stack.push(*out1); + stack.push(*out2); + } + State::AssertStart { out } => { + if at_start { + stack.push(*out); + } + } + State::AssertEnd { out } => { + if at_end { + stack.push(*out); + } + } + // Char et Match sont les seuls Ă©tats "stables" de l'ensemble. + State::Char { .. } | State::Match => set.list.push(id), + } + } + } + + /// Un pas de simulation : consomme `ch` (situĂ© Ă  [pos, next_pos) en + /// octets) et remplit `next` avec les Ă©tats atteignables. + fn step(&self, cur: &StateSet, ch: char, next_pos: usize, len: usize, next: &mut StateSet) { + next.clear(); + for &id in &cur.list { + if let State::Char { m, out } = &self.states[id] { + if m.matches(ch) { + self.add_closure(*out, next_pos == 0, next_pos == len, next); + } + } + } + } + + fn has_match(&self, set: &StateSet) -> bool { + set.seen[self.match_id] + } + + /// Recherche **non ancrĂ©e** : « le motif apparaĂźt-il quelque part ? ». + /// + /// L'astuce : on rĂ©injecte l'Ă©tat de dĂ©part Ă  CHAQUE position — c'est + /// l'Ă©quivalent d'un `.*` implicite devant le motif, sans le payer en + /// Ă©tats. Un seul passage sur le texte : O(texte × Ă©tats). + pub(crate) fn is_match(&self, text: &str) -> bool { + let len = text.len(); + let mut cur = StateSet::new(self.states.len()); + let mut next = StateSet::new(self.states.len()); + + self.add_closure(self.start, true, len == 0, &mut cur); + if self.has_match(&cur) { + return true; + } + for (i, ch) in text.char_indices() { + let next_pos = i + ch.len_utf8(); + self.step(&cur, ch, next_pos, len, &mut next); + std::mem::swap(&mut cur, &mut next); + // Nouveau dĂ©part possible Ă  next_pos (recherche non ancrĂ©e). + self.add_closure(self.start, false, next_pos == len, &mut cur); + if self.has_match(&cur) { + return true; + } + } + false + } + + /// Recherche du match **le plus Ă  gauche, le plus long** : pour chaque + /// position de dĂ©part candidate (dans l'ordre), simulation ancrĂ©e qui + /// note la derniĂšre position d'acceptation. Premier dĂ©part gagnant = + /// rĂ©sultat. Offsets en **octets**, `end` exclu — directement + /// utilisables pour trancher `&text[start..end]`. + pub(crate) fn find(&self, text: &str) -> Option<(usize, usize)> { + let len = text.len(); + let starts = text + .char_indices() + .map(|(i, _)| i) + .chain(std::iter::once(len)); + for s in starts { + if let Some(end) = self.match_at(text, s) { + return Some((s, end)); + } + } + None + } + + /// Simulation ancrĂ©e Ă  `from` : renvoie la fin (exclue) du match le plus + /// long commençant exactement Ă  `from`, s'il existe. + fn match_at(&self, text: &str, from: usize) -> Option { + let len = text.len(); + let mut cur = StateSet::new(self.states.len()); + let mut next = StateSet::new(self.states.len()); + + self.add_closure(self.start, from == 0, from == len, &mut cur); + let mut last = if self.has_match(&cur) { + Some(from) + } else { + None + }; + + for (i, ch) in text[from..].char_indices() { + if cur.list.is_empty() { + break; // plus aucun futur possible : inutile de continuer + } + let next_pos = from + i + ch.len_utf8(); + self.step(&cur, ch, next_pos, len, &mut next); + std::mem::swap(&mut cur, &mut next); + if self.has_match(&cur) { + last = Some(next_pos); + } + } + last + } +} + +// --------------------------------------------------------------------------- +// Tests unitaires du compilateur/simulateur +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse; + + fn nfa(pat: &str) -> Nfa { + compile(&parse(pat).unwrap()) + } + + #[test] + fn taille_lineaire_du_nfa() { + // Thompson : ~1 Ă©tat par construction. Un motif de 30 `a*` doit + // rester Ă  ~2 Ă©tats par `a*` (+1 Match), pas exploser. + let pat = "a*".repeat(30); + let n = nfa(&pat); + assert!( + n.states.len() <= 2 * 30 + 1, + "NFA trop gros : {}", + n.states.len() + ); + } + + #[test] + fn boucle_vide_termine() { + // (a*)* : une Ă©toile d'expression pouvant matcher vide crĂ©e un cycle + // d'epsilon — la fermeture doit terminer grĂące aux marques `seen`. + let n = nfa("(a*)*b"); + assert!(n.is_match("aaab")); + assert!(!n.is_match("aaa")); + } + + #[test] + fn plus_exige_au_moins_un() { + let n = nfa("ab+c"); + assert!(!n.is_match("ac")); + assert!(n.is_match("abc")); + assert!(n.is_match("abbbbc")); + } + + #[test] + fn match_at_prend_le_plus_long() { + let n = nfa("a+"); + assert_eq!(n.match_at("aaab", 0), Some(3)); + assert_eq!(n.match_at("aaab", 3), None); + } + + #[test] + fn assertion_fin_dans_la_fermeture() { + let n = nfa("a$"); + assert!(n.is_match("bca")); + assert!(!n.is_match("ab")); + } +} diff --git a/bion-regex/src/parser.rs b/bion-regex/src/parser.rs new file mode 100644 index 0000000..f8d8c38 --- /dev/null +++ b/bion-regex/src/parser.rs @@ -0,0 +1,449 @@ +//! Étape 1 du pipeline : **motif texte → AST**. +//! +//! Analyseur syntaxique par **descente rĂ©cursive**, une fonction par niveau +//! de prioritĂ© de la grammaire (du plus faible au plus fort) : +//! +//! ```text +//! alternance := concat ( '|' concat )* — prioritĂ© la plus faible +//! concat := rĂ©pĂ©tition* +//! rĂ©pĂ©tition := atome ( '*' | '+' | '?' )* +//! atome := '(' alternance ')' — groupe (prioritĂ© max) +//! | '[' classe ']' — classe de caractĂšres +//! | '.' | '^' | '$' +//! | '\' Ă©chappement +//! | caractĂšre littĂ©ral +//! ``` +//! +//! La descente rĂ©cursive rend la prioritĂ© des opĂ©rateurs *structurelle* : +//! `ab|cd*` se parse naturellement en `(ab)|(c(d*))` parce que `parse_alt` +//! appelle `parse_concat` qui appelle `parse_repeat` — chaque niveau « voit » +//! d'abord ce qui le lie le plus fort. Les groupes `(
)` ne servent ici qu'Ă  +//! forcer la prioritĂ© : sans capture dans l'API, ils sont transparents dans +//! l'AST (c'est un choix assumĂ©, voir le README). + +use crate::Error; + +// --------------------------------------------------------------------------- +// Reconnaisseur de caractĂšre +// --------------------------------------------------------------------------- + +/// Ce qu'une transition du NFA *consomme* : la question « ce caractĂšre du +/// texte convient-il ? ». LittĂ©ral, joker `.` ou classe — c'est TOUT ce que +/// le moteur sait tester, et c'est suffisant pour tout le langage supportĂ© +/// (`\d`, `\w`, `\s` ne sont que des classes prĂ©-remplies). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Matcher { + /// Un caractĂšre prĂ©cis (Unicode, pas seulement ASCII). + Char(char), + /// Le joker `.` — tout caractĂšre **sauf** `\n` (convention classique : + /// `.` ne traverse pas les lignes). + Any, + /// Une classe `[
]` : liste d'Ă©lĂ©ments, Ă©ventuellement niĂ©e (`[^
]`). + Class { + negated: bool, + items: Vec, + }, +} + +/// Un Ă©lĂ©ment d'une classe : un caractĂšre seul ou un intervalle `a-z`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ClassItem { + Ch(char), + Range(char, char), +} + +impl Matcher { + /// Le test central du moteur : `c` est-il acceptĂ© ? + /// Pour une classe niĂ©e, on inverse simplement le verdict (XOR). + pub(crate) fn matches(&self, c: char) -> bool { + match self { + Matcher::Char(x) => *x == c, + Matcher::Any => c != '\n', + Matcher::Class { negated, items } => { + let hit = items.iter().any(|it| match it { + ClassItem::Ch(x) => *x == c, + ClassItem::Range(lo, hi) => *lo <= c && c <= *hi, + }); + hit != *negated + } + } + } +} + +/// `\d` = `[0-9]`. +fn class_digit() -> Vec { + vec![ClassItem::Range('0', '9')] +} +/// `\w` = `[a-zA-Z0-9_]`. +fn class_word() -> Vec { + vec![ + ClassItem::Range('a', 'z'), + ClassItem::Range('A', 'Z'), + ClassItem::Range('0', '9'), + ClassItem::Ch('_'), + ] +} +/// `\s` = les blancs usuels (espace, tab, sauts de ligne, etc.). +fn class_space() -> Vec { + vec![ + ClassItem::Ch(' '), + ClassItem::Ch('\t'), + ClassItem::Ch('\n'), + ClassItem::Ch('\r'), + ClassItem::Ch('\u{000B}'), // tab vertical + ClassItem::Ch('\u{000C}'), // form feed + ] +} + +// --------------------------------------------------------------------------- +// AST +// --------------------------------------------------------------------------- + +/// L'arbre de syntaxe abstraite du motif. Volontairement minuscule : sept +/// constructions suffisent Ă  couvrir tout le langage supportĂ©, et chacune +/// se traduit en un fragment de NFA d'une poignĂ©e d'Ă©tats (voir `nfa.rs`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Ast { + /// Le motif vide (`""`, ou une branche vide de `a|`) : accepte la chaĂźne vide. + Empty, + /// Un reconnaisseur de caractĂšre (littĂ©ral, `.`, classe, `\d`
). + Char(Matcher), + /// ConcatĂ©nation `ab` : d'abord le gauche, puis le droit. + Concat(Box, Box), + /// Alternance `a|b` : le gauche OU le droit. + Alt(Box, Box), + /// `a*` : zĂ©ro ou plusieurs fois. + Star(Box), + /// `a+` : une ou plusieurs fois. + Plus(Box), + /// `a?` : zĂ©ro ou une fois. + Quest(Box), + /// `^` : assertion « on est au dĂ©but du texte » (ne consomme rien). + AnchorStart, + /// `$` : assertion « on est Ă  la fin du texte » (ne consomme rien). + AnchorEnd, +} + +// --------------------------------------------------------------------------- +// Parseur +// --------------------------------------------------------------------------- + +/// Point d'entrĂ©e : parse `pattern` en entier ou explique pourquoi c'est +/// impossible. Les positions des erreurs comptent en **caractĂšres** (pas en +/// octets), 0-basĂ© — plus lisible pour l'humain qui a tapĂ© le motif. +pub(crate) fn parse(pattern: &str) -> Result { + let mut p = Parser { + chars: pattern.chars().collect(), + pos: 0, + }; + let ast = p.parse_alt()?; + // S'il reste quelque chose, c'est forcĂ©ment une ')' orpheline : + // tout le reste aurait Ă©tĂ© consommĂ© par parse_concat. + if let Some(c) = p.peek() { + debug_assert_eq!(c, ')'); + return Err(Error::UnbalancedParen { pos: p.pos }); + } + Ok(ast) +} + +struct Parser { + chars: Vec, + pos: usize, +} + +impl Parser { + fn peek(&self) -> Option { + self.chars.get(self.pos).copied() + } + fn peek2(&self) -> Option { + self.chars.get(self.pos + 1).copied() + } + fn bump(&mut self) -> Option { + let c = self.peek(); + if c.is_some() { + self.pos += 1; + } + c + } + + /// alternance := concat ('|' concat)* + fn parse_alt(&mut self) -> Result { + let mut node = self.parse_concat()?; + while self.peek() == Some('|') { + self.bump(); + let rhs = self.parse_concat()?; + node = Ast::Alt(Box::new(node), Box::new(rhs)); + } + Ok(node) + } + + /// concat := rĂ©pĂ©tition* — s'arrĂȘte sur `|`, `)` ou la fin. + fn parse_concat(&mut self) -> Result { + let mut node: Option = None; + loop { + match self.peek() { + None | Some('|') | Some(')') => break, + _ => {} + } + let (atom, quantifiable) = self.parse_atom()?; + let atom = self.parse_postfix(atom, quantifiable)?; + node = Some(match node { + None => atom, + Some(n) => Ast::Concat(Box::new(n), Box::new(atom)), + }); + } + // Aucune brique ? C'est le motif vide (permis : `a|` ou `()`). + Ok(node.unwrap_or(Ast::Empty)) + } + + /// rĂ©pĂ©tition := atome ('*'|'+'|'?')* — on autorise l'empilement + /// (`a*?`, `a**`) : avec un NFA c'est bien dĂ©fini et inoffensif. + fn parse_postfix(&mut self, mut node: Ast, quantifiable: bool) -> Result { + while let Some(q @ ('*' | '+' | '?')) = self.peek() { + if !quantifiable { + return Err(Error::QuantifierOnAnchor { pos: self.pos }); + } + self.bump(); + node = match q { + '*' => Ast::Star(Box::new(node)), + '+' => Ast::Plus(Box::new(node)), + _ => Ast::Quest(Box::new(node)), + }; + } + Ok(node) + } + + /// atome — retourne aussi « peut-on le quantifier ? » (non pour `^`/`$` : + /// `^*` n'a pas de sens, on prĂ©fĂšre une erreur nette Ă  un comportement + /// surprenant). + fn parse_atom(&mut self) -> Result<(Ast, bool), Error> { + let start = self.pos; + let c = self.bump().expect("parse_concat garantit un caractĂšre"); + match c { + '(' => { + let inner = self.parse_alt()?; + if self.bump() != Some(')') { + return Err(Error::UnbalancedParen { pos: start }); + } + // Groupe transparent : il n'a servi qu'Ă  la prioritĂ©. + Ok((inner, true)) + } + '[' => Ok((Ast::Char(self.parse_class(start)?), true)), + '.' => Ok((Ast::Char(Matcher::Any), true)), + '^' => Ok((Ast::AnchorStart, false)), + '$' => Ok((Ast::AnchorEnd, false)), + '*' | '+' | '?' => Err(Error::DanglingQuantifier { pos: start }), + '\\' => Ok((Ast::Char(self.parse_escape()?), true)), + lit => Ok((Ast::Char(Matcher::Char(lit)), true)), + } + } + + /// Échappement hors classe : `\d \D \w \W \s \S`, contrĂŽles `\n \t \r`, + /// et tout mĂ©tacaractĂšre rendu littĂ©ral (`\.` `\*` `\(`
). + fn parse_escape(&mut self) -> Result { + let at = self.pos; + let c = self.bump().ok_or(Error::TrailingBackslash)?; + let m = match c { + 'd' => Matcher::Class { + negated: false, + items: class_digit(), + }, + 'D' => Matcher::Class { + negated: true, + items: class_digit(), + }, + 'w' => Matcher::Class { + negated: false, + items: class_word(), + }, + 'W' => Matcher::Class { + negated: true, + items: class_word(), + }, + 's' => Matcher::Class { + negated: false, + items: class_space(), + }, + 'S' => Matcher::Class { + negated: true, + items: class_space(), + }, + 'n' => Matcher::Char('\n'), + 't' => Matcher::Char('\t'), + 'r' => Matcher::Char('\r'), + '.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' + | '-' | '/' => Matcher::Char(c), + other => return Err(Error::UnknownEscape { c: other, pos: at }), + }; + Ok(m) + } + + /// classe := '[' '^'? Ă©lĂ©ment+ ']' + /// + /// SubtilitĂ©s classiques gĂ©rĂ©es : + /// - `]` en **premiĂšre** position est littĂ©ral : `[]a]` = « `]` ou `a` » ; + /// - `-` en dĂ©but ou fin de classe est littĂ©ral : `[a-]` = « `a` ou `-` » ; + /// - `\d \w \s` se dĂ©veloppent en leurs Ă©lĂ©ments ; `\n \t \r \] \\ \- \^` + /// sont des littĂ©raux ; + /// - `[z-a]` (borne basse > haute) est une erreur, pas un piĂšge silencieux. + fn parse_class(&mut self, open_pos: usize) -> Result { + let negated = if self.peek() == Some('^') { + self.bump(); + true + } else { + false + }; + let mut items: Vec = Vec::new(); + let mut first = true; + loop { + let at = self.pos; + let c = self.bump().ok_or(Error::UnclosedClass { pos: open_pos })?; + if c == ']' && !first { + break; + } + first = false; + // DĂ©part d'Ă©lĂ©ment : simple caractĂšre, ou Ă©chappement. + let lo: ClassEsc = if c == '\\' { + self.parse_class_escape()? + } else { + ClassEsc::One(c) + }; + match lo { + ClassEsc::Many(mut set) => items.append(&mut set), + ClassEsc::One(lo_c) => { + // Intervalle ? Seulement si `-` n'est pas collĂ© Ă  `]`. + if self.peek() == Some('-') + && self.peek2().is_some() + && self.peek2() != Some(']') + { + self.bump(); // le '-' + let hc = self.bump().ok_or(Error::UnclosedClass { pos: open_pos })?; + let hi_c = if hc == '\\' { + match self.parse_class_escape()? { + ClassEsc::One(x) => x, + // `[a-\d]` : un ensemble comme borne haute + // n'a pas de sens. + ClassEsc::Many(_) => { + return Err(Error::InvalidClassRange { pos: at }) + } + } + } else { + hc + }; + if lo_c > hi_c { + return Err(Error::InvalidClassRange { pos: at }); + } + items.push(ClassItem::Range(lo_c, hi_c)); + } else { + items.push(ClassItem::Ch(lo_c)); + } + } + } + } + Ok(Matcher::Class { negated, items }) + } + + /// Échappement **dans** une classe. Les versions niĂ©es (`\D`
) y sont + /// refusĂ©es : une nĂ©gation dans une classe dĂ©jĂ  (peut-ĂȘtre) niĂ©e est un + /// nid Ă  confusion — on prĂ©fĂšre l'interdire proprement. + fn parse_class_escape(&mut self) -> Result { + let at = self.pos; + let c = self.bump().ok_or(Error::TrailingBackslash)?; + let e = match c { + 'd' => ClassEsc::Many(class_digit()), + 'w' => ClassEsc::Many(class_word()), + 's' => ClassEsc::Many(class_space()), + 'n' => ClassEsc::One('\n'), + 't' => ClassEsc::One('\t'), + 'r' => ClassEsc::One('\r'), + ']' | '\\' | '-' | '^' | '[' => ClassEsc::One(c), + other => return Err(Error::UnknownEscape { c: other, pos: at }), + }; + Ok(e) + } +} + +/// RĂ©sultat d'un Ă©chappement en classe : un caractĂšre, ou un paquet +/// d'Ă©lĂ©ments (`\d` → l'intervalle `0-9`). +enum ClassEsc { + One(char), + Many(Vec), +} + +// --------------------------------------------------------------------------- +// Tests unitaires du parseur +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn priorite_alternance_concat_etoile() { + // ab|cd* doit se lire (ab)|(c(d*)) + let ast = parse("ab|cd*").unwrap(); + match ast { + Ast::Alt(l, r) => { + assert!(matches!(*l, Ast::Concat(_, _))); + match *r { + Ast::Concat(_, d) => assert!(matches!(*d, Ast::Star(_))), + other => panic!("attendu Concat, obtenu {other:?}"), + } + } + other => panic!("attendu Alt, obtenu {other:?}"), + } + } + + #[test] + fn groupe_transparent() { + // (a) et a produisent le mĂȘme AST + assert_eq!(parse("(a)").unwrap(), parse("a").unwrap()); + } + + #[test] + fn motif_vide_et_branche_vide() { + assert_eq!(parse("").unwrap(), Ast::Empty); + assert!(matches!(parse("a|").unwrap(), Ast::Alt(_, b) if *b == Ast::Empty)); + } + + #[test] + fn erreurs_de_syntaxe() { + assert_eq!(parse("*a"), Err(Error::DanglingQuantifier { pos: 0 })); + assert_eq!(parse("(ab"), Err(Error::UnbalancedParen { pos: 0 })); + assert_eq!(parse("ab)"), Err(Error::UnbalancedParen { pos: 2 })); + assert_eq!(parse("[a-"), Err(Error::UnclosedClass { pos: 0 })); + assert_eq!(parse("[z-a]"), Err(Error::InvalidClassRange { pos: 1 })); + assert_eq!(parse("\\q"), Err(Error::UnknownEscape { c: 'q', pos: 1 })); + assert_eq!(parse("a\\"), Err(Error::TrailingBackslash)); + assert_eq!(parse("^*"), Err(Error::QuantifierOnAnchor { pos: 1 })); + } + + #[test] + fn classe_crochet_litteral_en_tete() { + // []a] = la classe { ']', 'a' } + let m = match parse("[]a]").unwrap() { + Ast::Char(m) => m, + other => panic!("attendu Char, obtenu {other:?}"), + }; + assert!(m.matches(']')); + assert!(m.matches('a')); + assert!(!m.matches('b')); + } + + #[test] + fn classe_tiret_litteral_en_fin() { + let m = match parse("[a-]").unwrap() { + Ast::Char(m) => m, + other => panic!("attendu Char, obtenu {other:?}"), + }; + assert!(m.matches('a')); + assert!(m.matches('-')); + assert!(!m.matches('b')); + } + + #[test] + fn matcher_point_exclut_newline() { + assert!(Matcher::Any.matches('x')); + assert!(!Matcher::Any.matches('\n')); + } +} diff --git a/bion-regex/tests/regex.rs b/bion-regex/tests/regex.rs new file mode 100644 index 0000000..a796efc --- /dev/null +++ b/bion-regex/tests/regex.rs @@ -0,0 +1,283 @@ +//! Tests d'intĂ©gration de bion-regex : l'API publique, les piĂšges classiques +//! et LA garantie du moteur — le motif pathologique reste instantanĂ©. +//! +//! NOTE : `clippy::invalid_regex` est un faux positif ici — le lint croit +//! reconnaĂźtre le crate `regex` externe Ă  cause du nom `Regex::new`, alors +//! qu'on teste NOTRE moteur, y compris ses motifs volontairement invalides. +#![allow(clippy::invalid_regex)] + +use bion_regex::{Error, Regex}; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Bases : littĂ©raux, ., quantificateurs, alternance +// --------------------------------------------------------------------------- + +#[test] +fn litteraux() { + let re = Regex::new("chat").unwrap(); + assert!(re.is_match("le chat dort")); + assert!(!re.is_match("le chien dort")); + assert_eq!(re.pattern(), "chat"); +} + +#[test] +fn point_joker_sauf_newline() { + let re = Regex::new("a.c").unwrap(); + assert!(re.is_match("abc")); + assert!(re.is_match("aĂ©c")); // Unicode : Ă© = 1 caractĂšre (2 octets) + assert!(!re.is_match("a\nc")); // `.` ne traverse pas les lignes + assert!(!re.is_match("ac")); +} + +#[test] +fn quantificateurs_star_plus_quest() { + let star = Regex::new("ab*c").unwrap(); + assert!(star.is_match("ac")); + assert!(star.is_match("abbbc")); + + let plus = Regex::new("ab+c").unwrap(); + assert!(!plus.is_match("ac")); + assert!(plus.is_match("abc")); + + let quest = Regex::new("ab?c").unwrap(); + assert!(quest.is_match("ac")); + assert!(quest.is_match("abc")); + assert!(!quest.is_match("abbc")); +} + +#[test] +fn alternance() { + let re = Regex::new("chat|chien|oiseau").unwrap(); + assert!(re.is_match("un chien")); + assert!(re.is_match("un oiseau")); + assert!(!re.is_match("un poisson")); +} + +// --------------------------------------------------------------------------- +// Groupes (y compris imbriquĂ©s) +// --------------------------------------------------------------------------- + +#[test] +fn groupes_imbriques() { + // ((ab)+c)|d — groupes dans groupes, quantifiĂ©s + let re = Regex::new("((ab)+c)|d").unwrap(); + assert!(re.is_match("abc")); + assert!(re.is_match("abababc")); + assert!(re.is_match("d")); + assert!(!re.is_match("ac")); + assert!(!re.is_match("ab")); +} + +#[test] +fn groupe_quantifie_et_alternance_interne() { + let re = Regex::new("^(ab|cd)*$").unwrap(); + assert!(re.is_match("")); + assert!(re.is_match("abcdab")); + assert!(!re.is_match("abc")); +} + +// --------------------------------------------------------------------------- +// Classes de caractĂšres +// --------------------------------------------------------------------------- + +#[test] +fn classes_simples_et_intervalles() { + let re = Regex::new("[a-f0-9]+").unwrap(); + assert!(re.is_match("deadbeef42")); + assert_eq!(re.find("xyz a3f xyz"), Some((4, 7))); + assert!(!re.is_match("xyz")); +} + +#[test] +fn classe_negative() { + let re = Regex::new("[^0-9 ]+").unwrap(); + assert_eq!(re.find("12 abc 34"), Some((3, 6))); // « abc » + let strict = Regex::new("^[^abc]+$").unwrap(); + assert!(strict.is_match("xyz")); + assert!(!strict.is_match("xbz")); // contient un 'b' +} + +#[test] +fn echappements_d_w_s() { + let re = Regex::new(r"\d+\s\w+").unwrap(); + assert!(re.is_match("il y a 42 bions")); + assert!(!re.is_match("quarante-deux bions")); + + let nd = Regex::new(r"^\D+$").unwrap(); + assert!(nd.is_match("abc!")); + assert!(!nd.is_match("ab3c")); +} + +#[test] +fn metacaracteres_echappes() { + let re = Regex::new(r"^\(\d+\.\d+\)$").unwrap(); + assert!(re.is_match("(3.14)")); + assert!(!re.is_match("(3x14)")); // le \. est bien littĂ©ral + assert!(!re.is_match("3.14")); +} + +// --------------------------------------------------------------------------- +// Ancres +// --------------------------------------------------------------------------- + +#[test] +fn ancres_debut_fin() { + let deb = Regex::new("^abc").unwrap(); + assert!(deb.is_match("abcdef")); + assert!(!deb.is_match("xabc")); + + let fin = Regex::new("abc$").unwrap(); + assert!(fin.is_match("xxabc")); + assert!(!fin.is_match("abcx")); + + let exact = Regex::new("^abc$").unwrap(); + assert!(exact.is_match("abc")); + assert!(!exact.is_match("aabc")); + assert!(!exact.is_match("abcc")); +} + +#[test] +fn ancres_dans_les_branches() { + // L'ancre est une assertion d'Ă©tat, pas un simple prĂ©fixe : + // elle marche aussi au milieu d'une alternance. + let re = Regex::new("^dĂ©but|fin$").unwrap(); + assert!(re.is_match("dĂ©but de texte")); + assert!(re.is_match("texte fin")); + assert!(!re.is_match("la fin arrive")); // « fin » pas en fin de texte + assert!(!re.is_match("le dĂ©but")); // « dĂ©but » pas en dĂ©but de texte +} + +// --------------------------------------------------------------------------- +// find : positions, leftmost-longest, matchs vides, Unicode +// --------------------------------------------------------------------------- + +#[test] +fn find_leftmost_longest() { + let re = Regex::new("a+").unwrap(); + assert_eq!(re.find("baaa"), Some((1, 4))); // le plus long Ă  la 1re position + assert_eq!(re.find("bbb"), None); + + // Leftmost prime sur longest : le match Ă  0 gagne mĂȘme s'il est court. + let re = Regex::new("ab?").unwrap(); + assert_eq!(re.find("acabb"), Some((0, 1))); +} + +#[test] +fn find_match_vide() { + // b* reconnaĂźt la chaĂźne vide : match vide Ă  la position 0. + let re = Regex::new("b*").unwrap(); + assert_eq!(re.find("aaab"), Some((0, 0))); + assert_eq!(re.find(""), Some((0, 0))); +} + +#[test] +fn find_offsets_octets_unicode() { + // Les offsets sont en OCTETS : « Ă© » en occupe deux. + let re = Regex::new("chĂš+vre").unwrap(); + let texte = "la chÚÚÚvre"; + let (s, e) = re.find(texte).unwrap(); + assert_eq!(&texte[s..e], "chÚÚÚvre"); + assert_eq!(s, 3); +} + +#[test] +fn texte_vide_et_motif_vide() { + let vide = Regex::new("").unwrap(); + assert!(vide.is_match("")); + assert!(vide.is_match("abc")); + assert_eq!(vide.find("abc"), Some((0, 0))); + + let re = Regex::new("a").unwrap(); + assert!(!re.is_match("")); + assert_eq!(re.find(""), None); +} + +// --------------------------------------------------------------------------- +// Erreurs de syntaxe propres +// --------------------------------------------------------------------------- + +#[test] +fn erreurs_de_syntaxe_propres() { + assert_eq!( + Regex::new("*a").unwrap_err(), + Error::DanglingQuantifier { pos: 0 } + ); + assert_eq!( + Regex::new("(ab").unwrap_err(), + Error::UnbalancedParen { pos: 0 } + ); + assert_eq!( + Regex::new("ab)").unwrap_err(), + Error::UnbalancedParen { pos: 2 } + ); + assert_eq!( + Regex::new("[abc").unwrap_err(), + Error::UnclosedClass { pos: 0 } + ); + assert_eq!( + Regex::new("[z-a]").unwrap_err(), + Error::InvalidClassRange { pos: 1 } + ); + assert_eq!( + Regex::new(r"\q").unwrap_err(), + Error::UnknownEscape { c: 'q', pos: 1 } + ); + assert_eq!(Regex::new("ab\\").unwrap_err(), Error::TrailingBackslash); + assert_eq!( + Regex::new("^*").unwrap_err(), + Error::QuantifierOnAnchor { pos: 1 } + ); + // Les messages Display sont en français et localisĂ©s : + let msg = Regex::new("(ab").unwrap_err().to_string(); + assert!(msg.contains("parenthĂšse"), "message inattendu : {msg}"); +} + +// --------------------------------------------------------------------------- +// LE test du crate : pas d'explosion exponentielle +// --------------------------------------------------------------------------- + +#[test] +fn pathologique_a_star_reste_instantane() { + // « a*a*a*
a*b » sur 30 'a' sans 'b' : un backtracker doit essayer + // ~2^30 dĂ©coupages avant d'Ă©chouer. Le NFA de Thompson simulĂ© par + // ensembles d'Ă©tats rĂ©pond en O(texte × motif) — microsecondes. + let pattern = format!("{}b", "a*".repeat(30)); + let text = "a".repeat(30); + + let re = Regex::new(&pattern).unwrap(); + let debut = Instant::now(); + assert!(!re.is_match(&text)); + assert_eq!(re.find(&text), None); + let duree = debut.elapsed(); + assert!( + duree < Duration::from_secs(1), + "pathologique trop lent : {duree:?} (devrait ĂȘtre ~”s)" + ); +} + +#[test] +fn pathologique_a_quest_n_a_n() { + // L'autre classique de l'article de Russ Cox : a?ⁿaⁿ sur « aⁿ ». + // Ici le match EXISTE (tous les a? prennent vide) — un backtracker + // le trouve vite dans ce sens, mais a?ⁿaⁿ sur aⁿ⁻Âč (Ă©chec) le tue. + let n = 25; + let pattern = format!("^{}{}$", "a?".repeat(n), "a".repeat(n)); + let re = Regex::new(&pattern).unwrap(); + + let debut = Instant::now(); + assert!(re.is_match(&"a".repeat(n))); // succĂšs + assert!(!re.is_match(&"a".repeat(n - 1))); // Ă©chec = le cas qui explose ailleurs + let duree = debut.elapsed(); + assert!(duree < Duration::from_secs(1), "trop lent : {duree:?}"); +} + +#[test] +fn boucles_vides_imbriquees() { + // (a*)* et ((a*)*)* : cycles d'epsilon-transitions — la simulation doit + // terminer (dĂ©duplication des Ă©tats) et donner le bon rĂ©sultat. + let re = Regex::new("^((a*)*)*b$").unwrap(); + assert!(re.is_match("b")); + assert!(re.is_match("aaaab")); + assert!(!re.is_match("aaaa")); +} diff --git a/bion-triplet/Cargo.toml b/bion-triplet/Cargo.toml new file mode 100644 index 0000000..4e12a75 --- /dev/null +++ b/bion-triplet/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "bion-triplet" +version = "0.1.0" +edition = "2021" +description = "Le triplet d'Adressage GĂ©nĂ©ratif : (hash_gĂ©nĂ©rateur, coordonnĂ©es, hash_rĂ©sidu). Une donnĂ©e = une adresse dans un espace gĂ©nĂ©ratif." +license = "AGPL-3.0" + +[dependencies] +# EXCEPTION std-only, explicitement autorisĂ©e : on n'implĂ©mente pas BLAKE3 +# from scratch — le bion est la LOGIQUE du triplet, pas la fonction de hachage. +blake3 = { version = "1", default-features = false } diff --git a/bion-triplet/README.md b/bion-triplet/README.md new file mode 100644 index 0000000..f4940e3 --- /dev/null +++ b/bion-triplet/README.md @@ -0,0 +1,106 @@ +# bion-triplet + +Le **triplet d'Adressage GĂ©nĂ©ratif** en Rust `std`-only : la donnĂ©e comme pure adresse. + +``` +triplet = ( hash_gĂ©nĂ©rateur , coordonnĂ©es , hash_rĂ©sidu ) + └─ BLAKE3, 32 o ─┘ └─ opaque ──┘ └─ BLAKE3, 32 o ─┘ +``` + +## Quoi + +Ce crate fige la **logique** de la spec `adressage-generatif-triplet-v0` +(my_website2, `resources/papers/adressage-generatif-triplet-v0.md`) : les trois +champs du triplet et les trois opĂ©rations dessus. + +- **ÉCRIRE** — `encode(gĂ©nĂ©rateur, data, coords)` : calcule + `rĂ©sidu = data ⊖ G(coords)` et retourne `(Triplet, rĂ©sidu)`. On ne garde que + l'Ă©cart au prĂ©dictible ; si la donnĂ©e est exactement la prĂ©diction, le rĂ©sidu + est **vide** (le cas « dĂ©jĂ  su » : la pure structure ne coĂ»te rien). +- **LIRE** — `decode(gĂ©nĂ©rateur, triplet, rĂ©sidu)` : vĂ©rifie les **deux** hash + (gĂ©nĂ©rateur ET rĂ©sidu), puis `data = G(coords) ⊕ rĂ©sidu`. Bit-exact, sur + toute machine, Ă  toute Ă©poque. +- **ALIGNEMENT** — `align(a, b)` : Ă©galitĂ© des `gen_hash`, dĂ©cidable au bit. + Deux nƓuds alignĂ©s ne s'Ă©changent que `(coordonnĂ©es, rĂ©sidu)` — des adresses, + pas des blobs. + +GĂ©nĂ©rateurs d'exemple fournis : `ConstGenerator` (octets constants), +`PatternGenerator` (motif rĂ©pĂ©tĂ© + offset), `IdentityGenerator` (le plancher : +rĂ©sidu = donnĂ©e entiĂšre, le cas dĂ©gĂ©nĂ©rĂ© posĂ© par la spec). + +## Pourquoi + +Une donnĂ©e n'est pas un contenu qu'on garde, c'est une adresse dans un espace +gĂ©nĂ©ratif. Le triplet est le « connecteur logiciel » Ă©ternel de XERB0XI0N : +position, DM et rĂ©plication sont la mĂȘme opĂ©ration Ă  trois Ă©chelles — ce qui +voyage n'est jamais l'instant, c'est **l'Ă©cart entre l'instant et ce que +l'autre bout savait dĂ©jĂ  en prĂ©dire**. Ce bion se build une fois ; ensuite on +n'optimise que les gĂ©nĂ©rateurs au-dessus, jamais le connecteur. + +Il **complĂšte** `tsoin-codec` (xerboxion-rt) sans le dupliquer : `tsoin-codec` +est un *compresseur* (codage arithmĂ©tique, une maniĂšre maline de fabriquer un +petit rĂ©sidu) ; `bion-triplet` est le *format d'adresse* qui cite un gĂ©nĂ©rateur +et un rĂ©sidu par hash. Le codec se branche au-dessus, comme n'importe quel `⊖` +de domaine. + +## Exemple + +```rust +use bion_triplet::{encode, decode, align, ConstGenerator}; + +let g = ConstGenerator::new(0x2A); +let data = [0x2A, 0x2A, 0xFF, 0x2A]; +let (triplet, residu) = encode(&g, &data, ConstGenerator::coords(4)); +assert_eq!(residu, vec![0, 0, 0x2A ^ 0xFF, 0]); // seule la surprise pĂšse +assert_eq!(decode(&g, &triplet, &residu).unwrap(), data); + +let (autre, _) = encode(&g, b"****", ConstGenerator::coords(4)); +assert!(align(&triplet, &autre)); // mĂȘme gĂ©nĂ©rateur, dĂ©cidable au bit +``` + +## Choix de design (documentĂ©s) + +- **`⊖` = XOR Ă  longueur portĂ©e par le rĂ©sidu.** Sans ambiguĂŻtĂ© : rĂ©sidu vide + ⇔ « exactement la prĂ©diction » ; sinon `rĂ©sidu.len() == data.len()` et la + prĂ©diction est complĂ©tĂ©e par des zĂ©ros si elle est plus courte. HonnĂȘte face + au mur de Kolmogorov : un mauvais gĂ©nĂ©rateur donne un rĂ©sidu au poids plein, + jamais un mensonge. +- **DĂ©coder vĂ©rifie avant de calculer.** Mauvais gĂ©nĂ©rateur ⇒ + `GeneratorMismatch` ; rĂ©sidu corrompu ⇒ `ResidualMismatch`. Jamais une donnĂ©e + silencieusement fausse. +- **IdentitĂ© d'un gĂ©nĂ©rateur = hash d'une description canonique versionnĂ©e** + (`bion-triplet:v0:const:
`), jamais un Ă©tat interne. Changer la sĂ©mantique = + nouveau nom = nouveau hash : rĂ©trocompatibilitĂ© Ă©ternelle. +- **Un gĂ©nĂ©rateur ne panique jamais** sur des coordonnĂ©es malformĂ©es : il + prĂ©dit « rien » et le rĂ©sidu porte tout le rĂ©el. +- **Exception dĂ©pendance** : `blake3` (default-features off). On n'implĂ©mente + pas une fonction de hachage cryptographique from scratch — le bion, c'est la + logique du triplet. + +## Liens build-your-own-x + +Dans l'esprit de [build-your-own-x](https://github.com/codecrafters-io/build-your-own-x) : + +- *Build your own Git* — le store adressĂ© par contenu (`C1`) que le triplet + cite est exactement le modĂšle objet de Git (hash → blob). +- *Build your own Docker / virtual machine* — le gĂ©nĂ©rateur comme programme pur + sandboxĂ© (WASM `C0`) rejouable partout. +- Compression / codage arithmĂ©tique (voir `tsoin-codec` du core) — le `⊖` + malin qui fabrique de petits rĂ©sidus. + +## Comment l'optimiser (l'invitation au fork) + +Le triplet ne change plus ; **tout le gain est au-dessus**. Pistes : + +- un `⊖` de domaine plus malin que le XOR (diff structurĂ©, delta d'images, + codage arithmĂ©tique via `tsoin-codec`) — mĂȘme API, rĂ©sidu plus petit ; +- des gĂ©nĂ©rateurs rĂ©els : modĂšle de mouvement GPS, contexte de DM, index + Hilbert 4D du Cubion — tous bit-exacts (entiers seulement) ; +- une sĂ©rialisation binaire du `Triplet` (72 octets + coords) pour le manifeste + de rĂ©plication diff-only (*XI0N-rĂ©sidu*) ; +- un panel de gĂ©nĂ©rateurs nommĂ©s + la grandeur honnĂȘte + `certitude = 1 − L_min / L_baseline` (spec §3.2) ; +- brancher le chiffrement GPG du rĂ©sidu (toujours, au repos et en transit) — + hors de ce crate, dans la couche rĂ©seau. + +Licence AGPL-3.0 · spec gelĂ©e `adressage-generatif-triplet-v0` · *Ne pas nuire.* diff --git a/bion-triplet/src/lib.rs b/bion-triplet/src/lib.rs new file mode 100644 index 0000000..8587882 --- /dev/null +++ b/bion-triplet/src/lib.rs @@ -0,0 +1,542 @@ +//! `bion-triplet` — le triplet d'Adressage GĂ©nĂ©ratif. +//! +//! # Le cours en trois phrases +//! +//! Une donnĂ©e n'est pas un contenu qu'on garde, c'est une **adresse** dans un espace +//! gĂ©nĂ©ratif : un point qu'on retrouve. L'adresse est un **triplet** +//! `(hash_gĂ©nĂ©rateur, coordonnĂ©es, hash_rĂ©sidu)` : le gĂ©nĂ©rateur est un programme pur +//! partagĂ© par tous (un bien commun), les coordonnĂ©es localisent la donnĂ©e *dans* ce +//! gĂ©nĂ©rateur, et le rĂ©sidu est **l'Ă©cart au bit prĂšs** entre ce que le gĂ©nĂ©rateur +//! prĂ©dit et ce que le rĂ©el a fait. Seul le rĂ©sidu exige du stockage : Ă©crire, c'est +//! ne conserver que la surprise. +//! +//! Spec de rĂ©fĂ©rence : `adressage-generatif-triplet-v0` (my_website2, +//! `resources/papers/adressage-generatif-triplet-v0.md`). Ce crate en fige la +//! **logique** — les trois champs et les trois opĂ©rations — en Rust `std`-only +//! (seule exception : le crate `blake3`, car on ne rĂ©implĂ©mente pas une fonction +//! de hachage cryptographique ; le bion, c'est le triplet, pas le hash). +//! +//! # Les trois opĂ©rations +//! +//! - **ÉCRIRE** — [`encode`] : choisir un gĂ©nĂ©rateur `G`, calculer +//! `rĂ©sidu = donnĂ©e ⊖ G(coordonnĂ©es)`, ne garder que le triplet + le rĂ©sidu. +//! - **LIRE** — [`decode`] : `donnĂ©e = G(coordonnĂ©es) ⊕ rĂ©sidu`, aprĂšs vĂ©rification +//! des **deux** hash (gĂ©nĂ©rateur ET rĂ©sidu). DĂ©terministe, bit pour bit. +//! - **ALIGNEMENT** — [`align`] : deux triplets partagent un gĂ©nĂ©rateur ssi leurs +//! `gen_hash` sont Ă©gaux. Une Ă©galitĂ© de hash, **dĂ©cidable au bit** — pas une +//! synchronisation floue. +//! +//! # Le choix de `⊖` / `⊕` (documentĂ©, cas dĂ©gĂ©nĂ©rĂ© de la spec) +//! +//! La spec laisse l'opĂ©rateur de recombinaison au domaine ; ce crate implĂ©mente le +//! cas dĂ©gĂ©nĂ©rĂ© **XOR Ă  longueur portĂ©e par le rĂ©sidu**, avec un raffinement : +//! +//! - si `donnĂ©e == prĂ©diction` octet pour octet, le rĂ©sidu est **vide** (`[]`). +//! C'est le cas « dĂ©jĂ  su » : la pure structure, zĂ©ro stockage (spec §1, +//! « rĂ©sidu = vide ⇒ pure structure ») ; +//! - sinon `rĂ©sidu.len() == donnĂ©e.len()` et `rĂ©sidu[i] = donnĂ©e[i] XOR pred[i]`, +//! oĂč la prĂ©diction est **complĂ©tĂ©e par des zĂ©ros** si elle est plus courte que la +//! donnĂ©e (au-delĂ  de la prĂ©diction, le rĂ©sidu porte la donnĂ©e telle quelle — +//! c'est le cas dĂ©gĂ©nĂ©rĂ© « gĂ©nĂ©rateur = identitĂ© ⇒ rĂ©sidu = la donnĂ©e entiĂšre »). +//! +//! Ce schĂ©ma est **sans ambiguĂŻtĂ©** : un rĂ©sidu vide signifie exactement « la +//! prĂ©diction entiĂšre », un rĂ©sidu non vide fixe la longueur de la donnĂ©e. Il est +//! honnĂȘte vis-Ă -vis du mur de Kolmogorov : un mauvais gĂ©nĂ©rateur donne un rĂ©sidu +//! aussi lourd que la donnĂ©e, jamais plus lĂ©ger que la surprise rĂ©elle. Un `⊖` plus +//! malin (diff structurĂ©, codage arithmĂ©tique — voir `tsoin-codec` dans +//! xerboxion-rt) se branche *au-dessus*, en compressant le rĂ©sidu ; le triplet, lui, +//! ne change pas : c'est le connecteur Ă©ternel. +//! +//! # Exemple +//! +//! ``` +//! use bion_triplet::{encode, decode, align, ConstGenerator, Generator}; +//! +//! // Un gĂ©nĂ©rateur commun : « que des octets 0x2A ». +//! let g = ConstGenerator::new(0x2A); +//! // Les coordonnĂ©es (opaques pour le triplet) : ici, la longueur attendue. +//! let coords = ConstGenerator::coords(4); +//! +//! // ÉCRIRE : la donnĂ©e diffĂšre de la prĂ©diction sur un seul octet. +//! let data = [0x2A, 0x2A, 0xFF, 0x2A]; +//! let (triplet, residu) = encode(&g, &data, coords.clone()); +//! assert_eq!(residu, vec![0, 0, 0x2A ^ 0xFF, 0]); // seule la surprise pĂšse +//! +//! // LIRE : rĂ©gĂ©nĂ©rer + appliquer l'Ă©cart, hash vĂ©rifiĂ©s. +//! let lu = decode(&g, &triplet, &residu).unwrap(); +//! assert_eq!(lu, data); +//! +//! // ALIGNEMENT : mĂȘme gĂ©nĂ©rateur ⇒ on ne diffe que des adresses. +//! let (autre, _) = encode(&g, b"****", coords); +//! assert!(align(&triplet, &autre)); +//! ``` + +/// Taille d'un hash BLAKE3, en octets. GelĂ©e par la spec v0 (« BLAKE3, 32 o »). +pub const HASH_LEN: usize = 32; + +/// Un hash BLAKE3 de 32 octets — le seul type d'identitĂ© du format Ă©ternel. +pub type Hash = [u8; HASH_LEN]; + +/// LE TRIPLET — la donnĂ©e comme pure adresse (spec §1, `[POSÉ]`). +/// +/// Trois champs, tous de taille bornĂ©e (les coordonnĂ©es sont opaques mais +/// minuscules par construction : une par donnĂ©e). Le triplet ne contient +/// **aucun octet de contenu**, seulement des rĂ©fĂ©rences vĂ©rifiables : +/// +/// - [`gen_hash`](Self::gen_hash) — BLAKE3 du gĂ©nĂ©rateur (programme pur, +/// dĂ©terministe). Le gĂ©nĂ©rateur est un bien commun, rĂ©pliquĂ© gratuitement. +/// - [`coords`](Self::coords) — localisent la donnĂ©e *dans* le gĂ©nĂ©rateur +/// (seed, x/y/z/t, index Hilbert, clĂ©, temps
). Le format interne est propre +/// au domaine du gĂ©nĂ©rateur ; le triplet les traite comme opaques. +/// - [`res_hash`](Self::res_hash) — BLAKE3 du rĂ©sidu : l'Ă©cart au bit prĂšs +/// entre prĂ©diction et rĂ©el. La seule part qui exige du stockage — le Tsoin. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Triplet { + /// BLAKE3 du gĂ©nĂ©rateur — l'identitĂ© du « dĂ©jĂ  su » partagĂ©. + pub gen_hash: Hash, + /// CoordonnĂ©es opaques : oĂč, dans le gĂ©nĂ©rateur, vit cette donnĂ©e. + pub coords: Vec, + /// BLAKE3 du rĂ©sidu — l'identitĂ© de la surprise. + pub res_hash: Hash, +} + +/// Un **gĂ©nĂ©rateur** : un programme pur et dĂ©terministe qui, pour des +/// coordonnĂ©es donnĂ©es, prĂ©dit des octets — le « dĂ©jĂ  su » commun aux deux +/// bouts. +/// +/// Contrat (spec §3, garde-fou 4) : `generate` doit ĂȘtre **bit-exact** sur +/// toute machine, Ă  toute Ă©poque — mĂȘme entrĂ©e, mĂȘmes octets, partout. Un +/// gĂ©nĂ©rateur non reproductible (floats, rĂ©seau de neurones non quantifiĂ©) ne +/// peut pas porter un `hash_gĂ©nĂ©rateur` du format Ă©ternel. +/// +/// `hash` doit identifier le gĂ©nĂ©rateur de façon stable et sans collision +/// entre gĂ©nĂ©rateurs diffĂ©rents : hasher une **description canonique** +/// (nom versionnĂ© + paramĂštres), jamais un Ă©tat interne Ă©phĂ©mĂšre. +pub trait Generator { + /// PrĂ©dit les octets aux coordonnĂ©es donnĂ©es. Pur et dĂ©terministe. + fn generate(&self, coords: &[u8]) -> Vec; + + /// BLAKE3 de la description canonique du gĂ©nĂ©rateur. + fn hash(&self) -> Hash; +} + +/// Erreurs de [`decode`] : le triplet est vĂ©rifiable, donc il peut refuser. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TripletError { + /// Le gĂ©nĂ©rateur fourni n'est pas celui que le triplet cite + /// (`hash_gĂ©nĂ©rateur` ≠ `generator.hash()`). L'ALIGNEMENT a Ă©chouĂ© : + /// on ne « devine » jamais avec le mauvais dĂ©jĂ -su. + GeneratorMismatch { + /// Le hash attendu (celui du triplet). + expected: Hash, + /// Le hash du gĂ©nĂ©rateur effectivement fourni. + got: Hash, + }, + /// Le rĂ©sidu fourni n'est pas celui que le triplet cite + /// (`hash_rĂ©sidu` ≠ BLAKE3(rĂ©sidu)) : rĂ©sidu corrompu ou falsifiĂ©. + ResidualMismatch { + /// Le hash attendu (celui du triplet). + expected: Hash, + /// Le hash du rĂ©sidu effectivement fourni. + got: Hash, + }, +} + +impl std::fmt::Display for TripletError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn hex(h: &Hash) -> String { + h.iter().map(|b| format!("{b:02x}")).collect() + } + match self { + TripletError::GeneratorMismatch { expected, got } => write!( + f, + "gĂ©nĂ©rateur non alignĂ© : le triplet cite {}, reçu {}", + hex(expected), + hex(got) + ), + TripletError::ResidualMismatch { expected, got } => write!( + f, + "rĂ©sidu corrompu : le triplet cite {}, reçu {}", + hex(expected), + hex(got) + ), + } + } +} + +impl std::error::Error for TripletError {} + +/// BLAKE3 d'un bloc d'octets — le hash du format Ă©ternel. +pub fn hash_bytes(bytes: &[u8]) -> Hash { + *blake3::hash(bytes).as_bytes() +} + +/// **ÉCRIRE** (spec §2) : encode `data` comme adresse dans `generator`. +/// +/// Calcule `rĂ©sidu = data ⊖ generator.generate(&coords)` (XOR, prĂ©diction +/// complĂ©tĂ©e par des zĂ©ros — voir la doc du crate) et retourne le couple +/// `(Triplet, rĂ©sidu)`. Le triplet est l'adresse (Ă  rĂ©pliquer partout) ; +/// le rĂ©sidu est la seule part Ă  stocker — et, dans la flotte, la seule Ă  +/// chiffrer GPG (spec §3, garde-fou 5 ; le chiffrement vit hors de ce crate). +/// +/// Cas « dĂ©jĂ  su » : si `data` est exactement la prĂ©diction, le rĂ©sidu est +/// **vide** — la donnĂ©e est pure structure, elle ne coĂ»te rien. +pub fn encode(generator: &G, data: &[u8], coords: Vec) -> (Triplet, Vec) { + let prediction = generator.generate(&coords); + let residual = if data == prediction.as_slice() { + Vec::new() + } else { + data.iter() + .enumerate() + .map(|(i, &b)| b ^ prediction.get(i).copied().unwrap_or(0)) + .collect() + }; + let triplet = Triplet { + gen_hash: generator.hash(), + coords, + res_hash: hash_bytes(&residual), + }; + (triplet, residual) +} + +/// **LIRE** (spec §2) : `donnĂ©e = G(coordonnĂ©es) ⊕ rĂ©sidu`, aprĂšs vĂ©rification +/// des deux hash. +/// +/// VĂ©rifie d'abord que `generator` est bien celui que le triplet cite +/// (sinon [`TripletError::GeneratorMismatch`]), puis que `residual` est bien +/// le rĂ©sidu citĂ© (sinon [`TripletError::ResidualMismatch`]). Ensuite +/// seulement, rĂ©gĂ©nĂšre la prĂ©diction aux coordonnĂ©es du triplet et applique +/// l'Ă©cart. DĂ©terministe, bit pour bit, sur toute machine, Ă  toute Ă©poque. +pub fn decode( + generator: &G, + triplet: &Triplet, + residual: &[u8], +) -> Result, TripletError> { + let got_gen = generator.hash(); + if got_gen != triplet.gen_hash { + return Err(TripletError::GeneratorMismatch { + expected: triplet.gen_hash, + got: got_gen, + }); + } + let got_res = hash_bytes(residual); + if got_res != triplet.res_hash { + return Err(TripletError::ResidualMismatch { + expected: triplet.res_hash, + got: got_res, + }); + } + let prediction = generator.generate(&triplet.coords); + if residual.is_empty() { + // RĂ©sidu vide = « exactement la prĂ©diction » : pure structure. + return Ok(prediction); + } + Ok(residual + .iter() + .enumerate() + .map(|(i, &b)| b ^ prediction.get(i).copied().unwrap_or(0)) + .collect()) +} + +/// **ALIGNEMENT** (spec §2) : `a` et `b` citent-ils le mĂȘme gĂ©nĂ©rateur ? +/// +/// Une simple Ă©galitĂ© de `gen_hash` — dĂ©cidable au bit, pas une +/// synchronisation floue. Deux triplets alignĂ©s ne diffĂšrent que par +/// `(coordonnĂ©es, hash_rĂ©sidu)` : entre deux nƓuds alignĂ©s, on diffe des +/// adresses au lieu de transporter des blobs. C'est le levier de la +/// rĂ©plication diff-only, du DM Ă  rĂ©sidu et de la position prĂ©dite. +pub fn align(a: &Triplet, b: &Triplet) -> bool { + a.gen_hash == b.gen_hash +} + +// --------------------------------------------------------------------------- +// GĂ©nĂ©rateurs d'exemple — pĂ©dagogiques, bit-exacts, format Ă©ternel respectĂ©. +// --------------------------------------------------------------------------- + +/// PrĂ©fixe de domaine des descriptions canoniques des gĂ©nĂ©rateurs d'exemple. +/// VersionnĂ© : changer la sĂ©mantique d'un gĂ©nĂ©rateur = nouveau nom = nouveau +/// hash (rĂ©trocompatibilitĂ© Ă©ternelle — on n'Ă©crase jamais un `gen_hash`). +const DOMAIN: &[u8] = b"bion-triplet:v0:"; + +/// GĂ©nĂ©rateur constant : prĂ©dit `len` octets tous Ă©gaux Ă  `value`. +/// +/// CoordonnĂ©es : la longueur attendue, en `u64` little-endian (8 octets) — +/// fabriquĂ©es par [`ConstGenerator::coords`]. Des coordonnĂ©es malformĂ©es +/// (≠ 8 octets) prĂ©disent zĂ©ro octet : un gĂ©nĂ©rateur ne panique jamais, il +/// prĂ©dit « rien » et laisse le rĂ©sidu porter tout le rĂ©el. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConstGenerator { + value: u8, +} + +impl ConstGenerator { + /// Un gĂ©nĂ©rateur qui prĂ©dit des octets tous Ă©gaux Ă  `value`. + pub fn new(value: u8) -> Self { + Self { value } + } + + /// Fabrique les coordonnĂ©es : la longueur attendue de la donnĂ©e. + pub fn coords(len: u64) -> Vec { + len.to_le_bytes().to_vec() + } +} + +impl Generator for ConstGenerator { + fn generate(&self, coords: &[u8]) -> Vec { + let Ok(bytes) = <[u8; 8]>::try_from(coords) else { + return Vec::new(); + }; + let len = u64::from_le_bytes(bytes); + // Garde-fou local : ne jamais allouer plus que ce qu'un u32 adresse — + // des coordonnĂ©es hostiles ne doivent pas faire tomber le lecteur. + let len = usize::try_from(len.min(u32::MAX as u64)).unwrap_or(0); + vec![self.value; len] + } + + fn hash(&self) -> Hash { + let mut desc = Vec::with_capacity(DOMAIN.len() + 6); + desc.extend_from_slice(DOMAIN); + desc.extend_from_slice(b"const:"); + desc.push(self.value); + hash_bytes(&desc) + } +} + +/// GĂ©nĂ©rateur de motif : prĂ©dit `len` octets d'un motif rĂ©pĂ©tĂ© en boucle, +/// Ă  partir d'un dĂ©calage. +/// +/// C'est le gĂ©nĂ©rateur « structure pĂ©riodique » : tout ce qui se rĂ©pĂšte +/// (trames, en-tĂȘtes, textures, silences) s'effondre Ă  une coordonnĂ©e. +/// CoordonnĂ©es : `(offset u64 LE, len u64 LE)` — 16 octets, fabriquĂ©es par +/// [`PatternGenerator::coords`]. CoordonnĂ©es malformĂ©es ou motif vide ⇒ +/// prĂ©diction vide (mĂȘme philosophie que [`ConstGenerator`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PatternGenerator { + pattern: Vec, +} + +impl PatternGenerator { + /// Un gĂ©nĂ©rateur qui rĂ©pĂšte `pattern` en boucle. + pub fn new(pattern: Vec) -> Self { + Self { pattern } + } + + /// Fabrique les coordonnĂ©es : dĂ©calage dans le motif + longueur attendue. + pub fn coords(offset: u64, len: u64) -> Vec { + let mut c = Vec::with_capacity(16); + c.extend_from_slice(&offset.to_le_bytes()); + c.extend_from_slice(&len.to_le_bytes()); + c + } +} + +impl Generator for PatternGenerator { + fn generate(&self, coords: &[u8]) -> Vec { + if coords.len() != 16 || self.pattern.is_empty() { + return Vec::new(); + } + let offset = u64::from_le_bytes(coords[..8].try_into().unwrap()); + let len = u64::from_le_bytes(coords[8..].try_into().unwrap()); + let len = usize::try_from(len.min(u32::MAX as u64)).unwrap_or(0); + let plen = self.pattern.len() as u64; + (0..len as u64) + .map(|i| self.pattern[((offset + i) % plen) as usize]) + .collect() + } + + fn hash(&self) -> Hash { + let mut desc = Vec::with_capacity(DOMAIN.len() + 8 + self.pattern.len()); + desc.extend_from_slice(DOMAIN); + desc.extend_from_slice(b"pattern:"); + desc.extend_from_slice(&self.pattern); + hash_bytes(&desc) + } +} + +/// GĂ©nĂ©rateur identitĂ© : ne prĂ©dit **rien**. +/// +/// Le cas dĂ©gĂ©nĂ©rĂ© `[POSÉ]` de la spec §1 : « gĂ©nĂ©rateur = identitĂ© ⇒ +/// rĂ©sidu = la donnĂ©e entiĂšre (stockage classique) ». C'est le plancher de +/// rĂ©trocompatibilitĂ© Ă©ternelle : toute donnĂ©e, mĂȘme sans aucun gĂ©nĂ©rateur +/// malin, a un triplet valide — son rĂ©sidu pĂšse juste son poids plein +/// (le mur de Kolmogorov, assumĂ© au lieu d'ĂȘtre cachĂ©). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct IdentityGenerator; + +impl Generator for IdentityGenerator { + fn generate(&self, _coords: &[u8]) -> Vec { + Vec::new() + } + + fn hash(&self) -> Hash { + let mut desc = Vec::with_capacity(DOMAIN.len() + 8); + desc.extend_from_slice(DOMAIN); + desc.extend_from_slice(b"identity"); + hash_bytes(&desc) + } +} + +// --------------------------------------------------------------------------- +// Tests — chaque garde-fou de la spec a le sien. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// LIRE(ÉCRIRE(x)) == x, bit pour bit, avec le gĂ©nĂ©rateur constant. + #[test] + fn roundtrip_const() { + let g = ConstGenerator::new(0xAB); + let data = [0xAB, 0x00, 0xAB, 0xFF, 0x12]; + let (t, r) = encode(&g, &data, ConstGenerator::coords(5)); + assert_eq!(decode(&g, &t, &r).unwrap(), data); + } + + /// Roundtrip avec le gĂ©nĂ©rateur de motif, dĂ©calage non nul inclus. + #[test] + fn roundtrip_pattern_avec_offset() { + let g = PatternGenerator::new(vec![1, 2, 3]); + // PrĂ©diction attendue depuis offset=2 : 3,1,2,3,1,2,3 + let data = [3, 1, 2, 9, 1, 2, 3]; + let (t, r) = encode(&g, &data, PatternGenerator::coords(2, 7)); + // Seul l'octet 3 (9 vs 3) est une surprise. + assert_eq!(r, vec![0, 0, 0, 9 ^ 3, 0, 0, 0]); + assert_eq!(decode(&g, &t, &r).unwrap(), data); + } + + /// Le cas « dĂ©jĂ  su » : data == prĂ©diction ⇒ rĂ©sidu VIDE, zĂ©ro stockage. + #[test] + fn residu_vide_quand_deja_su() { + let g = ConstGenerator::new(0x2A); + let data = [0x2A; 16]; + let (t, r) = encode(&g, &data, ConstGenerator::coords(16)); + assert!(r.is_empty(), "la pure structure ne coĂ»te rien"); + assert_eq!(decode(&g, &t, &r).unwrap(), data.to_vec()); + } + + /// ALIGNEMENT : mĂȘme gĂ©nĂ©rateur ⇒ true ; gĂ©nĂ©rateurs diffĂ©rents ⇒ false. + #[test] + fn align_egalite_de_gen_hash() { + let g1 = ConstGenerator::new(1); + let g2 = ConstGenerator::new(2); + let (a, _) = encode(&g1, b"aaa", ConstGenerator::coords(3)); + let (b, _) = encode(&g1, b"zzz", ConstGenerator::coords(3)); + let (c, _) = encode(&g2, b"aaa", ConstGenerator::coords(3)); + assert!(align(&a, &b), "mĂȘme gĂ©nĂ©rateur, coordonnĂ©es diffĂ©rentes"); + assert!(!align(&a, &c), "gĂ©nĂ©rateurs diffĂ©rents"); + } + + /// DĂ©coder avec le mauvais gĂ©nĂ©rateur ⇒ GeneratorMismatch, jamais une + /// donnĂ©e silencieusement fausse. + #[test] + fn mauvais_generateur_refuse() { + let bon = ConstGenerator::new(7); + let mauvais = ConstGenerator::new(8); + let (t, r) = encode(&bon, b"hello", ConstGenerator::coords(5)); + match decode(&mauvais, &t, &r) { + Err(TripletError::GeneratorMismatch { expected, got }) => { + assert_eq!(expected, bon.hash()); + assert_eq!(got, mauvais.hash()); + } + autre => panic!("attendu GeneratorMismatch, obtenu {autre:?}"), + } + } + + /// Un rĂ©sidu falsifiĂ©/corrompu ⇒ ResidualMismatch. + #[test] + fn residu_corrompu_refuse() { + let g = ConstGenerator::new(7); + let (t, mut r) = encode(&g, b"hello", ConstGenerator::coords(5)); + r[0] ^= 0xFF; + assert!(matches!( + decode(&g, &t, &r), + Err(TripletError::ResidualMismatch { .. }) + )); + } + + /// GĂ©nĂ©rateur identitĂ© : le rĂ©sidu EST la donnĂ©e (stockage classique, + /// cas dĂ©gĂ©nĂ©rĂ© posĂ© par la spec). + #[test] + fn identite_residu_est_la_donnee() { + let g = IdentityGenerator; + let data = b"le reel tout entier"; + let (t, r) = encode(&g, data, Vec::new()); + assert_eq!(r, data.to_vec(), "identitĂ© : rĂ©sidu = donnĂ©e entiĂšre"); + assert_eq!(decode(&g, &t, &r).unwrap(), data.to_vec()); + } + + /// DonnĂ©e PLUS LONGUE que la prĂ©diction : au-delĂ , le rĂ©sidu porte la + /// donnĂ©e telle quelle (XOR avec zĂ©ro). Roundtrip exact. + #[test] + fn donnee_plus_longue_que_la_prediction() { + let g = ConstGenerator::new(0x11); + let data = [0x11, 0x11, 0xAA, 0xBB]; // prĂ©diction : 2 octets seulement + let (t, r) = encode(&g, &data, ConstGenerator::coords(2)); + assert_eq!(r, vec![0, 0, 0xAA, 0xBB]); + assert_eq!(decode(&g, &t, &r).unwrap(), data.to_vec()); + } + + /// DonnĂ©e PLUS COURTE que la prĂ©diction : la longueur du rĂ©sidu fait foi, + /// roundtrip exact sans ambiguĂŻtĂ©. + #[test] + fn donnee_plus_courte_que_la_prediction() { + let g = ConstGenerator::new(0x11); + let data = [0x11, 0x22]; // prĂ©diction : 8 octets + let (t, r) = encode(&g, &data, ConstGenerator::coords(8)); + assert_eq!(r.len(), 2); + assert_eq!(decode(&g, &t, &r).unwrap(), data.to_vec()); + } + + /// DonnĂ©e vide + prĂ©diction vide : rĂ©sidu vide, roundtrip exact. + #[test] + fn donnee_vide() { + let g = IdentityGenerator; + let (t, r) = encode(&g, b"", Vec::new()); + assert!(r.is_empty()); + assert_eq!(decode(&g, &t, &r).unwrap(), Vec::::new()); + } + + /// StabilitĂ© des identitĂ©s : mĂȘme description ⇒ mĂȘme hash (partout, + /// toujours) ; descriptions diffĂ©rentes ⇒ hash diffĂ©rents. + #[test] + fn hash_generateurs_stables_et_distincts() { + assert_eq!(ConstGenerator::new(5).hash(), ConstGenerator::new(5).hash()); + assert_ne!(ConstGenerator::new(5).hash(), ConstGenerator::new(6).hash()); + assert_ne!( + PatternGenerator::new(vec![5]).hash(), + ConstGenerator::new(5).hash(), + "familles diffĂ©rentes = identitĂ©s diffĂ©rentes, mĂȘme paramĂštre" + ); + assert_ne!(IdentityGenerator.hash(), ConstGenerator::new(0).hash()); + } + + /// CoordonnĂ©es malformĂ©es : un gĂ©nĂ©rateur ne panique jamais, il prĂ©dit + /// « rien » et le rĂ©sidu porte tout. + #[test] + fn coordonnees_malformees_predisent_rien() { + let g = ConstGenerator::new(9); + let data = b"survit"; + let (t, r) = encode(&g, data, vec![1, 2, 3]); // ≠ 8 octets + assert_eq!(r, data.to_vec()); + assert_eq!(decode(&g, &t, &r).unwrap(), data.to_vec()); + + let p = PatternGenerator::new(vec![1, 2]); + let (t2, r2) = encode(&p, data, vec![0; 5]); // ≠ 16 octets + assert_eq!(r2, data.to_vec()); + assert_eq!(decode(&p, &t2, &r2).unwrap(), data.to_vec()); + } + + /// Le triplet ne contient aucun octet de contenu : deux donnĂ©es + /// diffĂ©rentes de mĂȘme longueur sous le mĂȘme gĂ©nĂ©rateur ne diffĂšrent que + /// par `res_hash` — et le `res_hash` ne rĂ©vĂšle pas la donnĂ©e. + #[test] + fn triplet_est_pure_adresse() { + let g = ConstGenerator::new(0); + let (a, _) = encode(&g, b"secret-1", ConstGenerator::coords(8)); + let (b, _) = encode(&g, b"secret-2", ConstGenerator::coords(8)); + assert_eq!(a.gen_hash, b.gen_hash); + assert_eq!(a.coords, b.coords); + assert_ne!(a.res_hash, b.res_hash); + } +} diff --git a/bion-tsoinlog/Cargo.toml b/bion-tsoinlog/Cargo.toml new file mode 100644 index 0000000..f1ccfd5 --- /dev/null +++ b/bion-tsoinlog/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "bion-tsoinlog" +version = "0.1.0" +edition = "2021" +description = "Journal d'Ă©vĂ©nements append-only rejouable — la primitive de la machine Ă  tsoins." +license = "MIT" + +[dependencies] diff --git a/bion-tsoinlog/README.md b/bion-tsoinlog/README.md new file mode 100644 index 0000000..90391ef --- /dev/null +++ b/bion-tsoinlog/README.md @@ -0,0 +1,91 @@ +# bion-tsoinlog — le journal append-only rejouable + +## Quoi + +La **primitive de la machine Ă  tsoins** : un journal d'Ă©vĂ©nements sur disque oĂč +l'on ne fait qu'**appender** (jamais modifier, jamais effacer) et que l'on peut +**rejouer** — en entier ou par tranche `[from, to)`. Chaque Ă©vĂ©nement = un +`topic` (UTF-8) + un `payload` (octets opaques), et reçoit un numĂ©ro de sĂ©quence +`Seq` strictement croissant. + +- **std-only**, zĂ©ro dĂ©pendance, zĂ©ro `unsafe`. +- Format binaire v1 minuscule et documentĂ© : `magic "TSOINLG1"` puis + `len(u32 LE) · topic_len(u16 LE) · topic · payload · crc32(u32 LE)`. +- **CRC-32 (IEEE) implĂ©mentĂ© maison** (table gĂ©nĂ©rĂ©e Ă  la compilation, vecteur + canonique `"123456789" → 0xCBF43926` testĂ©) — pas de lib, build-your-own. +- **RĂ©cupĂ©ration aprĂšs crash** : Ă  l'ouverture, la queue tronquĂ©e ou au CRC faux + est dĂ©tectĂ©e et retaillĂ©e ; l'historique valide survit toujours (testĂ© en + tronquant et en corrompant le fichier Ă  la main). +- `fsync` configurable (`Sync::Always` / `Sync::Never` + `sync()` manuel). +- Index en mĂ©moire (offset de chaque record) reconstruit au scan d'ouverture → + `iter_from(seq)` et `replay` dĂ©marrent en seek direct, pas de re-scan. + +## Pourquoi + +C'est la structure au cƓur de Kafka, des WAL de bases de donnĂ©es, de l'event +sourcing, de git — et de la machine Ă  tsoins : **enregistrer le rĂ©el dans +l'ordre, pouvoir le revivre**. Le complĂ©ment exact de `tsoin-codec` +(`~/xerboxion-rt/crates/tsoin-codec`) : le codec transforme un contenu en +coordonnĂ©e de Babel, le log mĂ©morise durablement la *sĂ©quence* des Ă©vĂ©nements. +On appende volontiers des tsoins-de-fil comme payloads ; le log, lui, ne +prĂ©suppose rien sur le contenu. + +## Exemple + +```rust +use bion_tsoinlog::{TsoinLog, Seq, Sync}; + +let mut log = TsoinLog::open("journal.tsoinlog")?; // Sync::Never par dĂ©faut +// ou : TsoinLog::open_with("journal.tsoinlog", Sync::Always)? // durable Ă  chaque append + +let s0 = log.append("capteur/temp", b"21.5")?; // → Seq(0) +let s1 = log.append("bus/emit", b"{\"topic\":\"leds\"}")?; // → Seq(1) + +// Tout relire : +for rec in log.iter()? { + let rec = rec?; + println!("#{} [{}] {} octets", rec.seq.0, rec.topic, rec.payload.len()); +} + +// Rejouer une tranche [1, 2) (from inclus, to exclu, comme un Range) : +log.replay(Seq(1), Seq(2), |rec| { /* rĂ©-appliquer l'Ă©vĂ©nement */ })?; +# std::io::Result::Ok(()) +``` + +## API publique (stable — rĂ©trocompatibilitĂ© Ă©ternelle) + +| ÉlĂ©ment | RĂŽle | +|---|---| +| `TsoinLog::open(path)` / `open_with(path, Sync)` | ouvre/crĂ©e + scan + rĂ©paration | +| `append(topic, payload) -> io::Result` | appende un Ă©vĂ©nement | +| `iter()` / `iter_from(Seq)` | itĂšre (instantanĂ© cohĂ©rent, seek O(1)) | +| `replay(from, to, f) -> io::Result` | rejoue `[from, to)`, retourne le compte | +| `sync()` / `set_sync(Sync)` | contrĂŽle du `fsync` | +| `len()` / `is_empty()` / `next_seq()` | Ă©tat du journal | +| `Seq(u64)` / `Record { seq, topic, payload }` / `Sync` | types de donnĂ©es | +| `crc32(&[u8]) -> u32` / `MAGIC` / `MAX_TOPIC_LEN` | briques exposĂ©es | + +## Build-your-own-x correspondants + +Dans [build-your-own-x](https://github.com/codecrafters-io/build-your-own-x) : + +- **Build your own Database** — le log est un *write-ahead log* (WAL) minimal ; +- **Build your own Git** — un historique append-only adressĂ© par position ; +- le CRC-32 maison est la brique commune Ă  zip/gzip/PNG/Ethernet (« build your + own checksum » depuis la division polynomiale dans GF(2)). + +## Comment l'optimiser (l'invitation au fork) + +Le format v1 est volontairement le plus simple qui soit correct. Pistes, dans +l'ordre de rentabilitĂ©, **sans jamais casser la lecture des fichiers v1** : + +1. **`Sync::EveryN(n)` / group commit** — amortir le `fsync` sur n appends ; +2. **index persistant** (fichier `.idx` side-car, rĂ©gĂ©nĂ©rable) — ouverture O(1) + sur les trĂšs gros journaux au lieu du scan complet ; +3. **segments + compaction** — dĂ©couper en fichiers de taille bornĂ©e, archiver + ou fusionner les vieux segments (le chemin vers Kafka) ; +4. **mmap en lecture** — itĂ©ration zĂ©ro-copie ; +5. **CRC vectorisĂ©** (slicing-by-8, ou `crc32` matĂ©riel SSE4.2) — mĂȘme rĂ©sultat, + ~10× plus vite ; +6. **compression des payloads** — brancher `tsoin-codec` : appender la + coordonnĂ©e de Babel au lieu des octets bruts. diff --git a/bion-tsoinlog/src/lib.rs b/bion-tsoinlog/src/lib.rs new file mode 100644 index 0000000..54d11cb --- /dev/null +++ b/bion-tsoinlog/src/lib.rs @@ -0,0 +1,721 @@ +//! `bion-tsoinlog` — le **journal d'Ă©vĂ©nements append-only rejouable**. +//! +//! C'est la primitive de la *machine Ă  tsoins* : tout ce qui arrive est **appendĂ©** +//! (jamais modifiĂ©, jamais effacĂ©), et le passĂ© peut ĂȘtre **rejouĂ©** Ă  l'identique, +//! en entier ou par tranche. LĂ  oĂč `tsoin-codec` (dans `xerboxion-rt`) sait *encoder* +//! un contenu en coordonnĂ©e de Babel, ce bion-ci est le **LOG** : le fichier durable +//! qui mĂ©morise la sĂ©quence des Ă©vĂ©nements bruts. Les deux se composent : on peut +//! trĂšs bien appender des tsoins-de-fil comme payloads — mais le log, lui, ne +//! prĂ©suppose RIEN sur le contenu (des octets opaques + un topic). +//! +//! # Le cours : pourquoi un log append-only ? +//! +//! Un log append-only est la structure de donnĂ©es la plus simple qui donne Ă  la fois : +//! +//! 1. **La durabilitĂ©** — on n'Ă©crit qu'Ă  la fin du fichier ; un crash ne peut abĂźmer +//! que le *dernier* enregistrement, jamais l'historique. +//! 2. **L'ordre total** — chaque enregistrement reçoit un numĂ©ro de sĂ©quence +//! ([`Seq`]) strictement croissant : le temps du journal. +//! 3. **Le rejeu** — l'Ă©tat de n'importe quel systĂšme peut ĂȘtre *reconstruit* en +//! rejouant le log du dĂ©but (ou d'un point connu) : c'est l'*event sourcing*, +//! le principe de Kafka, des WAL de bases de donnĂ©es, de git
 et de la machine +//! Ă  tsoins : enregistrer le rĂ©el, pouvoir le revivre. +//! +//! # Format binaire (v1) — simple et documentĂ© +//! +//! Le fichier commence par un en-tĂȘte de 8 octets, puis une suite d'enregistrements : +//! +//! ```text +//! fichier := magic(8 = "TSOINLG1") record* +//! record := len(u32 LE) body crc32(u32 LE) +//! body := topic_len(u16 LE) topic(UTF-8) payload(octets bruts) +//! ``` +//! +//! - `len` = taille du `body` en octets (donc `record` = 4 + len + 4 octets). +//! - `crc32` = CRC-32 (IEEE 802.3, implĂ©mentĂ© ici mĂȘme — voir [`crc32`]) du `body`. +//! - le numĂ©ro de sĂ©quence n'est **pas** stockĂ© : il est *positionnel* (le i-Ăšme +//! enregistrement du fichier a `Seq(i)`), donc impossible Ă  dĂ©synchroniser. +//! +//! # RĂ©cupĂ©ration aprĂšs crash +//! +//! À l'ouverture, le fichier est scannĂ© : le premier enregistrement tronquĂ© ou dont +//! le CRC ne colle pas marque la **fin valide** du journal. Tout ce qui suit est +//! ignorĂ© et le fichier est retaillĂ© Ă  cette frontiĂšre — le dernier Ă©crit partiel +//! d'un crash disparaĂźt proprement, l'historique intact reste. (TestĂ© en tronquant +//! et en corrompant Ă  la main.) +//! +//! # Exemple +//! +//! ``` +//! use bion_tsoinlog::{TsoinLog, Seq}; +//! # let dir = std::env::temp_dir().join(format!("tsoinlog-doc-{}", std::process::id())); +//! # std::fs::create_dir_all(&dir).unwrap(); +//! # let path = dir.join("journal.tsoinlog"); +//! # let _ = std::fs::remove_file(&path); +//! let mut log = TsoinLog::open(&path).unwrap(); +//! let s0 = log.append("capteur/temp", b"21.5").unwrap(); +//! let s1 = log.append("capteur/temp", b"21.7").unwrap(); +//! assert_eq!((s0, s1), (Seq(0), Seq(1))); +//! +//! // Rejouer la tranche [0, 2) : +//! let mut vus = Vec::new(); +//! log.replay(Seq(0), Seq(2), |rec| vus.push(rec.payload.clone())).unwrap(); +//! assert_eq!(vus, vec![b"21.5".to_vec(), b"21.7".to_vec()]); +//! # std::fs::remove_file(&path).unwrap(); +//! ``` + +use std::fs::{File, OpenOptions}; +use std::io::{self, BufReader, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +/// Les 8 octets magiques en tĂȘte de fichier : identifient le format + sa version. +/// Si le format devait Ă©voluer un jour, ce serait `TSOINLG2` — jamais une rupture +/// silencieuse (rĂ©trocompatibilitĂ© Ă©ternelle : un lecteur v2 lira toujours le v1). +pub const MAGIC: [u8; 8] = *b"TSOINLG1"; + +/// Taille maximale du topic (il est prĂ©fixĂ© par un `u16`). +pub const MAX_TOPIC_LEN: usize = u16::MAX as usize; + +// --------------------------------------------------------------------------- +// CRC-32 (IEEE 802.3) — implĂ©mentĂ© depuis les principes (build-your-own). +// --------------------------------------------------------------------------- + +/// Table des 256 restes prĂ©calculĂ©s du CRC-32, gĂ©nĂ©rĂ©e **Ă  la compilation**. +/// +/// Le CRC-32 est une division polynomiale dans GF(2) : le message est vu comme un +/// grand polynĂŽme Ă  coefficients binaires, divisĂ© par le polynĂŽme gĂ©nĂ©rateur +/// IEEE `0x04C11DB7`. Ici on utilise sa forme *rĂ©flĂ©chie* `0xEDB88320` (bits +/// inversĂ©s), ce qui permet de traiter les octets LSB-d'abord — la convention +/// standard (zip, gzip, PNG, Ethernet). La table mĂ©morise le reste de la division +/// pour chacune des 256 valeurs d'octet : on avance alors octet par octet au lieu +/// de bit par bit. +const CRC_TABLE: [u32; 256] = { + let mut table = [0u32; 256]; + let mut i = 0; + while i < 256 { + let mut c = i as u32; + let mut k = 0; + while k < 8 { + // Si le bit sortant est 1, on « soustrait » (XOR) le gĂ©nĂ©rateur. + c = if c & 1 != 0 { + 0xEDB8_8320 ^ (c >> 1) + } else { + c >> 1 + }; + k += 1; + } + table[i] = c; + i += 1; + } + table +}; + +/// CRC-32 (IEEE) d'un buffer. PrĂ©/post-conditionnement standard : registre initial +/// tout Ă  1 (`!0`), rĂ©sultat inversĂ© — c'est ce qui rend le CRC sensible aux zĂ©ros +/// de tĂȘte et de queue. Vecteur de test canonique : `crc32(b"123456789") == 0xCBF43926`. +pub fn crc32(data: &[u8]) -> u32 { + let mut c = !0u32; + for &b in data { + c = CRC_TABLE[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8); + } + !c +} + +// --------------------------------------------------------------------------- +// Types publics +// --------------------------------------------------------------------------- + +/// NumĂ©ro de sĂ©quence d'un enregistrement : le « temps » du journal. +/// +/// Le premier enregistrement a `Seq(0)`, le suivant `Seq(1)`, etc. C'est un +/// identifiant *positionnel* : il n'est pas stockĂ© dans le fichier, il ne peut +/// donc jamais ĂȘtre incohĂ©rent avec lui. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Seq(pub u64); + +/// Un enregistrement relu depuis le journal : son numĂ©ro, son topic, ses octets. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Record { + /// Position dans le journal (0-indexĂ©e, strictement croissante). + pub seq: Seq, + /// Canal logique de l'Ă©vĂ©nement (UTF-8, ≀ 65535 octets). + pub topic: String, + /// Contenu opaque : le log ne l'interprĂšte jamais. + pub payload: Vec, +} + +/// Politique de synchronisation disque aprĂšs chaque `append`. +/// +/// - [`Sync::Always`] : `fsync` aprĂšs chaque Ă©criture — durabilitĂ© maximale +/// (l'enregistrement survit Ă  une coupure de courant dĂšs que `append` retourne), +/// dĂ©bit minimal. +/// - [`Sync::Never`] : on laisse l'OS vider ses caches — dĂ©bit maximal ; en cas de +/// crash machine, les derniers enregistrements peuvent manquer, mais grĂące Ă  la +/// rĂ©cupĂ©ration le journal reste *cohĂ©rent* (jamais corrompu, juste plus court). +/// +/// C'est LE compromis classique des journaux (cf. `innodb_flush_log_at_trx_commit`, +/// `fsync` de Redis AOF
). Par dĂ©faut : `Never` (on peut toujours appeler +/// [`TsoinLog::sync`] aux moments importants). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Sync { + /// `fsync` Ă  chaque `append`. + Always, + /// Jamais de `fsync` automatique (dĂ©faut). + #[default] + Never, +} + +// --------------------------------------------------------------------------- +// Le journal +// --------------------------------------------------------------------------- + +/// Le journal append-only. Une instance = un fichier ouvert en Ă©criture (append), +/// plus un **index en mĂ©moire** (offset de chaque enregistrement) reconstruit au +/// scan d'ouverture — c'est lui qui rend `iter_from`/`replay` en accĂšs direct +/// (seek O(1)) au lieu d'un re-scan. +#[derive(Debug)] +pub struct TsoinLog { + path: PathBuf, + file: File, + /// `index[i]` = offset du dĂ©but de l'enregistrement `Seq(i)` dans le fichier. + index: Vec, + /// Fin valide du journal = offset oĂč Ă©crire le prochain enregistrement. + end: u64, + sync: Sync, +} + +impl TsoinLog { + /// Ouvre (ou crĂ©e) le journal Ă  `path`, avec la politique par dĂ©faut + /// ([`Sync::Never`]). Scanne le fichier, reconstruit l'index, et **rĂ©pare** + /// une Ă©ventuelle fin tronquĂ©e/corrompue (voir la doc du module). + pub fn open>(path: P) -> io::Result { + Self::open_with(path, Sync::default()) + } + + /// Comme [`TsoinLog::open`] mais en choisissant la politique de `fsync`. + pub fn open_with>(path: P, sync: Sync) -> io::Result { + let path = path.as_ref().to_path_buf(); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) // append-only : on ne dĂ©truit JAMAIS l'existant + .open(&path)?; + + let file_len = file.metadata()?.len(); + if file_len == 0 { + // Fichier neuf : on pose l'en-tĂȘte. + file.write_all(&MAGIC)?; + file.sync_all()?; + return Ok(Self { + path, + file, + index: Vec::new(), + end: MAGIC.len() as u64, + sync, + }); + } + if file_len < MAGIC.len() as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fichier trop court pour ĂȘtre un tsoinlog (en-tĂȘte absent)", + )); + } + let mut magic = [0u8; 8]; + file.seek(SeekFrom::Start(0))?; + file.read_exact(&mut magic)?; + if magic != MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "mauvais magic : ce fichier n'est pas un tsoinlog v1", + )); + } + + // Scan : on avance enregistrement par enregistrement tant que tout est + // valide ; le premier accroc marque la fin rĂ©elle du journal. + let mut reader = BufReader::new(&mut file); + reader.seek(SeekFrom::Start(MAGIC.len() as u64))?; + let mut index = Vec::new(); + let mut off = MAGIC.len() as u64; + // Fin de boucle = fin propre OU queue tronquĂ©e/corrompue : on s'arrĂȘte lĂ . + while let Some((next_off, _body)) = read_record_at(&mut reader, off, file_len) { + index.push(off); + off = next_off; + } + drop(reader); + + // RĂ©paration : si des octets invalides traĂźnent aprĂšs la fin valide + // (crash en pleine Ă©criture), on retaille — le journal redevient sain. + if off < file_len { + file.set_len(off)?; + file.sync_all()?; + } + Ok(Self { + path, + file, + index, + end: off, + sync, + }) + } + + /// Change la politique de `fsync` (prend effet dĂšs le prochain `append`). + pub fn set_sync(&mut self, sync: Sync) { + self.sync = sync; + } + + /// Appende un Ă©vĂ©nement et retourne son numĂ©ro de sĂ©quence. + /// + /// Erreurs : `InvalidInput` si `topic` dĂ©passe [`MAX_TOPIC_LEN`] octets ou si + /// `topic + payload` dĂ©passe `u32::MAX - 2` octets ; sinon les erreurs d'E/S. + pub fn append(&mut self, topic: &str, payload: &[u8]) -> io::Result { + let t = topic.as_bytes(); + if t.len() > MAX_TOPIC_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "topic > 65535 octets", + )); + } + let body_len = 2usize + .checked_add(t.len()) + .and_then(|n| n.checked_add(payload.len())) + .filter(|&n| n <= u32::MAX as usize) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "enregistrement > 4 Gio"))?; + + // On assemble le record complet en mĂ©moire puis UNE Ă©criture : si le + // processus meurt au milieu, on obtient au pire un suffixe partiel — + // exactement le cas que la rĂ©cupĂ©ration d'ouverture sait effacer. + let mut buf = Vec::with_capacity(4 + body_len + 4); + buf.extend_from_slice(&(body_len as u32).to_le_bytes()); + buf.extend_from_slice(&(t.len() as u16).to_le_bytes()); + buf.extend_from_slice(t); + buf.extend_from_slice(payload); + let crc = crc32(&buf[4..]); + buf.extend_from_slice(&crc.to_le_bytes()); + + self.file.seek(SeekFrom::Start(self.end))?; + self.file.write_all(&buf)?; + if self.sync == Sync::Always { + self.file.sync_data()?; + } + let seq = Seq(self.index.len() as u64); + self.index.push(self.end); + self.end += buf.len() as u64; + Ok(seq) + } + + /// Force un `fsync` maintenant (utile avec [`Sync::Never`] aux points clĂ©s). + pub fn sync(&mut self) -> io::Result<()> { + self.file.sync_data() + } + + /// Nombre d'enregistrements valides dans le journal. + pub fn len(&self) -> u64 { + self.index.len() as u64 + } + + /// `true` si le journal ne contient encore aucun enregistrement. + pub fn is_empty(&self) -> bool { + self.index.is_empty() + } + + /// Le numĂ©ro que recevra le **prochain** `append`. + pub fn next_seq(&self) -> Seq { + Seq(self.index.len() as u64) + } + + /// ItĂšre sur tous les enregistrements, du premier au dernier. + pub fn iter(&self) -> io::Result { + self.iter_from(Seq(0)) + } + + /// ItĂšre Ă  partir de `from` (inclus). GrĂące Ă  l'index, le dĂ©part est un + /// `seek` direct — pas de re-scan du fichier. Si `from` est au-delĂ  de la + /// fin, l'itĂ©rateur est simplement vide. + pub fn iter_from(&self, from: Seq) -> io::Result { + // Handle de lecture indĂ©pendant : on peut itĂ©rer sans gĂȘner l'Ă©criture. + let file = File::open(&self.path)?; + let mut reader = BufReader::new(file); + let start = self.index.get(from.0 as usize).copied().unwrap_or(self.end); + reader.seek(SeekFrom::Start(start))?; + Ok(Iter { + reader, + offset: start, + end: self.end, + next_seq: from, + }) + } + + /// Rejoue la tranche `[from, to)` (from inclus, to exclu — comme un `Range`) + /// en appelant `f` sur chaque enregistrement, dans l'ordre. Retourne le + /// nombre d'enregistrements rejouĂ©s. `to` peut dĂ©passer la fin : on s'arrĂȘte + /// au dernier enregistrement existant (rejouer « jusqu'au bout » = passer + /// `Seq(u64::MAX)` ou `log.next_seq()`). + pub fn replay(&self, from: Seq, to: Seq, mut f: F) -> io::Result + where + F: FnMut(&Record), + { + let mut n = 0u64; + for rec in self.iter_from(from)? { + let rec = rec?; + if rec.seq >= to { + break; + } + f(&rec); + n += 1; + } + Ok(n) + } +} + +// --------------------------------------------------------------------------- +// Lecture bas niveau + itĂ©rateur +// --------------------------------------------------------------------------- + +/// Tente de lire un enregistrement complet et valide commençant Ă  `off` +/// (le reader doit dĂ©jĂ  y ĂȘtre positionnĂ©). Retourne `Some((offset_suivant, body))` +/// si tout est bon, `None` si la fin du fichier arrive avant, si `len` dĂ©borde +/// de la zone `limit`, ou si le CRC ne correspond pas — les trois visages d'une +/// queue de fichier morte. +fn read_record_at(reader: &mut R, off: u64, limit: u64) -> Option<(u64, Vec)> { + // 4 (len) + 4 (crc) au minimum. + if limit.saturating_sub(off) < 8 { + return None; + } + let mut len4 = [0u8; 4]; + reader.read_exact(&mut len4).ok()?; + let body_len = u32::from_le_bytes(len4) as u64; + if body_len < 2 || off + 4 + body_len + 4 > limit { + return None; // longueur invalide ou enregistrement tronquĂ© + } + let mut body = vec![0u8; body_len as usize]; + reader.read_exact(&mut body).ok()?; + let mut crc4 = [0u8; 4]; + reader.read_exact(&mut crc4).ok()?; + if u32::from_le_bytes(crc4) != crc32(&body) { + return None; // octets abĂźmĂ©s : ce record (et tout ce qui suit) est mort + } + // Le topic_len doit ĂȘtre cohĂ©rent avec la taille du body. + let topic_len = u16::from_le_bytes([body[0], body[1]]) as u64; + if 2 + topic_len > body_len { + return None; + } + Some((off + 4 + body_len + 4, body)) +} + +/// DĂ©code un `body` validĂ© en [`Record`]. +fn decode_body(seq: Seq, body: Vec) -> io::Result { + let topic_len = u16::from_le_bytes([body[0], body[1]]) as usize; + let topic = std::str::from_utf8(&body[2..2 + topic_len]) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "topic non UTF-8"))? + .to_owned(); + let payload = body[2 + topic_len..].to_vec(); + Ok(Record { + seq, + topic, + payload, + }) +} + +/// ItĂ©rateur sur les enregistrements du journal. BornĂ© Ă  la fin valide connue au +/// moment de sa crĂ©ation : un `append` postĂ©rieur n'est pas visible par un +/// itĂ©rateur dĂ©jĂ  ouvert (instantanĂ© cohĂ©rent). +pub struct Iter { + reader: BufReader, + offset: u64, + end: u64, + next_seq: Seq, +} + +impl Iterator for Iter { + type Item = io::Result; + + fn next(&mut self) -> Option { + let (next_off, body) = read_record_at(&mut self.reader, self.offset, self.end)?; + self.offset = next_off; + let seq = self.next_seq; + self.next_seq = Seq(seq.0 + 1); + Some(decode_body(seq, body)) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Chemin de fichier de test unique (rĂ©pertoire temp de l'OS). + fn tmp_path(tag: &str) -> PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("bion-tsoinlog-tests-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(format!("{tag}-{n}.tsoinlog")) + } + + // 1. Le vecteur canonique du CRC-32 IEEE : la preuve que notre implĂ©mentation + // maison est LA bonne (interopĂ©rable avec zip/gzip/PNG). + #[test] + fn crc32_vecteur_canonique() { + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + assert_eq!(crc32(b""), 0); + assert_ne!(crc32(b"a"), crc32(b"b")); + } + + // 2. Aller-retour Ă©lĂ©mentaire : ce qu'on appende est ce qu'on relit. + #[test] + fn append_read_roundtrip() { + let p = tmp_path("roundtrip"); + let mut log = TsoinLog::open(&p).unwrap(); + assert!(log.is_empty()); + let s = log + .append("hello/monde", b"payload \x00\xff binaire") + .unwrap(); + assert_eq!(s, Seq(0)); + let recs: Vec = log.iter().unwrap().map(|r| r.unwrap()).collect(); + assert_eq!(recs.len(), 1); + assert_eq!(recs[0].seq, Seq(0)); + assert_eq!(recs[0].topic, "hello/monde"); + assert_eq!(recs[0].payload, b"payload \x00\xff binaire"); + } + + // 3. Plusieurs topics, ordre et numĂ©ros de sĂ©quence prĂ©servĂ©s. + #[test] + fn topics_multiples_et_ordre() { + let p = tmp_path("topics"); + let mut log = TsoinLog::open(&p).unwrap(); + for i in 0..5u8 { + let s = log.append(&format!("t/{i}"), &[i, i, i]).unwrap(); + assert_eq!(s, Seq(i as u64)); + } + let recs: Vec = log.iter().unwrap().map(|r| r.unwrap()).collect(); + assert_eq!(recs.len(), 5); + for (i, r) in recs.iter().enumerate() { + assert_eq!(r.seq, Seq(i as u64)); + assert_eq!(r.topic, format!("t/{i}")); + assert_eq!(r.payload, vec![i as u8; 3]); + } + } + + // 4. Cas limites : topic vide, payload vide, gros payload — tout est lĂ©gal. + #[test] + fn cas_limites_vides_et_gros() { + let p = tmp_path("limites"); + let mut log = TsoinLog::open(&p).unwrap(); + log.append("", b"").unwrap(); + log.append("juste-topic", b"").unwrap(); + let gros = vec![0xABu8; 1 << 20]; // 1 Mio + log.append("", &gros).unwrap(); + let recs: Vec = log.iter().unwrap().map(|r| r.unwrap()).collect(); + assert_eq!( + recs[0], + Record { + seq: Seq(0), + topic: String::new(), + payload: vec![] + } + ); + assert_eq!(recs[1].topic, "juste-topic"); + assert_eq!(recs[2].payload, gros); + // Et un topic trop long est refusĂ© proprement. + let trop = "x".repeat(MAX_TOPIC_LEN + 1); + assert_eq!( + log.append(&trop, b"").unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + } + + // 5. Persistance : on ferme, on rouvre, la sĂ©quence continue oĂč elle Ă©tait. + #[test] + fn reouverture_continue_la_sequence() { + let p = tmp_path("reopen"); + { + let mut log = TsoinLog::open(&p).unwrap(); + log.append("a", b"1").unwrap(); + log.append("a", b"2").unwrap(); + } + let mut log = TsoinLog::open(&p).unwrap(); + assert_eq!(log.len(), 2); + assert_eq!(log.next_seq(), Seq(2)); + assert_eq!(log.append("a", b"3").unwrap(), Seq(2)); + let recs: Vec = log.iter().unwrap().map(|r| r.unwrap()).collect(); + assert_eq!( + recs.iter().map(|r| r.payload[0]).collect::>(), + b"123".to_vec() + ); + } + + // 6. CRASH-RECOVERY (troncature) : on coupe le fichier au milieu du dernier + // enregistrement — Ă  la rĂ©ouverture il est ignorĂ©, le reste est intact, + // et on peut rĂ©-appender par-dessus. + #[test] + fn recovery_fichier_tronque() { + let p = tmp_path("tronque"); + { + let mut log = TsoinLog::open(&p).unwrap(); + log.append("ok", b"garde-moi").unwrap(); + log.append("ok", b"garde-moi aussi").unwrap(); + log.append("boom", b"je serai coupe en plein vol").unwrap(); + } + // Simule le crash : on tronque 5 octets dans le dernier record. + let len = std::fs::metadata(&p).unwrap().len(); + let f = OpenOptions::new().write(true).open(&p).unwrap(); + f.set_len(len - 5).unwrap(); + drop(f); + + let mut log = TsoinLog::open(&p).unwrap(); + assert_eq!(log.len(), 2, "le record tronquĂ© doit ĂȘtre ignorĂ©"); + // Le fichier a Ă©tĂ© retaillĂ© Ă  la frontiĂšre valide. + let recs: Vec = log.iter().unwrap().map(|r| r.unwrap()).collect(); + assert_eq!(recs[1].payload, b"garde-moi aussi"); + // Et la vie continue : le prochain append prend Seq(2). + assert_eq!(log.append("neuf", b"apres le crash").unwrap(), Seq(2)); + let log2 = TsoinLog::open(&p).unwrap(); + assert_eq!(log2.len(), 3); + } + + // 7. CRASH-RECOVERY (corruption) : un octet du dernier record est abĂźmĂ© → + // le CRC le dĂ©tecte, le record est Ă©cartĂ©, l'historique d'avant survit. + #[test] + fn recovery_crc_corrompu() { + let p = tmp_path("corrompu"); + { + let mut log = TsoinLog::open(&p).unwrap(); + log.append("sain", b"aaaa").unwrap(); + log.append("abime", b"bbbb").unwrap(); + } + // Flip d'un octet dans le payload du 2e record (l'avant-dernier octet + // avant le CRC final). + let mut bytes = std::fs::read(&p).unwrap(); + let n = bytes.len(); + bytes[n - 6] ^= 0xFF; + std::fs::write(&p, &bytes).unwrap(); + + let log = TsoinLog::open(&p).unwrap(); + assert_eq!(log.len(), 1, "le record au CRC faux doit ĂȘtre Ă©cartĂ©"); + let recs: Vec = log.iter().unwrap().map(|r| r.unwrap()).collect(); + assert_eq!(recs[0].topic, "sain"); + } + + // 8. Un fichier qui n'est pas un tsoinlog est refusĂ© (pas de lecture hasardeuse). + #[test] + fn mauvais_magic_refuse() { + let p = tmp_path("magic"); + std::fs::write(&p, b"PASUNLOGDUTOUT!!").unwrap(); + let err = TsoinLog::open(&p).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + // 9. iter_from : dĂ©part au milieu (seek direct via l'index), et au-delĂ  de + // la fin → itĂ©rateur vide, pas d'erreur. + #[test] + fn iter_from_milieu_et_apres_fin() { + let p = tmp_path("iterfrom"); + let mut log = TsoinLog::open(&p).unwrap(); + for i in 0..10u64 { + log.append("n", &i.to_le_bytes()).unwrap(); + } + let recs: Vec = log.iter_from(Seq(7)).unwrap().map(|r| r.unwrap()).collect(); + assert_eq!(recs.len(), 3); + assert_eq!(recs[0].seq, Seq(7)); + assert_eq!(recs[0].payload, 7u64.to_le_bytes()); + assert_eq!(log.iter_from(Seq(10)).unwrap().count(), 0); + assert_eq!(log.iter_from(Seq(9999)).unwrap().count(), 0); + } + + // 10. Replay partiel : la tranche [from, to) exactement, dans l'ordre. + #[test] + fn replay_partiel() { + let p = tmp_path("replay"); + let mut log = TsoinLog::open(&p).unwrap(); + for i in 0..10u8 { + log.append("ev", &[i]).unwrap(); + } + let mut vus = Vec::new(); + let n = log + .replay(Seq(3), Seq(7), |r| vus.push(r.payload[0])) + .unwrap(); + assert_eq!(n, 4); + assert_eq!(vus, vec![3, 4, 5, 6]); + // to au-delĂ  de la fin : on rejoue jusqu'au bout sans erreur. + let n = log.replay(Seq(8), Seq(u64::MAX), |_| {}).unwrap(); + assert_eq!(n, 2); + // tranche vide. + let n = log + .replay(Seq(5), Seq(5), |_| panic!("ne doit pas ĂȘtre appelĂ©")) + .unwrap(); + assert_eq!(n, 0); + } + + // 11. GROS VOLUME : 10 000 records, roundtrip intĂ©gral + index aprĂšs rĂ©ouverture. + #[test] + fn volume_10k_records() { + let p = tmp_path("volume"); + { + let mut log = TsoinLog::open(&p).unwrap(); + for i in 0..10_000u64 { + let payload = [i.to_le_bytes().as_slice(), &[(i % 251) as u8; 17]].concat(); + let s = log.append(&format!("vol/{}", i % 7), &payload).unwrap(); + assert_eq!(s, Seq(i)); + } + } + // RĂ©ouverture : le scan reconstruit l'index sur les 10k records. + let log = TsoinLog::open(&p).unwrap(); + assert_eq!(log.len(), 10_000); + let mut count = 0u64; + for rec in log.iter().unwrap() { + let rec = rec.unwrap(); + assert_eq!(rec.seq, Seq(count)); + let i = u64::from_le_bytes(rec.payload[..8].try_into().unwrap()); + assert_eq!(i, count); + assert_eq!(rec.topic, format!("vol/{}", count % 7)); + count += 1; + } + assert_eq!(count, 10_000); + // AccĂšs direct profond via l'index. + let r = log.iter_from(Seq(9_999)).unwrap().next().unwrap().unwrap(); + assert_eq!( + u64::from_le_bytes(r.payload[..8].try_into().unwrap()), + 9_999 + ); + } + + // 12. fsync configurable : les deux politiques Ă©crivent des journaux identiques + // (la durabilitĂ© change, pas le format) ; sync() manuel disponible. + #[test] + fn politiques_de_sync() { + let p1 = tmp_path("sync-always"); + let p2 = tmp_path("sync-never"); + let mut a = TsoinLog::open_with(&p1, Sync::Always).unwrap(); + let mut b = TsoinLog::open_with(&p2, Sync::Never).unwrap(); + for i in 0..20u8 { + a.append("s", &[i]).unwrap(); + b.append("s", &[i]).unwrap(); + } + b.sync().unwrap(); + b.set_sync(Sync::Always); + b.append("s", &[99]).unwrap(); // record de 4 + (2+1+1) + 4 = 12 octets + let a_bytes = std::fs::read(&p1).unwrap(); + let b_bytes = std::fs::read(&p2).unwrap(); + assert_eq!(a_bytes.len() + 12, b_bytes.len()); + // Les 20 premiers records sont octet-pour-octet identiques. + assert_eq!(a_bytes[..], b_bytes[..a_bytes.len()]); + } + + // 13. Un itĂ©rateur ouvert est un instantanĂ© : les appends postĂ©rieurs ne + // s'y invitent pas (cohĂ©rence de lecture). + #[test] + fn iterateur_est_un_instantane() { + let p = tmp_path("snapshot"); + let mut log = TsoinLog::open(&p).unwrap(); + log.append("x", b"1").unwrap(); + let it = log.iter().unwrap(); + log.append("x", b"2").unwrap(); + assert_eq!(it.count(), 1); + assert_eq!(log.iter().unwrap().count(), 2); + } +} diff --git a/bion-vc/Cargo.toml b/bion-vc/Cargo.toml new file mode 100644 index 0000000..0a5cbe3 --- /dev/null +++ b/bion-vc/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "bion-vc" +version = "0.1.0" +edition = "2021" +description = "Horloges vectorielles + Multi-Value Register « fork visible » — l'algo de merge causal de xion-relativiste-v0" +license = "AGPL-3.0-only" + +# std only — aucun crate externe, c'est le dogme des bions : +# chaque brique est relisible de bout en bout, comme un cours. +[dependencies] diff --git a/bion-vc/README.md b/bion-vc/README.md new file mode 100644 index 0000000..152c4c2 --- /dev/null +++ b/bion-vc/README.md @@ -0,0 +1,104 @@ +# bion-vc — horloges vectorielles + Multi-Value Register « fork visible » + +## Quoi + +La brique causale du xerboxion, `std`-only, zĂ©ro dĂ©pendance, zĂ©ro `unsafe` : + +- **`VectorClock`** — l'horloge logique canonique (Fidge/Mattern) : `increment(node)`, + `merge(&other)` (max composante par composante), `observe(&other, node)` (rĂšgle de + rĂ©ception complĂšte), et la comparaison causale `causality()` → + `Before | After | Concurrent | Equal`. `PartialOrd` est implĂ©mentĂ© honnĂȘtement : + `partial_cmp` rend `None` pour deux horloges concurrentes, parce que l'ordre causal + est *partiel* — c'est toute la leçon. +- **`Stamped`** — une valeur + son estampille causale (l'enveloppe du tsoin, §2 de la spec). +- **`MvReg`** — le Multi-Value Register : `set()` (Ă©crasement *causal* uniquement — de ce + que l'Ă©crivain a vu), `apply()`/`merge()` qui **gardent les branches concurrentes**, + `values()`/`branches()`/`is_forked()` pour les exposer, `resolve()` pour fermer un fork + **explicitement** avec un VC qui domine toutes les branches. +- SĂ©rialisation texte stable (`to_text`/`from_text`, formats gelĂ©s `a:2,b:1` et `mvr1`), + sans serde : deux rĂ©pliques convergĂ©es produisent le mĂȘme texte, octet pour octet. + +## Pourquoi + +C'Ă©tait **l'algo manquant** du projet : la spec `xion-relativiste-v0` +(`resources/papers/xion-relativiste-v0.md` de my_website2) gĂšle la dĂ©cision RS-7 — + +> **Divergence = branche, retrouvaille = merge, conflit = fork visible.** +> Jamais d'Ă©crasement silencieux. + +— mais personne ne l'avait codĂ©e. La rĂ©plication actuelle du labo fait du LWW par horloge +murale (`StateReplicationController`, `savedAt`/`cmpAt()`), qui jette silencieusement une +des deux Ă©critures concurrentes. Ce bion est l'arbitre de remplacement du chemin de +migration §6 de la spec : le merge devient un CRDT (commutatif, associatif, idempotent — +testĂ© par permutations), l'essaim converge sans coordinateur, et aucune branche ne meurt +sans un acte explicite. + +## Exemple + +```rust +use bion_vc::{MvReg, Causality, VectorClock}; + +// Deux rĂ©pliques divergent : chacune Ă©crit « sa » valeur du mĂȘme point. +let mut bord = MvReg::new(); +bord.set("cubion-bord", "cap sur Europe".to_string()); +let mut sol = MvReg::new(); +sol.set("cubion-sol", "cap sur Titan".to_string()); + +// Retrouvailles : merge. Personne ne perd rien → fork VISIBLE. +bord.merge(&sol); +assert!(bord.is_forked()); +assert_eq!(bord.values().len(), 2); + +// Fermer le fork est un acte explicite, tracĂ©, qui domine les deux branches. +bord.resolve("cubion-bord", |branches| { + let mut caps: Vec<&str> = branches.iter().map(|b| b.value.as_str()).collect(); + caps.sort(); + caps.join(" PUIS ") +}); +assert!(!bord.is_forked()); + +// Et l'horloge seule, pour estampiller n'importe quoi d'autre : +let mut vc = VectorClock::new(); +vc.increment("moi"); +assert_eq!(vc.causality(&VectorClock::new()), Causality::After); +``` + +## Liens build-your-own-x + +Le [dĂ©pĂŽt build-your-own-x](https://github.com/codecrafters-io/build-your-own-x) n'a pas +(encore) de section « Build your own CRDT » — ce bion en tient lieu, depuis les principes, +avec les sources primaires qu'un tel tutoriel citerait : + +- Lamport, *Time, Clocks, and the Ordering of Events in a Distributed System*, CACM 1978 — + le happened-before. +- Fidge 1988, Mattern 1989 — le vector clock qui capture la causalitĂ© *complĂšte*. +- Shapiro, Preguiça, Baquero, Zawirski, *Conflict-free Replicated Data Types*, SSS 2011 — + les propriĂ©tĂ©s CRDT et la spec du MV-Register. +- DeCandia et al., *Dynamo*, SOSP 2007 — les « siblings » : le fork visible en production + depuis 2007. + +Parents dans build-your-own-x : « Build your own Git » (le merge Ă  trois voies et les +branches visibles sont le mĂȘme geste) et « Build your own Database » (la rĂ©plication). + +## Comment l'optimiser (l'invitation au fork) + +L'API est gelĂ©e ; tout le reste est Ă  toi. Pistes rĂ©elles, dans l'ordre du §7 de la spec : + +1. **Compaction des VC** — un VC croĂźt en O(n) nƓuds rencontrĂ©s. À grande Ă©chelle : + deltas de VC, *dotted version vectors* (Preguiça et al. 2010), ou VC hiĂ©rarchiques par + agrĂ©gat. Le format texte rĂ©serve la place (c'est une map : ajoute des nƓuds composites). +2. **`apply` en O(nÂČ)** sur le nombre de branches — correct et simple ; si un point + accumule des centaines de forks non rĂ©solus, indexer les branches par nƓud dominant. +3. **Interner les ids de nƓuds** (`Rc`/index dans un registre) au lieu de `String` + par entrĂ©e — le clone de VC est aujourd'hui la seule allocation chaude. +4. **GC des branches** — un MVR n'oublie jamais tout seul (c'est voulu) : une politique de + rĂ©tention/archivage se construit *au-dessus*, jamais dedans. + +RĂšgle du fork : garde `to_text`/`from_text` compatibles (rĂ©trocompatibilitĂ© Ă©ternelle) — +un format v2 porte un nouvel en-tĂȘte, il ne mute jamais v1. + +``` +cargo test -p bion-vc +``` + +*Ne pas nuire. Aucune montre n'a raison contre l'autre ; aucune branche ne meurt en silence.* diff --git a/bion-vc/src/lib.rs b/bion-vc/src/lib.rs new file mode 100644 index 0000000..9a7199b --- /dev/null +++ b/bion-vc/src/lib.rs @@ -0,0 +1,1044 @@ +//! # bion-vc — horloges vectorielles et Multi-Value Register « fork visible » +//! +//! Ce bion implĂ©mente **l'algo manquant** de la spec `xion-relativiste-v0` +//! (`resources/papers/xion-relativiste-v0.md` de my_website2) : l'horloge +//! logique de chaque nƓud (space-cubion, boxion, rĂ©plique
), la comparaison +//! causale, et la sĂ©mantique de merge gelĂ©e par RS-7 : +//! +//! > **Divergence = branche, retrouvaille = merge, conflit = fork visible.** +//! > Jamais d'Ă©crasement silencieux, jamais d'arbitrage automatique qui jette +//! > de l'information. +//! +//! ## Le cours en trois minutes +//! +//! La relativitĂ© (et le simple clock-skew) interdit l'horloge universelle. +//! L'informatique distribuĂ©e a rĂ©solu le problĂšme en l'abandonnant : ce qui +//! compte n'est pas *quand* un Ă©vĂ©nement a eu lieu, mais **ce qu'il savait** +//! au moment d'avoir lieu — l'ordre **causal** (Lamport 1978). Le vector +//! clock (Fidge 1988, Mattern 1989) capture cette causalitĂ© *complĂštement* : +//! +//! - chaque nƓud `i` tient une map `{nƓud → compteur}` ; +//! - Ă©vĂ©nement local : `VC[i] += 1` ([`VectorClock::increment`]) ; +//! - Ă©mission : l'Ă©vĂ©nement part estampillĂ© d'une copie du VC ([`Stamped`]) ; +//! - rĂ©ception : `VC ← max composante par composante`, puis `VC[i] += 1` +//! ([`VectorClock::merge`] + [`VectorClock::increment`], ou directement +//! [`VectorClock::observe`]). +//! +//! Deux estampilles se comparent alors causalement ([`VectorClock::causality`]) : +//! `Before` (e₁ → e₂), `After`, `Equal`, ou `Concurrent` — deux lignes +//! d'univers qui ne se sont pas parlĂ©. C'est un ordre **partiel** : d'oĂč +//! l'impl de `PartialOrd` qui rend `None` pour la concurrence. +//! +//! Quand deux Ă©critures **concurrentes** touchent le mĂȘme point, aucune +//! n'a « raison » : le [`MvReg`] (Multi-Value Register, Shapiro et al. 2011 ; +//! la sĂ©mantique des « siblings » de Dynamo/Riak) **garde les deux branches** +//! et les expose. Le merge est un CRDT : commutatif, associatif, idempotent — +//! N rĂ©pliques qui se retrouvent dans n'importe quel ordre convergent vers le +//! mĂȘme Ă©tat, sans coordinateur. Fermer un fork est un **acte explicite** +//! ([`MvReg::resolve`]) : une nouvelle valeur dont le VC domine toutes les +//! branches — le fork se referme comme un merge commit, tracĂ©. +//! +//! ## Exemple +//! +//! ``` +//! use bion_vc::{MvReg, Causality}; +//! +//! // Deux space-cubions divergent : chacun Ă©crit « sa » valeur du mĂȘme point. +//! let mut bord = MvReg::new(); +//! bord.set("cubion-bord", "cap sur Europe".to_string()); +//! let mut sol = MvReg::new(); +//! sol.set("cubion-sol", "cap sur Titan".to_string()); +//! +//! // Retrouvailles : merge. Aucune Ă©criture ne disparaĂźt → fork VISIBLE. +//! bord.merge(&sol); +//! assert!(bord.is_forked()); +//! assert_eq!(bord.values().len(), 2); +//! +//! // Fermer le fork est un acte explicite, estampillĂ©, qui domine les branches. +//! bord.resolve("cubion-bord", |branches| { +//! let mut caps: Vec<&str> = branches.iter().map(|b| b.value.as_str()).collect(); +//! caps.sort(); +//! caps.join(" PUIS ") +//! }); +//! assert!(!bord.is_forked()); +//! ``` +//! +//! ## Ce que ce bion n'est PAS +//! +//! Pas de transport (DTN/Bundle Protocol, §5 de la spec), pas de signature +//! GPG (§2), pas de compaction de VC (§7.1) : ce bion est la **brique +//! causale pure**, Ă  composer avec les autres. API petite et stable — la +//! rĂ©trocompatibilitĂ© est Ă©ternelle, comme il se doit. + +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; + +// --------------------------------------------------------------------------- +// Causality — le rĂ©sultat d'une comparaison causale +// --------------------------------------------------------------------------- + +/// Relation causale entre deux estampilles vectorielles. +/// +/// C'est le thĂ©orĂšme central des vector clocks (Mattern 1989) : la +/// comparaison composante par composante dĂ©cide *exactement* la relation +/// happened-before de Lamport. Quatre cas, ni plus ni moins : +/// +/// - [`Causality::Before`] — `self` est causalement antĂ©rieur (`self < other` +/// partout au sens ≀, strictement quelque part) : `other` « savait » tout +/// ce que `self` savait, et plus. +/// - [`Causality::After`] — symĂ©trique. +/// - [`Causality::Equal`] — mĂȘmes compteurs partout : mĂȘme point causal. +/// - [`Causality::Concurrent`] — **ni l'un ni l'autre** : les deux Ă©vĂ©nements +/// ont eu lieu dans des branches causalement disjointes. C'est LE cas que +/// les horloges murales ne savent pas voir, et que le xion relativiste +/// refuse de trancher en silence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Causality { + /// `self` → `other` (self est dans le passĂ© causal de other). + Before, + /// `other` → `self` (self est dans le futur causal de other). + After, + /// Aucun chemin causal entre les deux : branches disjointes. + Concurrent, + /// MĂȘme estampille : mĂȘme connaissance causale. + Equal, +} + +// --------------------------------------------------------------------------- +// VectorClock +// --------------------------------------------------------------------------- + +/// Horloge vectorielle : une map `{id de nƓud → compteur d'Ă©vĂ©nements}`. +/// +/// RĂšgles canoniques (gelĂ©es en §1 de `xion-relativiste-v0`) : +/// +/// 1. **ÉvĂ©nement local** : [`increment`](Self::increment). +/// 2. **Émission** : l'Ă©vĂ©nement part avec une copie (`clone`) du VC courant. +/// 3. **RĂ©ception** : [`merge`](Self::merge) (max composante par composante) +/// puis [`increment`](Self::increment) — ou [`observe`](Self::observe) qui +/// fait les deux. +/// +/// Invariant interne : aucun compteur Ă  zĂ©ro n'est stockĂ© — un nƓud absent de +/// la map compte pour 0 ([`get`](Self::get)). C'est ce qui rend l'Ă©galitĂ© +/// structurelle (`==`) Ă©quivalente Ă  l'Ă©galitĂ© causale, et la sĂ©rialisation +/// canonique (la map est un `BTreeMap`, donc triĂ©e, donc stable). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct VectorClock { + counters: BTreeMap, +} + +impl VectorClock { + /// Horloge vide : le point zĂ©ro causal, antĂ©rieur-ou-Ă©gal Ă  tout. + pub fn new() -> Self { + Self::default() + } + + /// Compteur du nƓud `node` (0 si jamais vu — l'absence EST le zĂ©ro). + pub fn get(&self, node: &str) -> u64 { + self.counters.get(node).copied().unwrap_or(0) + } + + /// ÉvĂ©nement local sur `node` : `VC[node] += 1` (rĂšgle 1 de la spec). + /// + /// Rend le nouveau compteur, pratique pour numĂ©roter l'Ă©vĂ©nement. + pub fn increment(&mut self, node: &str) -> u64 { + let c = self.counters.entry(node.to_string()).or_insert(0); + *c += 1; + *c + } + + /// Max composante par composante (rĂšgle 3, premiĂšre moitiĂ©). + /// + /// C'est un **join de treillis** : commutatif, associatif, idempotent. + /// AprĂšs `a.merge(&b)`, `a` domine causalement l'ancien `a` ET `b` : + /// elle « sait » tout ce que les deux savaient. + pub fn merge(&mut self, other: &VectorClock) { + for (node, &count) in &other.counters { + let c = self.counters.entry(node.clone()).or_insert(0); + if count > *c { + *c = count; + } + } + } + + /// RĂ©ception complĂšte (rĂšgle 3 entiĂšre) : merge PUIS tick local. + /// + /// Le tick final est essentiel : il fait de la rĂ©ception elle-mĂȘme un + /// Ă©vĂ©nement, causalement postĂ©rieur Ă  l'Ă©mission — c'est lui qui + /// enchaĂźne les causalitĂ©s Ă  travers le rĂ©seau. + pub fn observe(&mut self, other: &VectorClock, node: &str) -> u64 { + self.merge(other); + self.increment(node) + } + + /// Comparaison causale — le cƓur du bion. + /// + /// Parcourt l'union des nƓuds des deux horloges et note s'il existe une + /// composante oĂč `self` est plus petit, une oĂč il est plus grand. Les + /// quatre combinaisons donnent les quatre cas de [`Causality`]. + pub fn causality(&self, other: &VectorClock) -> Causality { + let mut somewhere_less = false; // ∃ k : self[k] < other[k] + let mut somewhere_greater = false; // ∃ k : self[k] > other[k] + for node in self.counters.keys().chain(other.counters.keys()) { + let a = self.get(node); + let b = other.get(node); + if a < b { + somewhere_less = true; + } else if a > b { + somewhere_greater = true; + } + if somewhere_less && somewhere_greater { + return Causality::Concurrent; // dĂ©cidĂ©, inutile de continuer + } + } + match (somewhere_less, somewhere_greater) { + (false, false) => Causality::Equal, + (true, false) => Causality::Before, + (false, true) => Causality::After, + (true, true) => unreachable!("court-circuitĂ© ci-dessus"), + } + } + + /// `self` domine-t-il `other` ? (After ou Equal : `self` sait au moins + /// tout ce que `other` sait.) C'est le filtre anti-doublon du §3 de la + /// spec : on ne retransmet jamais un tsoin dont le VC est dĂ©jĂ  dominĂ©. + pub fn dominates(&self, other: &VectorClock) -> bool { + matches!(self.causality(other), Causality::After | Causality::Equal) + } + + /// Ordre **total mais arbitraire** (lexicographique sur la map triĂ©e). + /// + /// ⚠ N'a AUCUN sens causal — sert uniquement Ă  trier de façon + /// dĂ©terministe (ordre canonique des branches d'un [`MvReg`], clĂ©s de + /// stockage
). Pour la causalitĂ©, [`causality`](Self::causality). + pub fn canonical_cmp(&self, other: &VectorClock) -> Ordering { + self.counters.cmp(&other.counters) + } + + /// SĂ©rialisation texte stable : `nƓud:compteur,nƓud:compteur`, nƓuds + /// triĂ©s (l'horloge vide donne la chaĂźne vide). Les caractĂšres rĂ©servĂ©s + /// des ids sont Ă©chappĂ©s en `%HH`. Format **gelĂ©** — v1 pour toujours. + pub fn to_text(&self) -> String { + let mut out = String::new(); + for (i, (node, count)) in self.counters.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&esc(node)); + out.push(':'); + out.push_str(&count.to_string()); + } + out + } + + /// Parse le format de [`to_text`](Self::to_text). Les entrĂ©es Ă  compteur + /// nul sont ignorĂ©es (normalisation : l'absence est le zĂ©ro). + pub fn from_text(s: &str) -> Result { + let mut vc = VectorClock::new(); + if s.is_empty() { + return Ok(vc); + } + for part in s.split(',') { + let (node, count) = part + .rsplit_once(':') + .ok_or_else(|| ParseError::new(format!("entrĂ©e sans ':' : {part:?}")))?; + let count: u64 = count + .parse() + .map_err(|_| ParseError::new(format!("compteur invalide : {count:?}")))?; + if count > 0 { + let node = unesc(node)?; + // max plutĂŽt qu'insert aveugle : un texte dupliquant un nƓud + // reste une horloge valide au lieu d'un Ă©tat dĂ©pendant de l'ordre. + if count > vc.get(&node) { + vc.counters.insert(node, count); + } + } + } + Ok(vc) + } + + /// Nombre de nƓuds connus de cette horloge. + pub fn len(&self) -> usize { + self.counters.len() + } + + /// Vrai si l'horloge est au point zĂ©ro causal. + pub fn is_empty(&self) -> bool { + self.counters.is_empty() + } + + /// ItĂšre `(nƓud, compteur)` dans l'ordre canonique (triĂ©). + pub fn iter(&self) -> impl Iterator { + self.counters.iter().map(|(n, &c)| (n.as_str(), c)) + } +} + +/// L'ordre causal est PARTIEL : `partial_cmp` rend `None` pour deux horloges +/// concurrentes — c'est le contrat exact de `PartialOrd`, et c'est toute la +/// leçon : certains couples d'Ă©vĂ©nements ne sont tout simplement pas ordonnĂ©s. +impl PartialOrd for VectorClock { + fn partial_cmp(&self, other: &Self) -> Option { + match self.causality(other) { + Causality::Before => Some(Ordering::Less), + Causality::After => Some(Ordering::Greater), + Causality::Equal => Some(Ordering::Equal), + Causality::Concurrent => None, + } + } +} + +impl fmt::Display for VectorClock { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_text()) + } +} + +impl FromStr for VectorClock { + type Err = ParseError; + fn from_str(s: &str) -> Result { + Self::from_text(s) + } +} + +// --------------------------------------------------------------------------- +// Stamped +// --------------------------------------------------------------------------- + +/// Une valeur estampillĂ©e de son horloge : « l'enveloppe causale » du §2 de +/// la spec. Le contenu ne change pas ; le VC dit *ce que l'Ă©crivain savait*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Stamped { + /// La valeur portĂ©e. + pub value: T, + /// L'estampille causale au moment de l'Ă©criture. + pub vc: VectorClock, +} + +impl Stamped { + /// Enveloppe `value` avec l'estampille `vc`. + pub fn new(value: T, vc: VectorClock) -> Self { + Self { value, vc } + } +} + +// --------------------------------------------------------------------------- +// MvReg +// --------------------------------------------------------------------------- + +/// Multi-Value Register : le registre qui **refuse d'oublier**. +/// +/// État = l'antichaĂźne des Ă©critures causalement maximales : toute Ă©criture +/// strictement dominĂ©e est Ă©laguĂ©e (elle est *dans le passĂ©* d'une autre), +/// toutes les Ă©critures **concurrentes** cohabitent. Une seule branche = +/// registre convergĂ© ; plusieurs = **fork visible** ([`is_forked`](Self::is_forked)). +/// +/// DĂ©cision RS-7, gelĂ©e (11.08, §4 de `xion-relativiste-v0`) : +/// +/// > Les branches concurrentes sont conservĂ©es et exposĂ©es comme fork +/// > visible. Jamais d'Ă©crasement silencieux. La rĂ©solution n'est jamais le +/// > fait du protocole : c'est un nouvel Ă©vĂ©nement explicite +/// > ([`resolve`](Self::resolve)) dont le VC domine toutes les branches. +/// +/// Le merge est un CRDT (commutatif, associatif, idempotent — testĂ© par +/// permutations plus bas) : l'ordre des retrouvailles ne compte pas, +/// re-merger ne coĂ»te rien, l'essaim converge sans coordinateur. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MvReg { + /// Branches vivantes, triĂ©es dans l'ordre canonique de leurs VC. + branches: Vec>, +} + +impl Default for MvReg { + fn default() -> Self { + Self { + branches: Vec::new(), + } + } +} + +impl MvReg { + /// Registre vide : rien n'a encore Ă©tĂ© Ă©crit. + pub fn new() -> Self { + Self::default() + } + + /// Les branches vivantes, chacune avec son VC (l'ordre est canonique). + pub fn branches(&self) -> &[Stamped] { + &self.branches + } + + /// Toutes les valeurs vivantes. Une seule = convergĂ© ; plusieurs = fork. + pub fn values(&self) -> Vec<&T> { + self.branches.iter().map(|s| &s.value).collect() + } + + /// Vrai si au moins deux branches concurrentes cohabitent. + pub fn is_forked(&self) -> bool { + self.branches.len() > 1 + } + + /// Nombre de branches vivantes. + pub fn len(&self) -> usize { + self.branches.len() + } + + /// Vrai si rien n'a jamais Ă©tĂ© Ă©crit (ou tout a Ă©tĂ© pris ailleurs). + pub fn is_empty(&self) -> bool { + self.branches.is_empty() + } + + /// L'horloge « somme » : le merge des VC de toutes les branches. C'est + /// le point causal qu'une rĂ©solution doit dĂ©passer pour fermer le fork. + pub fn clock(&self) -> VectorClock { + let mut vc = VectorClock::new(); + for b in &self.branches { + vc.merge(&b.vc); + } + vc + } +} + +impl MvReg { + /// Écriture par le nƓud `node` : la sĂ©mantique MVR canonique. + /// + /// La nouvelle estampille = merge des VC de toutes les branches VUES, + /// puis tick de `node`. Elle domine donc tout ce que ce rĂ©plica + /// connaissait : localement, l'Ă©criture remplace les branches — c'est un + /// Ă©crasement **causal** (l'Ă©crivain savait), jamais un Ă©crasement de + /// branches qu'il n'a pas vues. Rend l'estampille posĂ©e. + pub fn set(&mut self, node: &str, value: T) -> VectorClock { + let mut vc = self.clock(); + vc.increment(node); + self.branches.clear(); + self.branches.push(Stamped::new(value, vc.clone())); + vc + } + + /// InsĂšre une Ă©criture estampillĂ©e venue d'ailleurs (brique du merge). + /// + /// Trois cas, dans l'esprit du §3 de la spec : + /// - dĂ©jĂ  connue Ă  l'identique, ou strictement dominĂ©e → ignorĂ©e + /// (idempotence, filtre anti-doublon) ; + /// - elle domine strictement des branches → elle les Ă©lague (elles sont + /// dans son passĂ© causal) ; + /// - concurrente avec le reste → **elle s'ajoute**. Le fork devient + /// visible. Rien n'est jetĂ©. + pub fn apply(&mut self, incoming: Stamped) { + for b in &self.branches { + match b.vc.causality(&incoming.vc) { + // Une branche existante domine strictement l'arrivante. + Causality::After => return, + // MĂȘme point causal : doublon exact → idempotence. (MĂȘme VC + // mais valeur diffĂ©rente = nƓud menteur : on GARDE les deux, + // le mensonge devient un fork visible, attribuable.) + Causality::Equal if b.value == incoming.value => return, + _ => {} + } + } + // L'arrivante survit : Ă©lague ce qu'elle domine strictement. + self.branches + .retain(|b| b.vc.causality(&incoming.vc) != Causality::Before); + self.branches.push(incoming); + // Ordre canonique : l'Ă©tat est identique quel que soit l'ordre des + // merges — condition de l'Ă©galitĂ© structurelle entre rĂ©pliques. + self.branches.sort_by(|a, b| a.vc.canonical_cmp(&b.vc)); + } + + /// Ferme le fork **explicitement** : la seule façon lĂ©gitime de perdre + /// une branche. `f` reçoit toutes les branches vivantes et rend la + /// valeur de rĂ©solution ; son estampille = merge de tous les VC + tick + /// de `node`, donc elle **domine** chaque branche — le fork se referme + /// comme un merge commit, et les anciennes branches, re-mergĂ©es plus + /// tard, seront Ă©laguĂ©es comme du passĂ© (pas de rĂ©surrection). + pub fn resolve(&mut self, node: &str, f: F) -> VectorClock + where + F: FnOnce(&[Stamped]) -> T, + { + let value = f(&self.branches); + self.set(node, value) + } +} + +impl MvReg { + /// Retrouvailles : absorbe toutes les branches de `other`. + /// + /// C'est le merge CRDT du §4 de la spec — **dĂ©terministe, commutatif, + /// associatif, idempotent** (testĂ© par permutations). Cas par cas : + /// histoire ordonnĂ©e → la plus complĂšte gagne (l'autre est son passĂ©) ; + /// histoires concurrentes → les deux restent, fork visible. + pub fn merge(&mut self, other: &MvReg) { + for b in &other.branches { + self.apply(b.clone()); + } + } +} + +// --------------------------------------------------------------------------- +// SĂ©rialisation texte du MvReg +// --------------------------------------------------------------------------- + +/// En-tĂȘte du format texte MvReg. GelĂ© : un format v2 Ă©ventuel aura son +/// propre en-tĂȘte, jamais une mutation silencieuse de v1. +const MVR_HEADER: &str = "mvr1"; +/// ReprĂ©sentation d'un VC vide sur une ligne de branche (une ligne ne peut +/// pas commencer par le sĂ©parateur espace). +const EMPTY_VC: &str = "-"; + +impl MvReg { + /// SĂ©rialisation texte stable, une branche par ligne : + /// + /// ```text + /// mvr1 + /// nƓud:3,autre:1 valeur Ă©chappĂ©e + /// nƓud:2,tiers:4 autre valeur + /// ``` + /// + /// Le VC (jamais d'espace dedans, `-` si vide) et la valeur sont sĂ©parĂ©s + /// par le premier espace ; `%`, retours Ă  la ligne (et `%` de l'id) + /// Ă©chappĂ©s en `%HH`. Deux rĂ©pliques convergĂ©es produisent **exactement + /// le mĂȘme texte** (ordre canonique) — comparable au diff, hashable. + pub fn to_text(&self) -> String { + let mut out = String::from(MVR_HEADER); + for b in &self.branches { + out.push('\n'); + let vc = b.vc.to_text(); + if vc.is_empty() { + out.push_str(EMPTY_VC); + } else { + out.push_str(&vc); + } + out.push(' '); + out.push_str(&esc_value(&b.value.to_string())); + } + out + } +} + +impl fmt::Display for MvReg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_text()) + } +} + +impl MvReg { + /// Parse le format de [`to_text`](Self::to_text). Chaque branche repasse + /// par [`apply`](Self::apply) : un texte trafiquĂ© (branches dominĂ©es, + /// doublons) se renormalise au lieu de corrompre l'Ă©tat. + pub fn from_text(s: &str) -> Result { + let mut lines = s.lines(); + match lines.next() { + Some(MVR_HEADER) => {} + other => { + return Err(ParseError::new(format!( + "en-tĂȘte attendu {MVR_HEADER:?}, trouvĂ© {other:?}" + ))) + } + } + let mut reg = MvReg::new(); + for line in lines { + let (vc_part, value_part) = line + .split_once(' ') + .ok_or_else(|| ParseError::new(format!("ligne sans sĂ©parateur : {line:?}")))?; + let vc = if vc_part == EMPTY_VC { + VectorClock::new() + } else { + VectorClock::from_text(vc_part)? + }; + let raw = unesc(value_part)?; + let value = T::from_str(&raw) + .map_err(|_| ParseError::new(format!("valeur illisible : {raw:?}")))?; + reg.apply(Stamped::new(value, vc)); + } + Ok(reg) + } +} + +// --------------------------------------------------------------------------- +// ParseError + Ă©chappement +// --------------------------------------------------------------------------- + +/// Erreur de parsing des formats texte (VC ou MvReg). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError { + message: String, +} + +impl ParseError { + fn new(message: String) -> Self { + Self { message } + } +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "bion-vc: {}", self.message) + } +} + +impl std::error::Error for ParseError {} + +/// Échappe un id de nƓud : `%`, `:`, `,`, espace et retours Ă  la ligne +/// deviennent `%HH` (ASCII). Tout le reste passe tel quel (UTF-8 inclus). +fn esc(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '%' | ':' | ',' | ' ' | '\n' | '\r' => { + out.push('%'); + out.push_str(&format!("{:02X}", ch as u32)); + } + _ => out.push(ch), + } + } + out +} + +/// Échappe une valeur (dernier champ de ligne) : seuls `%` et les retours Ă  +/// la ligne sont rĂ©servĂ©s — les espaces d'une valeur restent lisibles. +fn esc_value(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '%' | '\n' | '\r' => { + out.push('%'); + out.push_str(&format!("{:02X}", ch as u32)); + } + _ => out.push(ch), + } + } + out +} + +/// DĂ©code les sĂ©quences `%HH` (l'inverse exact de [`esc`] et [`esc_value`]). +fn unesc(s: &str) -> Result { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + out.push(ch); + continue; + } + let hi = chars.next(); + let lo = chars.next(); + let (Some(hi), Some(lo)) = (hi, lo) else { + return Err(ParseError::new(format!("Ă©chappement tronquĂ© dans {s:?}"))); + }; + let byte = u32::from_str_radix(&format!("{hi}{lo}"), 16) + .map_err(|_| ParseError::new(format!("Ă©chappement invalide %{hi}{lo}")))?; + let ch = char::from_u32(byte) + .ok_or_else(|| ParseError::new(format!("Ă©chappement hors plage %{hi}{lo}")))?; + out.push(ch); + } + Ok(out) +} + +// =========================================================================== +// Tests — les propriĂ©tĂ©s CRDT sont testĂ©es PAR PERMUTATIONS : l'ordre des +// retrouvailles ne doit jamais compter, c'est la promesse de l'essaim. +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + /// Petit constructeur d'horloge pour les tests : `vc(&[("a", 2), ("b", 1)])`. + fn vc(entries: &[(&str, u64)]) -> VectorClock { + let mut v = VectorClock::new(); + for &(node, count) in entries { + for _ in 0..count { + v.increment(node); + } + } + v + } + + // ---- VectorClock : rĂšgles de base ------------------------------------- + + #[test] + fn horloge_neuve_est_vide_et_get_rend_zero() { + let v = VectorClock::new(); + assert!(v.is_empty()); + assert_eq!(v.len(), 0); + assert_eq!(v.get("jamais-vu"), 0); + } + + #[test] + fn increment_compte_les_evenements_locaux() { + let mut v = VectorClock::new(); + assert_eq!(v.increment("a"), 1); + assert_eq!(v.increment("a"), 2); + assert_eq!(v.increment("b"), 1); + assert_eq!(v.get("a"), 2); + assert_eq!(v.get("b"), 1); + assert_eq!(v.len(), 2); + } + + #[test] + fn merge_prend_le_max_composante_par_composante() { + let mut a = vc(&[("a", 3), ("b", 1)]); + let b = vc(&[("b", 5), ("c", 2)]); + a.merge(&b); + assert_eq!(a.get("a"), 3); + assert_eq!(a.get("b"), 5); + assert_eq!(a.get("c"), 2); + } + + #[test] + fn observe_fait_merge_puis_tick_regle_trois() { + // RĂšgle 3 de la spec : VC ← max(VC, reçu), puis VC[i] += 1. + let mut bord = vc(&[("bord", 2)]); + let recu = vc(&[("sol", 4), ("bord", 1)]); + let n = bord.observe(&recu, "bord"); + assert_eq!(n, 3); + assert_eq!(bord.get("bord"), 3); + assert_eq!(bord.get("sol"), 4); + // La rĂ©ception est un Ă©vĂ©nement : elle domine strictement l'Ă©mission. + assert_eq!(bord.causality(&recu), Causality::After); + } + + // ---- CausalitĂ© : les quatre cas --------------------------------------- + + #[test] + fn causalite_les_quatre_cas() { + let e1 = vc(&[("a", 1)]); + let e2 = vc(&[("a", 1), ("b", 1)]); // e2 a vu e1 puis avancĂ© + let e3 = vc(&[("c", 1)]); // ligne d'univers disjointe + + assert_eq!(e1.causality(&e2), Causality::Before); + assert_eq!(e2.causality(&e1), Causality::After); + assert_eq!(e1.causality(&e1.clone()), Causality::Equal); + assert_eq!(e1.causality(&e3), Causality::Concurrent); + assert_eq!(e3.causality(&e1), Causality::Concurrent); + // L'horloge vide est le passĂ© de tout le monde. + assert_eq!(VectorClock::new().causality(&e1), Causality::Before); + assert_eq!( + VectorClock::new().causality(&VectorClock::new()), + Causality::Equal + ); + } + + #[test] + fn partial_ord_rend_none_pour_la_concurrence() { + let e1 = vc(&[("a", 2)]); + let e2 = vc(&[("a", 2), ("b", 1)]); + let e3 = vc(&[("b", 3)]); + assert_eq!(e1.partial_cmp(&e2), Some(Ordering::Less)); + assert_eq!(e2.partial_cmp(&e1), Some(Ordering::Greater)); + assert_eq!(e1.partial_cmp(&e1.clone()), Some(Ordering::Equal)); + assert_eq!(e1.partial_cmp(&e3), None); // concurrents : PAS d'ordre + assert!(e1 < e2); // les opĂ©rateurs marchent sur les cas ordonnĂ©s + // ...et les concurrents sont INCOMPARABLES dans les deux sens. + assert_eq!(e3.partial_cmp(&e1), None); + assert_ne!(e1, e3); + } + + #[test] + fn dominates_est_le_filtre_anti_doublon() { + let petit = vc(&[("a", 1)]); + let grand = vc(&[("a", 2), ("b", 1)]); + let ailleurs = vc(&[("c", 1)]); + assert!(grand.dominates(&petit)); + assert!(grand.dominates(&grand.clone())); // Equal domine aussi (≄) + assert!(!petit.dominates(&grand)); + assert!(!grand.dominates(&ailleurs)); // concurrent : on transporte + } + + // ---- PropriĂ©tĂ©s CRDT du merge de VC (par permutations) ---------------- + + #[test] + fn merge_vc_commutatif_associatif_idempotent() { + let samples = [ + VectorClock::new(), + vc(&[("a", 1)]), + vc(&[("a", 3), ("b", 2)]), + vc(&[("b", 5), ("c", 1)]), + vc(&[("a", 2), ("c", 4)]), + ]; + for x in &samples { + for y in &samples { + // CommutativitĂ© : x⊔y == y⊔x + let mut xy = x.clone(); + xy.merge(y); + let mut yx = y.clone(); + yx.merge(x); + assert_eq!(xy, yx, "commutativitĂ© violĂ©e : {x} ⊔ {y}"); + // Idempotence : (x⊔y)⊔y == x⊔y + let mut again = xy.clone(); + again.merge(y); + assert_eq!(again, xy, "idempotence violĂ©e : {x} ⊔ {y}"); + for z in &samples { + // AssociativitĂ© : (x⊔y)⊔z == x⊔(y⊔z) + let mut left = xy.clone(); + left.merge(z); + let mut yz = y.clone(); + yz.merge(z); + let mut right = x.clone(); + right.merge(&yz); + assert_eq!(left, right, "associativitĂ© violĂ©e : {x},{y},{z}"); + } + } + } + } + + // ---- MvReg : sĂ©mantique du fork visible -------------------------------- + + #[test] + fn ecrivain_seul_pas_de_fork() { + let mut r = MvReg::new(); + r.set("a", "v1"); + r.set("a", "v2"); // il a vu v1 : Ă©crasement CAUSAL, lĂ©gitime + assert!(!r.is_forked()); + assert_eq!(r.values(), vec![&"v2"]); + assert_eq!(r.clock().get("a"), 2); + } + + #[test] + fn ecritures_concurrentes_fork_visible_jamais_d_ecrasement() { + // Deux rĂ©pliques du mĂȘme point divergent puis se retrouvent. + let mut bord = MvReg::new(); + bord.set("bord", "cap Europe"); + let mut sol = MvReg::new(); + sol.set("sol", "cap Titan"); + + bord.merge(&sol); + assert!(bord.is_forked()); + assert_eq!(bord.len(), 2); + let mut vals: Vec<&&str> = bord.values(); + vals.sort(); + assert_eq!(vals, vec![&"cap Europe", &"cap Titan"]); + // Et symĂ©triquement : personne ne perd rien. + sol.merge(&bord); + assert_eq!(sol.len(), 2); + } + + #[test] + fn histoire_ordonnee_la_plus_complete_gagne_sans_conflit() { + // e1 → e2 : e2 raffine e1, le merge est l'union des logs (§4 cas 1). + let mut a = MvReg::new(); + a.set("a", "brouillon"); + let mut b = a.clone(); // b a vu le brouillon + b.set("b", "version finale"); + a.merge(&b); + assert!(!a.is_forked()); + assert_eq!(a.values(), vec![&"version finale"]); + } + + #[test] + fn set_apres_merge_ferme_le_fork_car_l_ecrivain_a_vu() { + let mut a = MvReg::new(); + a.set("a", 1); + let mut b = MvReg::new(); + b.set("b", 2); + a.merge(&b); + assert!(a.is_forked()); + let vc_resolution = a.set("a", 3); // a a VU les deux branches + assert!(!a.is_forked()); + assert!(vc_resolution.get("a") >= 2 && vc_resolution.get("b") >= 1); + } + + #[test] + fn merge_mvreg_commutatif_toutes_permutations() { + // Trois rĂ©pliques concurrentes ; on les merge dans les 6 ordres + // possibles : l'Ă©tat final DOIT ĂȘtre identique (essaim sans + // coordinateur — l'ordre des retrouvailles ne compte pas). + let mut a = MvReg::new(); + a.set("a", "alpha".to_string()); + let mut b = MvReg::new(); + b.set("b", "beta".to_string()); + let mut c = MvReg::new(); + c.set("c", "gamma".to_string()); + let regs = [&a, &b, &c]; + + let orders: [[usize; 3]; 6] = [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ]; + let mut results = Vec::new(); + for order in orders { + let mut acc = MvReg::new(); + for i in order { + acc.merge(regs[i]); + } + results.push(acc); + } + for r in &results[1..] { + assert_eq!(r, &results[0], "l'ordre des merges a changĂ© l'Ă©tat"); + } + assert_eq!(results[0].len(), 3); // les trois branches, toutes visibles + } + + #[test] + fn merge_mvreg_associatif() { + let mut a = MvReg::new(); + a.set("a", 10); + let mut b = MvReg::new(); + b.set("b", 20); + let mut c = MvReg::new(); + c.set("c", 30); + + // (a ⊔ b) ⊔ c + let mut left = a.clone(); + left.merge(&b); + left.merge(&c); + // a ⊔ (b ⊔ c) + let mut bc = b.clone(); + bc.merge(&c); + let mut right = a.clone(); + right.merge(&bc); + + assert_eq!(left, right); + } + + #[test] + fn merge_mvreg_idempotent() { + let mut a = MvReg::new(); + a.set("a", "x"); + let mut b = MvReg::new(); + b.set("b", "y"); + a.merge(&b); + let snapshot = a.clone(); + a.merge(&b); // re-merger ne coĂ»te rien + a.merge(&snapshot.clone()); // mĂȘme avec soi-mĂȘme + assert_eq!(a, snapshot); + } + + #[test] + fn resolve_ferme_le_fork_et_domine_toutes_les_branches() { + let mut a = MvReg::new(); + a.set("a", "gauche".to_string()); + let mut b = MvReg::new(); + b.set("b", "droite".to_string()); + a.merge(&b); + assert!(a.is_forked()); + + let anciennes: Vec = a.branches().iter().map(|s| s.vc.clone()).collect(); + let vc_res = a.resolve("humain", |branches| { + let mut vals: Vec<&str> = branches.iter().map(|s| s.value.as_str()).collect(); + vals.sort(); // rĂ©solution dĂ©terministe, de domaine + vals.join("+") + }); + assert!(!a.is_forked()); + assert_eq!(a.values(), vec![&"droite+gauche".to_string()]); + // Le VC de rĂ©solution domine STRICTEMENT chaque ancienne branche : + // c'est ce qui fait du fork fermĂ© un merge commit, pas un oubli. + for old in &anciennes { + assert_eq!(vc_res.causality(old), Causality::After); + } + } + + #[test] + fn pas_de_resurrection_apres_resolution() { + let mut a = MvReg::new(); + a.set("a", 1); + let mut b = MvReg::new(); + b.set("b", 2); + let b_avant = b.clone(); + a.merge(&b); + a.resolve("humain", |_| 99); + // Une vieille branche re-mergĂ©e (mule DTN en retard, §5) est du + // passĂ© causal : elle ne rouvre PAS le fork. + a.merge(&b_avant); + assert!(!a.is_forked()); + assert_eq!(a.values(), vec![&99]); + } + + #[test] + fn apply_ignore_le_domine_et_garde_le_concurrent() { + let mut r: MvReg<&str> = MvReg::new(); + r.apply(Stamped::new("rĂ©cent", vc(&[("a", 2)]))); + r.apply(Stamped::new("vieux", vc(&[("a", 1)]))); // dominĂ© → ignorĂ© + assert_eq!(r.values(), vec![&"rĂ©cent"]); + r.apply(Stamped::new("ailleurs", vc(&[("b", 1)]))); // concurrent → gardĂ© + assert_eq!(r.len(), 2); + r.apply(Stamped::new("rĂ©cent", vc(&[("a", 2)]))); // doublon exact → no-op + assert_eq!(r.len(), 2); + } + + // ---- SĂ©rialisation ------------------------------------------------------ + + #[test] + fn vc_texte_aller_retour_stable() { + let cas = [ + VectorClock::new(), + vc(&[("a", 1)]), + vc(&[("cubion-bord", 42), ("cubion-sol", 7)]), + vc(&[("id bizarre:avec,tout %", 3), ("Ă©ĂŒĂ±", 2)]), // Ă©chappement + UTF-8 + ]; + for v in &cas { + let txt = v.to_text(); + let back = VectorClock::from_text(&txt).expect("re-parse"); + assert_eq!(&back, v, "aller-retour cassĂ© pour {txt:?}"); + } + // Format canonique exact, gelĂ© : nƓuds triĂ©s, `nƓud:compteur` par virgules. + assert_eq!(vc(&[("b", 1), ("a", 2)]).to_text(), "a:2,b:1"); + assert_eq!(VectorClock::new().to_text(), ""); + } + + #[test] + fn vc_from_text_normalise_et_rejette_le_malforme() { + // Compteur nul ignorĂ© : l'absence est le zĂ©ro. + let v = VectorClock::from_text("a:0,b:2").unwrap(); + assert_eq!(v.len(), 1); + assert_eq!(v.get("b"), 2); + // Erreurs propres, jamais de panique. + assert!(VectorClock::from_text("sans-deux-points").is_err()); + assert!(VectorClock::from_text("a:pas-un-nombre").is_err()); + assert!(VectorClock::from_text("a%GG:1").is_err()); // Ă©chappement invalide + assert!(VectorClock::from_text("a%2:1").is_err()); // Ă©chappement tronquĂ© + } + + #[test] + fn mvreg_texte_aller_retour_fork_compris() { + let mut a = MvReg::new(); + a.set("bord", "cap Europe, vite".to_string()); // virgule + espaces dans la valeur + let mut b = MvReg::new(); + b.set("sol", "ligne1%ligne2".to_string()); // caractĂšre rĂ©servĂ© + a.merge(&b); + assert!(a.is_forked()); + + let txt = a.to_text(); + let back: MvReg = MvReg::from_text(&txt).expect("re-parse"); + assert_eq!(back, a); + // Deux rĂ©pliques convergĂ©es → texte identique (ordre canonique). + let mut c = MvReg::new(); + c.merge(&a); + assert_eq!(c.to_text(), txt); + // Registre vide : juste l'en-tĂȘte. + assert_eq!(MvReg::::new().to_text(), "mvr1"); + assert_eq!(MvReg::::from_text("mvr1").unwrap(), MvReg::new()); + } + + #[test] + fn mvreg_from_text_rejette_le_malforme() { + assert!(MvReg::::from_text("").is_err()); // pas d'en-tĂȘte + assert!(MvReg::::from_text("pasbon\na:1 x").is_err()); + assert!(MvReg::::from_text("mvr1\nsans-separateur").is_err()); + assert!(MvReg::::from_text("mvr1\na:1 pas-un-entier").is_err()); + } + + #[test] + fn mvreg_from_text_renormalise_les_branches_dominees() { + // Un texte trafiquĂ© contenant une branche dominĂ©e : apply() l'Ă©lague + // au parse — l'Ă©tat reste une antichaĂźne, jamais corrompu. + let txt = "mvr1\na:1 vieux\na:2 recent"; + let r: MvReg = MvReg::from_text(txt).unwrap(); + assert_eq!(r.values(), vec![&"recent".to_string()]); + } + + #[test] + fn meme_vc_valeurs_differentes_fork_visible_pas_d_arbitrage() { + // NƓud menteur (§7.5) : deux valeurs sous la MÊME estampille. Le + // protocole n'arbitre pas en silence : les deux restent visibles. + let mut r: MvReg<&str> = MvReg::new(); + r.apply(Stamped::new("version-1", vc(&[("a", 1)]))); + r.apply(Stamped::new("version-2", vc(&[("a", 1)]))); + assert!(r.is_forked()); + assert_eq!(r.len(), 2); + } +}