file hello répond : base de données SQLite 3.x, application id 0x53454C46, user version 1. Puis le shell lance ./hello et le programme répond aussi : Hello, world! 1

Les deux réponses sont vraies en même temps. Le fichier se laisse interroger avec le client sqlite3 ordinaire, et il démarre comme n'importe quel binaire. Farid Zakaria a construit ce prototype, l'a baptisé SELF, pour Structured Executable & Linkable Format, et a publié l'ensemble sous le nom de selfdb sur GitHub. En une journée, le billet a dépassé les 500 commentaires sur Hacker News.129

Lui-même résume la ligne de crête, et ça vaut la peine d'être cité :

« Not "a database that describes an executable", but the actual file you chmod +x and run » — pas une base qui décrit un exécutable, le vrai fichier qu'on passe en chmod +x.1

SELF remplacera ELF ? Non. Son auteur place la parité de performance ou la modification du noyau dans ses non-objectifs explicites.3 Ce qui tombe du camion, alors, quand on passe un format binaire de 1989 dans une base de données moderne ? Beaucoup.

Quatre octets à l'offset 68

Le champ existe depuis SQLite 3, précisément pour ce genre de réemploi. La documentation du format définit un « Application ID » de 4 octets à l'octet 68 de l'en-tête, posé par PRAGMA application_id, « prévu pour les fichiers de base utilisés comme format applicatif ».5 SELF y inscrit les codes ASCII des lettres S, E, L, F : 0x53454C46.4

Reste à convaincre le noyau. Linux embarque un sous-système peu connu, binfmt_misc, qui permet à un administrateur d'associer un interpréteur à n'importe quelle séquence d'octets trouvée dans un fichier.6 Son format d'enregistrement impose une contrainte dure : la séquence magique doit tenir dans les 128 premiers octets du fichier. L'offset 68 plus quatre octets arrivent à 72, largement dedans. Tout le mécanisme est là : reconnaître « SQLite format 3\0 » à l'offset 0 et « SELF » à l'offset 68, puis remettre le fichier à self-exec, un petit programme C lié à libsqlite3 qui lit les en-têtes de programme dans la base, mappe les segments, applique les relocalisations et saute au point d'entrée.1

Détail savoureux, qui en dit long sur la solidité de tous ces formats : self-exec lui-même doit rester un fichier ELF. Un interpréteur qui correspondrait à sa propre registration binfmt se rappellerait en boucle jusqu'à l'abandon du noyau avec -ELOOP.1

self-exec a gagné trois modes d'exécution en chemin. memfd reconstruit un ELF minimal à partir des lignes, l'écrit dans un fichier anonyme en mémoire et appelle fexecve. native mappe les segments lui-même, synthétise pile et vecteur auxiliaire, puis passe la main au vrai ld.so. selfld, c'est l'éditeur de liens SQL décrit plus bas.2 Un écart entre le plan et le livré en dit long : le design proposait trois flags binfmt, la version livrée n'en utilise aucun, parce que le flag P fait injecter par le noyau un opérande argv[0] supplémentaire que des programmes multi-call stricts comme GNU hello refusent sèchement.3 Pour la robustesse, l'enregistrement peut aussi matcher une magie unique de 72 octets partant de l'offset 0, qui couvre l'en-tête SQLite et le tampon SELF dans un seul motif, les octets du milieu masqués.3

Sur NixOS, l'enregistrement tient en quelques lignes de module, et nix run .#self-vm démarre une machine virtuelle où hello est, factuellement, une base de données.13

Une base qui se cache

L'argument sous la performance tient en une phrase : ELF n'a jamais eu un problème de format. Il a un problème d'aveu.

