Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Construction des manifestes & Merkle DAG dag Décidé

Comment une arborescence de fichiers devient un graphe de nœuds adressés par le contenu. Quatre décisions tranchées : convergence différenciée, découpage des manifestes par le contenu, CBOR canonique, fichiers à flux nommés.

En clair — recettes, annuaires, racines Un fichier est découpé en morceaux (chunks). Le manifeste, c'est la recette : la liste des morceaux qui recomposent le fichier — sans nom, sans date, que du contenu. Le nœud répertoire, c'est l'annuaire : « presentation.pptx → recette X », avec les dates, les droits, le propriétaire. La racine de snapshot, c'est la photo d'ensemble datée : « le 2 juillet à 3h00, la machine ressemblait à ça ». Toute cette page décrit comment on fabrique ces objets — et pourquoi chaque détail compte pour la déduplication.

Chaque nœud est nommé par le hash de son contenu chiffré (adresse = BLAKE3(chiffré)) → immuable, auto-vérifiable, déduplicable.

Le modèle objet — cinq types de nœuds

Comme Git (blob / tree / commit), adapté au chiffrement.

  • Chunk — un morceau de données brut, la feuille. Adresse = BLAKE3(chiffré).
  • Manifeste de fichier — la recette d'un flux de contenu : la liste ordonnée des références de ses chunks.
  • Arbre de manifestes — pour un gros fichier, le manifeste est lui-même découpé en sous-manifestes.
  • Nœud répertoire — une liste nom → référence plus les métadonnées de chaque entrée.
  • Racine de snapshot — pointe vers le répertoire de tête plus les métadonnées du snapshot.

Pourquoi un DAG, pas un arbre

Dans un arbre pur, chaque nœud a un seul parent. Ici, grâce à la dédup, un même nœud est référencé par plusieurs parents — deux fichiers qui partagent un bloc, deux snapshots qui partagent un fichier. Ces nœuds partagés font un graphe orienté acyclique (DAG), exactement comme les objets Git. Ce partage est la déduplication.


Décision 1 — Qui est convergent, qui est aléatoire

