🦀 bions-rust vague 1 : 6 briques build-your-own-x — on ne les rebuild plus jamais

Principe RS-7 : « dès qu'on build un truc, plus personne n'a à le rebuild —
la seule chose à faire est l'optimisation. » (nexus/RepoVerse)
- bion-vc      : horloges vectorielles + MvReg fork-visible (LA spec
                 xion-relativiste-v0 enfin codée — CRDT testé par permutations)
- bion-triplet : l'Adressage Génératif (gen_hash BLAKE3, coords, résidu ;
                 résidu vide quand déjà-su ; align décidable au bit)
- bion-tsoinlog: journal append-only rejouable (CRC32 maison, crash-recovery)
- bion-kv      : magasin clé-valeur bitcask (compaction atomique, tombstones)
- bion-regex   : moteur Thompson NFA linéaire (jamais exponentiel — Russ Cox)
- bion-git     : mini-git content-addressed (SHA-1 maison + vecteurs officiels,
                 branches divergentes = le fork visible)
129 tests verts, clippy 0 warning, doc française = chaque bion est un cours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cloudion-labo
2026-08-16 01:07:31 +00:00
commit 2556698dd3
27 changed files with 6518 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

105
Cargo.lock generated Normal file
View File

@@ -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"

16
Cargo.toml Normal file
View File

@@ -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",
]

62
README.md Normal file
View File

@@ -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é.

11
bion-git/Cargo.toml Normal file
View File

@@ -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]

116
bion-git/README.md Normal file
View File

@@ -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/<nom>` — 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 <email>` ; 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.

986
bion-git/src/lib.rs Normal file
View File