Zakaria, qui a creusé l'idée pendant sa thèse, décrit ELF comme « une base de données écrite à la main, adressée par offsets, datant de 1989 ».3 La table de correspondance qu'il propose est la meilleure partie du projet, parce que chaque ligne se vérifie dans n'importe quel manuel de systèmes :

  • .strtab et .dynstr : de l'internement de chaînes, réimplémenté à la main. Côté SQLite : une colonne TEXT.
  • .hash et .gnu.hash : un index de recherche de symboles, bricolé en filtre de Bloom plus chaînes de collision pour que ld.so puisse rejeter un raté sans parcourir la chaîne. Côté SQLite : CREATE INDEX, un vrai b-tree.
  • la table des sections : une table de tables. Côté SQLite : sqlite_schema.
  • st_name qui pointe dans .strtab : une clé étrangère, faite d'offsets de tableau.
  • « impossible d'étendre ELF, chaque consommateur code les offsets en dur » : pas d'évolution de schéma. Côté SQLite : ALTER TABLE ADD COLUMN.

Quiconque a déjà analysé des binaires connaît la conséquence : chaque consommateur d'ELF, du loader du noyau à readelf en passant par LIEF, réécrit le même parseur, et chaque producteur réécrit le même sérialiseur.1 Le format est laconique parce qu'il a été dessiné quand le disque et la bande passante coûtaient cher, et il n'a pas de schéma auto-descriptif : les sections veulent dire quelque chose par convention, pas par contrat.1

Avant SELF, il y a eu sqlelf, publié comme article arXiv en mai 2024. L'outil attaquait le problème côté observation : il exposait ELF comme tables virtuelles SQL, de sorte que SELECT name FROM elf_symbols remplaçait readelf plus grep.7 L'article, raconte-t-il, n'a pas trouvé de revue.1 SELF inverse la flèche : les lignes ne sont plus une vue posée sur le fichier. Les lignes sont le fichier.

Ce qui tombe tout seul

Le format entier tient dans un fichier DDL lisible, et l'essentiel y est optionnel.4 Deux tables portent l'exécution : self_meta, l'ancien en-tête ELF en paires clé/valeur, et segments, une ligne par en-tête de programme avec les octets dans un BLOB.1 Ajoutez symbols, relocations, needed et dynamic_entries, et le programme se lie dynamiquement. Tout le reste, sections, notes, jusqu'à une table docs, existe pour l'outillage et peut être supprimé sans que le programme ne s'en aperçoive.1

Relisez cette dernière phrase comme un énoncé sur ELF. Dans SELF, striper un binaire, c'est :

DELETE FROM sections;
DELETE FROM notes;
VACUUM;

La démo du billet fait passer un binaire hello de 57 344 à 49 152 octets de cette façon, puis le lance, parce que les tables supprimées n'ont jamais servi à l'exécution.1 patchelf devient un UPDATE. Le versionnage de symboles, l'usine à gaz .gnu.version_r, devient une colonne version. L'index des symboles est un b-tree ordinaire ; le schéma le dit sans détour : « this index IS .gnu.hash ».4

Et le format se documente tout seul. Chaque fichier SELF embarque exports, imports et ldd comme vues SQL, donc les outils du quotidien deviennent des one-liners : ldd, c'est SELECT soname FROM ldd ; nm -D, une requête sur imports ; readelf -l, une requête sur segments.1 Le DESIGN.md l'écrit sans décorum : .schema est la spécification.3 Le schéma contient même une table docs, un emplacement pour le manuel du format dans chaque binaire, où le DESIGN.md imagine un jour des pages de manuel et des tables SBOM.3

Côté conversion, elf2self travaille après l'édition de liens, en Python avec LIEF. Rien à retoucher dans les toolchains. L'aller-retour a une garantie fonctionnelle, pas octet pour octet : self2elf(elf2self(x)) doit produire un ELF aux mêmes segments, symboles et informations dynamiques.3

Lier, mais en JOIN

Les binaires statiques étaient la partie facile. C'est avec les bibliothèques partagées que la base commence à payer, et le projet roule sur deux voies d'ambition très différente.

La voie qui marche garde le ld.so de glibc aux commandes et n'intercepte que la recherche. L'interface rtld-audit de glibc permet à une bibliothèque d'intercepter chaque recherche d'objet partagé, dlopen compris, avant toute consultation du système de fichiers.1 SELF fournit libself-audit.so, qui répond à « quel fichier satisfait ce soname ? » par une requête SQL contre la base système. Le loader standard mappe et reloge ensuite l'objet comme d'habitude, et tout ce que glibc sait faire, PLT paresseux, IFUNC, TLS, versionnage de symboles, continue de fonctionner.1 Le test du dépôt supprime carrément le libgreet.so.1 ELF du disque, et l'application tourne quand même, sa bibliothèque chargée depuis des lignes SQLite.2

La voie ambitieuse, self-ld, remplace ld.so et résout chaque relocalisation par une requête :1

SELECT s.value + o.load_bias
FROM   relocations r
JOIN   symbols s ON r.symbol = s.id
JOIN   objects o ON s.object = o.id
WHERE  r.id = ?
ORDER BY o.load_order
LIMIT  1;

La note honnête dort dans le DESIGN.md plutôt que dans un README triomphant : self-ld ne gère qu'une closure freestanding, sans libc. TLS, relocalisations IFUNC et la poignée de main du loader glibc sont hors périmètre, donc les vrais programmes glibc passent par la voie audit, pas par l'éditeur de liens SQL.3 Le prototype et le produit restent séparés, et c'est exactement la discipline qu'il faut pour ce genre de projet.

Le DESIGN.md esquisse aussi la base système complète. Une base résolveur, /var/lib/self/system.db, remplace le cache ldconfig par une table indexée d'objets, de sonames et de build IDs. Et comme chaque fichier .self partage le même schéma, les questions trans-système n'exigent aucun nouveau format, seulement ATTACH : attachez hello et libc, puis demandez quelles importations de hello libc ne satisfait pas. C'est ldd -r, le mode qui signale les symboles non résolus, réduit à un seul JOIN.3

La facture honnête

Un format que le noyau mappe directement, ça se remplace avec des frais. Le projet les mesure, au lieu de les maquiller. La « parité de performance » figure noir sur blanc parmi les non-objectifs de la version zéro.3

La taille d'abord. Un petit binaire pris seul grossit à peu près du double, parce que les pages b-tree de SQLite ont un prix.1 Mais l'essentiel de ce surcoût loge dans les tables optionnelles d'outillage. Un coreutils stripé pèse 1 794 048 octets en SELF contre 1 768 632 en ELF. C'est 1,44 % de plus ; l'auteur arrondit à « within 1 % », et les chiffres bruts dépassent légèrement son arrondi. La direction du résultat compte plus que la décimale : une fois stripé, la base fait pratiquement la taille du binaire qu'elle remplace.13

La latence, c'est là que la facture devient sérieuse. Le benchmark du dépôt mesure un exec nu à 0,42 ms et le loader SELF à environ 2,1 ms, à peu près cinq fois plus lent, le prix de l'ouverture de la base et de la reconstruction de l'image. Le billet cite de son côté ~5 ms de surcoût fixe pour ouvrir SQLite et démarrer l'interpréteur, sur ses propres mesures allant d'un hello de 15 Kio à un gdb de 42 Mio liant 47 bibliothèques. Deux protocoles différents, même direction.13 Le plan d'évaluation derrière ces chiffres se lit comme un petit article de méthodologie : hyperfine pour la latence d'exec à chaud et à froid, mémoire partagée mesurée avec pss sur instances concurrentes, tests différentiels d'aller-retour, et un sqldiff qui doit revenir vide sur les binaires convertis.3

Le plus cher ne se voit pas dans un benchmark. Un ELF normal est mmap-é, donc dix processus qui lancent le même binaire partagent ses pages de texte via le cache de pages. SELF copie les octets hors du b-tree au lieu de les mapper, donc chaque processus reçoit sa copie. L'auteur le signale lui-même comme le vrai manque de performance.13 Sur Hacker News, un commentateur formule l'objection sans détour : « Isn't this the whole point of executable file formats? To avoid loading everything all at once, share immutable segments with other processes... » — n'est-ce pas là tout l'intérêt d'un format exécutable ?9 L'objection est juste, et le projet ne prétend jamais le contraire. À ma lecture, ce genre de critique rend le travail plus crédible, pas moins. Les benchmarks contiennent aussi une curiosité instructive : curl, 274 Kio répartis sur 27 bibliothèques, démarre plus lentement que git, 4,6 Mio sur 5 bibliothèques, parce que ld.so paie par objet et non par octet.1

L'anti-ambiguïté

Le projet cesse d'être une curiosité à l'instant précis où un fichier SQLite se met à contenir plus d'un programme. self closure range un exécutable et toutes ses dépendances transitives dans une seule base, et le schéma tue une ambiguïté qui traîne depuis des décennies.1

ldd liste des sonames, pas des fichiers. Quel libc.so.6 précis satisfait une dépendance se décide à l'exécution par des chemins de recherche, exactement le genre de non-déterminisme que Nix passe sa vie à éliminer avec RUNPATH.1 SELF fait pareil avec une clé étrangère : la table needs stocke, pour chaque arête de dépendance, le resolved_path de l'objet qui la satisfait.1 La résolution de bibliothèque cesse d'être une recherche et devient de l'intégrité référentielle. Dans la démo, ls et ses cinq bibliothèques, six objets, tiennent dans un fichier de 4,8 Mio.1

Il y a ici une ironie silencieuse qu'il faut nommer. Nix résout déjà chaque arête vers un chemin de store ; c'est le travail de RUNPATH. SELF ajoute moins de déterminisme qu'il n'en déplace l'endroit : le fichier closure transporte son propre graphe de dépendances comme donnée, donc la réponse à « quelle libc ce programme utilisera-t-il vraiment ? » voyage dans l'artefact, avec l'artefact.13

723 programmes, un fichier

Pointez le même outil sur un système entier et les chiffres cessent d'être mignons. Zakaria a lancé self closure sur tous les binaires ELF du PATH de sa machine : 723 exécutables tirant 400 bibliothèques partagées distinctes, 1 123 objets, 346 386 symboles et 3 808 arêtes de dépendance, le tout dans un seul fichier SQLite.1

La surprise, c'est la taille : 611,9 Mio de base contre 644,4 Mio d'ELF d'origine. Le surcoût b-tree qui doublait un hello isolé s'amortit à environ 6 % sur mille objets, parce que bibliothèques et symboles sont dédupliqués par le schéma lui-même. Dans le modèle AppImage, où chaque programme embarque sa closure privée, les mêmes 723 programmes pèseraient 5,53 Gio.1 La base répond aussi à des questions que les fichiers d'origine ignorent. Une requête montre 345 sonames distincts portés par 399 objets bibliothèques, quelques bibliothèques présentes en plusieurs builds à la fois, la duplication qu'un store comme Nix tolère exprès. En excluant l'éditeur de liens dynamique lui-même, quatre des 3 808 arêtes restent sans chemin résolu, et la requête qui les trouve est à un WHERE resolved_path IS NULL de distance.1

LD_PRELOAD, transactionnel

Une fois le système réduit à des lignes, intervenir dessus devient une affaire de transactions. La démo tient en quatre gestes. Un programme sort avec le code 13. On insère une ligne dans une table preload, qui nomme une bibliothèque interposant une fonction. Le même binaire relancé sort avec 42. On supprime la ligne, retour à 13. Sans variable d'environnement, sans réédition de liens, et l'opération est atomique et réversible, ROLLBACK compris.1

L'exemple choisi par l'auteur est le bon : « interposer un malloc de traçage partout, puis ROLLBACK » devient une transaction unique sur un userland entier.1 Quiconque a déjà essayé d'instrumenter temporairement une flotte de binaires reconnaîtra combien de machinerie cette phrase supprime.

Un serveur dans son binaire

L'exemple du dépôt en dit plus que n'importe quel plaidoyer. self-httpd est un serveur web qui ouvre argv[0], son propre exécutable, comme base SQLite, sert ses pages depuis sa propre table routes et réécrit les compteurs de visites en lui-même.2 Le programme, son contenu et ses journaux sont un seul fichier. Modifier le site en production, c'est un UPDATE. Il est déployé et répond sur selfdb.exe.xyz.23

Là, le format n'est plus vraiment une histoire d'ELF. Un artefact à la fois code et état interrogeable appelle des artefacts qui, sinon, seraient trois répertoires et un script de déploiement.

Ce qui reste après

Retirez le remplacement d'ELF, il reste des idées qu'on peut emporter ailleurs.

D'abord la représentation. Le livre blanc de SQLite sur les formats applicatifs soutient qu'un schéma documenté bat un format binaire sur mesure, rappelle que la Bibliothèque du Congrès recommande SQLite pour la conservation à long terme, et cite Fred Brooks : « Show me your tables, and I won't usually need your flowcharts » — montrez-moi vos tables, je n'aurai plus besoin de vos flowcharts.8 SELF, c'est cet argument pointé vers le format binaire le plus enraciné de l'informatique, et la table de correspondance ELF vers SQL en est la version concrète. Le format de fichier SQLite est rétrocompatible depuis la version 3.0.0, en juin 2004, une prétention de longévité que peu de formats exécutables peuvent avancer.5

Ensuite, l'outillage. Chaque parseur supprimé est une classe de bugs de parseur qui ne s'écrira jamais. sqldiff entre deux binaires SELF produit un diff sémantique de programmes ; la provenance est une ligne de self_meta qui note le chemin de store d'où vient le binaire ; le DESIGN.md esquisse une signature des lignes plutôt que des octets.3 Il nomme aussi le principe général, en créditant nushell : des données structurées à la frontière valent mieux que des octets plus un parseur dédié chez chaque consommateur.3 Des gains modestes, réels, à la portée de quiconque dessine aujourd'hui un format d'artefact, quel que soit ce que le noyau exécutera demain.

Et la méthode, qui compte le plus ici. Nix a permis à une personne seule de reconstruire assez de système pour tester un format hérétique contre de vrais programmes glibc, dans une VM démarrable, sans demander la permission à personne et sans toucher aux toolchains.1 La barrière n'a jamais été l'expertise noyau. C'était d'avoir un monde reconstruisible.

Là où ça s'arrête

La liste honnête, en grande partie celle du projet lui-même. Pas de partage de pages de texte, donc le bilan mémoire reste moins bon qu'ELF jusqu'à ce que quelqu'un résolve l'alignement page des BLOB, explicitement remis à plus tard.3 self-ld ne lie que des closures freestanding.3 setuid est déclaré hors périmètre.3 Le nom SELF entre en collision avec le Signed ELF de Sony pour les exécutables PlayStation, collision que l'auteur a décidé d'accepter.3 L'angle noyau a sa propre section dans le DESIGN.md, classée par effort : ne rien faire, et binfmt_misc marche déjà ; un matcher à base de BPF pourrait router sur user_version vers des interpréteurs versionnés ; un vrai lecteur dans le noyau, mille à deux mille lignes de C parcourant le b-tree sans aucun SQL, récupérerait la pagination à la demande via le cache de pages. Le verdict de l'auteur sur cette dernière option : « a fantastic talk slide and a terrible patch series » — une superbe slide de conférence et une épouvantable série de patches.3 Et le dépôt, treize commits et 307 étoiles au moment de la rédaction, est un prototype fonctionnel, pas une distribution.2

Rien de tout cela n'affaiblit le résultat obtenu. Un programme peut être une base de données valide, tourner sur un noyau Linux non modifié par un mécanisme userspace documenté, et laisser derrière lui un système qu'on peut interroger, stripper, préloader et differ en SQL. ELF a 37 ans et il est passable partout. Demandez-lui à quoi il sert vraiment, et SELF répond dans une langue que SQLite parle depuis 2004.5