En clair — c'est quoi, « convergent » ? Chiffré convergent = la même chose chiffrée deux fois donne exactement le même résultat (la clé est calculée à partir du contenu + le secret du tenant). Si le PC d'Alice et le PC de Bob chiffrent le même logo.png, le serveur reçoit deux fois le même blob et n'en garde qu'un : c'est la déduplication. Chiffré aléatoire = chaque chiffrement tire un aléa, le résultat diffère à chaque fois : aucun partage possible, mais aussi aucune devinette possible. Car le convergent a un prix : qui possède déjà un fichier candidat peut vérifier s'il existe chez toi (l'attaque par confirmation). Tout le choix est là : dédup + devinettes, ou ni l'un ni l'autre.

La décision crypto traitait « les métadonnées » d’un bloc : tout en nonce aléatoire. En y regardant de près, les métadonnées n’ont pas toutes le même profil — on tranche donc par type de nœud, selon deux questions : que gagne-t-on à déduplicer ? et que risque-t-on à laisser deviner ?

La règle retenue Tout nœud dérivé purement du contenu → convergent (chunks, manifestes de fichier).
Tout nœud portant des noms ou de l'horloge → nonce aléatoire (répertoires, racines), avec partage par référence explicite.

Pourquoi les manifestes peuvent être convergents — l’oracle est déjà payé. Une recette, c’est la liste des adresses et clés de tous les morceaux du fichier. Pour fabriquer une recette candidate à deviner, il faut donc… posséder le fichier entier. Mais qui possède le fichier entier peut déjà le confirmer morceau par morceau, via l’oracle des chunks qu’on a accepté. Rendre les manifestes convergents ne donne rien de plus à l’attaquant — et offre une vraie dédup : la recette ne contenant ni nom ni date, deux machines qui ont le même fichier partagent la même recette même si leurs répertoires diffèrent par ailleurs. C’est le cas commercial type — la flotte de machines semblables — où l’onboarding de la N-ième machine ne coûte presque rien.

Pourquoi les répertoires restent aléatoires — tout risque, zéro gain. L’annuaire porte les noms (« existe-t-il un dossier licenciements-2026/ ? » — très devinable, très sensible) et les dates. Et il ne dédupliquerait presque jamais : les mtimes diffèrent toujours un peu d’une machine à l’autre. Convergence inutile et dangereuse → nonce aléatoire. Les racines de snapshot sont uniques par construction (date, hôte) : zéro valeur de dédup → aléatoire aussi. L’incrémental n’en souffre pas : le client stateless récupère de toute façon l’arbre du snapshot parent pour détecter les changements, et réutilise par référence les sous-arbres inchangés.

Deux propriétés qui verrouillent le raisonnement :

  • La convergence se compose vers le haut. Un nœud ne peut être convergent que si les références de ses enfants sont déterministes. Manifestes convergents → référençables de façon stable. Répertoires aléatoires → référencés par leurs parents (aléatoires aussi). Aucun conflit : le convergent ne référence que du convergent.
  • Le point de comparaison. borg, restic et kopia dédupliquent toutes leurs métadonnées, noms compris (adressage par hash/HMAC du clair). Notre choix est plus conservateur que les trois — on ne rend convergent que l'indevinable — tout en gardant l'essentiel du gain.
Impact sur la doc crypto Ce choix précise la règle « métadonnées = nonce aléatoire » de la solution retenue : elle reste vraie pour les répertoires et racines, mais les manifestes de fichier — purs contenus — passent au régime convergent. La page crypto est mise à jour en conséquence.

Le manifeste de fichier

À cause du chiffrement convergent des chunks, la recette ne liste pas que des hashes : il faut aussi la clé de chaque chunk, impossible à re-dériver à la restauration sans le clair. C’est le surcoût métadonnées 2×.

FileManifest {                                   // un flux de contenu, rien d'autre
  entries: [
    { addr: 32 o, clé: 32 o, len_clair: varint } // un chunk
    | { hole: varint }                           // un trou (fichier sparse)
    …                                            // ordre = ordre du fichier
  ]
}
  • addr pour aller chercher le blob, clé pour le déchiffrer, len_clair pour les frontières.
  • L’offset de chaque chunk se déduit par somme préfixe des len — inutile de le stocker, et ça permet le seek (restaurer une plage d’octets sans tout lire).
  • Une entrée hole dit « ici, N octets de vide » : un disque de VM de 100 Go rempli à 20 % ne stocke aucun chunk de zéros, et se restaure à l’identique.
  • Le flag de compression (brut|zstd) est dans le payload chiffré du chunk, pas dans le manifeste.
  • Rien d’autre : ni nom, ni date, ni droits — voir la règle d’or de la décision 4.

Décision 2 — Gros fichiers : l’arbre de manifestes, découpé par le contenu

Un fichier de 1 Tio à 2 Mio de chunk ≈ 500 000 entrées → une recette plate de ~32 Mo, à télécharger et déchiffrer en entier pour restaurer le moindre octet. Trop lourd : on découpe la recette en pages (sous-manifestes), avec un sommaire qui pointe vers les pages — récursivement si besoin. Reste à décider où couper les pages.

En clair — le piège des pages de taille fixe Option naïve : une page = 1 000 lignes, point. Mais insère trois lignes au début de la recette (des données ajoutées en tête du fichier) : tout se décale, page 1, page 2, … toutes les pages changent, tout est re-stocké alors que 99,9 % n'a pas bougé. C'est exactement le défaut des blocs de taille fixe qu'on a refusé pour les données en choisissant FastCDC. Utiliser la solution intelligente pour les octets et la naïve pour les recettes serait incohérent.
Décision — hashsplit récursif On coupe la page quand une entrée a une « forme » particulière — son adresse satisfait un masque de bits (cible ~1 024 entrées/nœud, min/max bornés), technique éprouvée par bup. Les frontières « collent » au contenu : une insertion ne change qu'une ou deux pages au lieu de toutes. Déterministe, car les adresses des enfants le sont (décision 1 — la composition, encore). Les paramètres (masque, cible, min/max) rejoignent le profil épinglé du tenant, même discipline que le chunker.

Coût réel : nul pour presque tout le monde. À 2 Mio de chunk moyen, un fichier < ~2 Gio tient en une seule page — l’arbre ne s’active que pour les fichiers réellement énormes.

Décision 3 — Sérialisation : CBOR canonique

En clair — le formulaire, pas la lettre libre Avant de chiffrer un nœud, il faut l'écrire en octets. Or l'adresse d'un nœud = l'empreinte de ses octets. Si le client d'Alice écrit {nom: "a", taille: 5} et celui de Bob {taille: 5, nom: "a"} — même information, octets différents — les adresses diffèrent et la dédup casse. Il faut donc des règles d'écriture strictes, sans aucune liberté : le formulaire administratif où chacun remplit les cases dans le même ordre, pas la lettre libre. C'est ce qu'on appelle un encodage canonique.
Décision — CBOR, profil déterministe CBOR canonique (RFC 8949 §4.2) : clés triées, longueurs définies, encodages minimaux — le déterminisme est déjà spécifié par la norme, pas à réinventer. Clés entières pour la compacité, un octet de version de format dans chaque nœud, et une validation stricte de canonicité au parsing dans une bibliothèque partagée par tous les clients — c'est elle qui garantit « même contenu → mêmes octets », pas la bonne volonté. En Rust : minicbor.

Écartés : protobuf (explicitement non-déterministe entre implémentations — disqualifiant pour de l’adressage par hash), MessagePack (pas de profil canonique normalisé : à réinventer), format maison (gagne ~10–20 % d’octets — sans intérêt face à des chunks de 2 Mio, au prix d’une spec et d’un outillage à maintenir).

Décision 4 — Nœud répertoire & métadonnées : flux nommés, et la règle d’or

DirEntry {
  nom, type,
  flux: [ { nom_flux, ref: {addr, clé}, taille } ],   // "data" + ADS Windows / forks macOS
  meta: { mode, uid, gid, user, group,                 // numéros ET noms
          mtime_ns, ctime_ns,
          cible_symlink?, dev?, groupe_hardlink?,
          xattrs?, acl?, … }                           // champs optionnels versionnés
}
DirNode { entries: [DirEntry] triées par nom }          // tri = sérialisation déterministe

Trois choix structurent ce modèle :

  • Un fichier = un tiroir à compartiments. Windows autorise des flux cachés en plus du contenu principal (les Alternate Data Streams) ; macOS a ses resource forks. Plutôt que de les boulonner plus tard, chaque fichier est d'emblée un ensemble de flux nommés, chacun avec sa propre recette. Un fichier Linux ordinaire a un seul flux — coût zéro ; le jour du support Windows sérieux, la case existe déjà.
  • Propriétaire en double. uid/gid numériques et noms (user/group) — sur une autre machine, « l'utilisateur 1000 » n'est pas forcément le même ; à la restauration on choisit le mapping. Liens durs regroupés (même dev+inode → contenu stocké une fois, liens recréés). Champs optionnels versionnés pour étendre (ACL, xattrs, SID/DACL Windows, flags Finder) sans casser le format.
  • La règle d'or : les métadonnées ne vont jamais dans la recette. Alice et Bob ont le même fichier, modifié à des dates différentes. Si la date était dans le manifeste, leurs recettes différeraient d'un octet → adresses différentes → plus aucun partage — toute la décision 1 s'évapore. Donc : recette = pur contenu ; date, droits, propriétaire = dans l'annuaire (chiffré aléatoire). C'est le verrou de l'édifice.

Petites métadonnées inline dans l’entrée ; grosses valeurs (ACL/xattrs volumineux) débordées en blobs référencés, à nonce aléatoire. À raffiner avec le sujet Fichiers spéciaux (sparse déjà couvert par les entrées hole).

Racine de snapshot

Snapshot {
  tree:    { addr, clé },            // le répertoire de tête
  date, hôte, chemins_source[],
  parent?: { addr, clé },            // snapshot précédent (historique + réutilisation)
  profils: { chunker_id, compression_id, manifest_split_id },
  version_outil, tags[]
}

profils fige quels paramètres de découpage, de compression et de hashsplit ont produit ce snapshot. L’ensemble des racines d’un tenant = les racines du GC : le mark-and-sweep des points ouverts part de là.

Construction & restauration

# construction : post-order (enfants avant parents)
fichier → chunker → (clé, chiffré, adresse) par chunk → manifeste (convergent)
répertoire → nœud à partir des références de ses enfants (nonce aléatoire)
tête → racine de snapshot (nonce aléatoire)
# à chaque nœud : vérifier l'existence avant d'uploader (dédup)

On construit enfants avant parents : un parent référence ses enfants par adresse, qu’on ne connaît qu’après les avoir hachés. La mémoire reste bornée par la profondeur du chemin courant — bon pour les NAS. À la restauration, on descend le DAG : racine → répertoire → manifeste → chunks → déchiffrer → décompresser → réassembler ; restauration partielle en ne descendant que la branche voulue, seek dans les gros fichiers via l’arbre de manifestes. Et comme chaque adresse est le hash de son contenu, télécharger et rehacher vérifie l’intégrité de haut en bas — inviolable.


Les quatre décisions en une ligne

  • 1 · Convergence différenciée — recettes partagées (indevinables sans posséder le fichier), annuaires et racines aléatoires (les noms se devinent). Plus prudent que borg/restic/kopia, qui dédupliquent tout.
  • 2 · Hashsplit récursif — les recettes des gros fichiers sont découpées par le contenu, même astuce que FastCDC un étage plus haut. Paramètres épinglés.
  • 3 · CBOR canonique — mêmes octets partout, règles déjà normalisées (RFC 8949), validation stricte partagée.
  • 4 · Flux nommés + règle d'or — fichier = compartiments (ADS/forks prêts), trous explicites, et jamais de métadonnées dans les recettes.

Cohérence d’ensemble : chaque nœud reçoit le régime crypto que son entropie justifie, le principe « content-defined » s’applique aux deux étages, tout paramètre influençant les adresses vit dans le profil épinglé du tenant, et un seul GC traite tous les nœuds pareil.