From 2556698dd39d15ca9e3ceb381f6a35f1eff254ba Mon Sep 17 00:00:00 2001 From: cloudion-labo Date: Sun, 16 Aug 2026 01:07:31 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A6=80=20bions-rust=20vague=201=20:=206?= =?UTF-8?q?=20briques=20build-your-own-x=20=E2=80=94=20on=20ne=20les=20reb?= =?UTF-8?q?uild=20plus=20jamais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + Cargo.lock | 105 ++++ Cargo.toml | 16 + README.md | 62 +++ bion-git/Cargo.toml | 11 + bion-git/README.md | 116 +++++ bion-git/src/lib.rs | 986 +++++++++++++++++++++++++++++++++++ bion-git/src/sha1.rs | 161 ++++++ bion-kv/Cargo.toml | 8 + bion-kv/README.md | 93 ++++ bion-kv/src/crc32.rs | 137 +++++ bion-kv/src/lib.rs | 712 +++++++++++++++++++++++++ bion-regex/Cargo.toml | 8 + bion-regex/README.md | 126 +++++ bion-regex/src/lib.rs | 162 ++++++ bion-regex/src/nfa.rs | 445 ++++++++++++++++ bion-regex/src/parser.rs | 449 ++++++++++++++++ bion-regex/tests/regex.rs | 283 ++++++++++ bion-triplet/Cargo.toml | 11 + bion-triplet/README.md | 106 ++++ bion-triplet/src/lib.rs | 542 +++++++++++++++++++ bion-tsoinlog/Cargo.toml | 8 + bion-tsoinlog/README.md | 91 ++++ bion-tsoinlog/src/lib.rs | 721 +++++++++++++++++++++++++ bion-vc/Cargo.toml | 10 + bion-vc/README.md | 104 ++++ bion-vc/src/lib.rs | 1044 +++++++++++++++++++++++++++++++++++++ 27 files changed, 6518 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 bion-git/Cargo.toml create mode 100644 bion-git/README.md create mode 100644 bion-git/src/lib.rs create mode 100644 bion-git/src/sha1.rs create mode 100644 bion-kv/Cargo.toml create mode 100644 bion-kv/README.md create mode 100644 bion-kv/src/crc32.rs create mode 100644 bion-kv/src/lib.rs create mode 100644 bion-regex/Cargo.toml create mode 100644 bion-regex/README.md create mode 100644 bion-regex/src/lib.rs create mode 100644 bion-regex/src/nfa.rs create mode 100644 bion-regex/src/parser.rs create mode 100644 bion-regex/tests/regex.rs create mode 100644 bion-triplet/Cargo.toml create mode 100644 bion-triplet/README.md create mode 100644 bion-triplet/src/lib.rs create mode 100644 bion-tsoinlog/Cargo.toml create mode 100644 bion-tsoinlog/README.md create mode 100644 bion-tsoinlog/src/lib.rs create mode 100644 bion-vc/Cargo.toml create mode 100644 bion-vc/README.md create mode 100644 bion-vc/src/lib.rs 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); + } +}