@@ -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<Id> {
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> {
Id::from_hex(s)
}
}
fn hex_val(c: u8) -> io::Result<u8> {
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<Kind> {
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<Id>,
/// 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/<branche> → 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<Repo> {
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<Repo> {
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<Id> {
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<u8>)> {
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<Id> {
self.write_tree_inner(dir_snapshot)
}
fn write_tree_inner(&self, dir: &Path) -> io::Result<Id> {
// (clé de tri, octets d'entrée) — la clé traite un répertoire comme "nom/".
let mut entries: Vec<(Vec<u8>, Vec<u8>)> = 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<Vec<TreeEntry>> {
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<Id> {
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 <chaîne libre> <ts> +0000
/// committer <chaîne libre> <ts> +0000
///
/// <message verbatim>
/// ```
///
/// `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<Id> {
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<Commit> {
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 <chaîne libre> <ts> +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::<u64>()
.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<Vec<Commit>> {
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/<name>`.
/// 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<Id> {
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<Vec<String>> {
let mut out: Vec<String> = 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 <rs1@xerboxion>", 1_755_300_000)
.unwrap();
let id_bis = repo
.commit_at(tree, &[], msg, "rs-1 <rs1@xerboxion>", 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 <rs1@xerboxion>");
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::<Vec<_>>(),
["trois", "deux", "un"]
);
assert_eq!(histoire[2].parents, Vec::<Id>::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::<Vec<_>>(),
["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::<Id>().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);
}
}

161
bion-git/src/sha1.rs Normal file
View File

@@ -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}");
}
}
}

8
bion-kv/Cargo.toml Normal file
View File

@@ -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]

93
bion-kv/README.md Normal file
View File

@@ -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<Vec<u8>>` — 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<Vec<u8>, _>` → 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.

137
bion-kv/src/crc32.rs Normal file
View File

@@ -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);
}
}

712
bion-kv/src/lib.rs Normal file
View File

@@ -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<u8>`),
/// 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<Vec<u8>, 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<P: AsRef<Path>>(dir: P) -> io::Result<Kv> {
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<Option<Vec<u8>>> {
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<Vec<u8>, Slot> = HashMap::with_capacity(self.index.len());
// (tri des clés = sortie déterministe, agréable pour tester/diff-er)
let mut keys: Vec<Vec<u8>> = 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<Item = &[u8]> {
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<u64> {
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<u8> {
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()[..])
);
}
}

8
bion-regex/Cargo.toml Normal file
View File

@@ -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]

126
bion-regex/README.md Normal file
View File

@@ -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 »** — <https://swtch.com/~rsc/regexp/regexp1.html>. À 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 :
<https://github.com/codecrafters-io/build-your-own-x#build-your-own-regex-engine>.
## 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<Regex, Error>` — 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.

162
bion-regex/src/lib.rs Normal file
View File

@@ -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* (<https://swtch.com/~rsc/regexp/regexp1.html>).
//!
//! ## 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<Regex, Error> {
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 {}

445
bion-regex/src/nfa.rs Normal file
View File

@@ -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*
//! (<https://swtch.com/~rsc/regexp/regexp1.html>).
//!
//! # 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<State>,
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<State> = 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<State>) -> 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<usize>,
/// 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<usize>,
seen: Vec<bool>,
}
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<usize> {
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"));
}
}

449
bion-regex/src/parser.rs Normal file
View File

@@ -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<ClassItem>,
},
}
/// 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<ClassItem> {
vec![ClassItem::Range('0', '9')]
}
/// `\w` = `[a-zA-Z0-9_]`.
fn class_word() -> Vec<ClassItem> {
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<ClassItem> {
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<Ast>, Box<Ast>),
/// Alternance `a|b` : le gauche OU le droit.
Alt(Box<Ast>, Box<Ast>),
/// `a*` : zéro ou plusieurs fois.
Star(Box<Ast>),
/// `a+` : une ou plusieurs fois.
Plus(Box<Ast>),
/// `a?` : zéro ou une fois.
Quest(Box<Ast>),
/// `^` : 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<Ast, Error> {
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<char>,
pos: usize,
}
impl Parser {
fn peek(&self) -> Option<char> {
self.chars.get(self.pos).copied()
}
fn peek2(&self) -> Option<char> {
self.chars.get(self.pos + 1).copied()
}
fn bump(&mut self) -> Option<char> {
let c = self.peek();
if c.is_some() {
self.pos += 1;
}
c
}
/// alternance := concat ('|' concat)*
fn parse_alt(&mut self) -> Result<Ast, Error> {
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<Ast, Error> {
let mut node: Option<Ast> = 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<Ast, Error> {
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<Matcher, Error> {
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<Matcher, Error> {
let negated = if self.peek() == Some('^') {
self.bump();
true
} else {
false
};
let mut items: Vec<ClassItem> = 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<ClassEsc, Error> {
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<ClassItem>),
}
// ---------------------------------------------------------------------------
// 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'));
}
}

283
bion-regex/tests/regex.rs Normal file
View File

@@ -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"));
}

11
bion-triplet/Cargo.toml Normal file
View File

@@ -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 }

106
bion-triplet/README.md Normal file
View File

@@ -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.*

542
bion-triplet/src/lib.rs Normal file
View File

@@ -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<u8>,
/// 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<u8>;
/// 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<G: Generator>(generator: &G, data: &[u8], coords: Vec<u8>) -> (Triplet, Vec<u8>) {
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<G: Generator>(
generator: &G,
triplet: &Triplet,
residual: &[u8],
) -> Result<Vec<u8>, 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<u8> {
len.to_le_bytes().to_vec()
}
}
impl Generator for ConstGenerator {
fn generate(&self, coords: &[u8]) -> Vec<u8> {
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<u8>,
}
impl PatternGenerator {
/// Un générateur qui répète `pattern` en boucle.
pub fn new(pattern: Vec<u8>) -> Self {
Self { pattern }
}
/// Fabrique les coordonnées : décalage dans le motif + longueur attendue.
pub fn coords(offset: u64, len: u64) -> Vec<u8> {
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<u8> {
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<u8> {
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::<u8>::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);
}
}

8
bion-tsoinlog/Cargo.toml Normal file
View File

@@ -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]

91
bion-tsoinlog/README.md Normal file
View File

@@ -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<Seq>` | appende un événement |
| `iter()` / `iter_from(Seq)` | itère (instantané cohérent, seek O(1)) |
| `replay(from, to, f) -> io::Result<u64>` | 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.

721
bion-tsoinlog/src/lib.rs Normal file
View File

@@ -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<u8>,
}
/// 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<u64>,
/// 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<P: AsRef<Path>>(path: P) -> io::Result<Self> {
Self::open_with(path, Sync::default())
}
/// Comme [`TsoinLog::open`] mais en choisissant la politique de `fsync`.
pub fn open_with<P: AsRef<Path>>(path: P, sync: Sync) -> io::Result<Self> {
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<Seq> {
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<Iter> {
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<Iter> {
// 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<F>(&self, from: Seq, to: Seq, mut f: F) -> io::Result<u64>
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<R: Read>(reader: &mut R, off: u64, limit: u64) -> Option<(u64, Vec<u8>)> {
// 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<u8>) -> io::Result<Record> {
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<File>,
offset: u64,
end: u64,
next_seq: Seq,
}
impl Iterator for Iter {
type Item = io::Result<Record>;
fn next(&mut self) -> Option<Self::Item> {
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<Record> = 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<Record> = 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<Record> = 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<Record> = log.iter().unwrap().map(|r| r.unwrap()).collect();
assert_eq!(
recs.iter().map(|r| r.payload[0]).collect::<Vec<_>>(),
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<Record> = 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<Record> = 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<Record> = 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);
}
}

10
bion-vc/Cargo.toml Normal file
View File

@@ -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]

104
bion-vc/README.md Normal file
View File

@@ -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<T>`** — une valeur + son estampille causale (l'enveloppe du tsoin, §2 de la spec).
- **`MvReg<T>`** — 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<str>`/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.*

1044
bion-vc/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff