🦀 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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
105
Cargo.lock
generated
Normal file
105
Cargo.lock
generated
Normal 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
16
Cargo.toml
Normal 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
62
README.md
Normal 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
11
bion-git/Cargo.toml
Normal 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
116
bion-git/README.md
Normal 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
986
bion-git/src/lib.rs
Normal 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
161
bion-git/src/sha1.rs
Normal 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
8
bion-kv/Cargo.toml
Normal 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
93
bion-kv/README.md
Normal 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
137
bion-kv/src/crc32.rs
Normal 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
712
bion-kv/src/lib.rs
Normal 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
8
bion-regex/Cargo.toml
Normal 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
126
bion-regex/README.md
Normal 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
162
bion-regex/src/lib.rs
Normal 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
445
bion-regex/src/nfa.rs
Normal 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
449
bion-regex/src/parser.rs
Normal 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
283
bion-regex/tests/regex.rs
Normal 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
11
bion-triplet/Cargo.toml
Normal 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
106
bion-triplet/README.md
Normal 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
542
bion-triplet/src/lib.rs
Normal 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
8
bion-tsoinlog/Cargo.toml
Normal 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
91
bion-tsoinlog/README.md
Normal 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
721
bion-tsoinlog/src/lib.rs
Normal 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
10
bion-vc/Cargo.toml
Normal 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
104
bion-vc/README.md
Normal 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
1044
bion-vc/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user