Ferry
2026Open source · Apache-2.0
Un outil pair-à-pair open source pour les équipes de développeurs, sous licence Apache-2.0. Chaque machine fait tourner le même démon — client et serveur à la fois — et une CLI, une extension VS Code et une app de bureau Tauri lui parlent toutes via un socket local. Sept crates Rust dans un seul workspace Cargo, avec la direction des dépendances imposée par le graphe de crates : la casser est une erreur de build, pas quelque chose qu'un relecteur doit attraper. Deux démons font tourner tout le pipeline de bout en bout : découverte, pairing, rosters signés, transfert chiffré, livraison.
Rôle — Auteur unique — architecture, spec de pré-implémentation, implémentation Rust complète
- Crates Rust
- 7
- Tests
- 350+ Rust
- Surfaces
- CLI · VS Code · Desktop
- Bindings générés
- 37 types
- Primitives crypto
- 4 auditées
- Docker pulls
- 546
- GitHub release downloads
- 9
La contrainte qui l'a façonné
Il n'y a pas de tiers de confiance. Chaque règle doit être imposée par la machine qui s'en soucie, contre une preuve cryptographique plutôt que la parole d'un serveur. L'essentiel du travail d'architecture suppose l'inverse : un serveur qui impose une règle une fois, pour tout le monde.
L'architecture
À quoi ça ressemble
3 écrans · cliquer pour agrandirLa contrainte qui façonne tout
Il n'y a pas de tiers de confiance. Quelques conséquences :
Un pair non vérifié ne peut rien faire. Refus par défaut, imposé au handshake Noise avant qu'aucune trame ne soit lue comme une commande — jamais dans l'interface. La découverte n'est pas la confiance : un nom d'hôte sur un réseau est une affirmation non authentifiée. Le pairing demande un code court hors bande et une phrase de vérification qu'un humain confirme, et il ne peut pas être contourné.
Envoyer est une écriture locale, pas un appel réseau. La ligne d'outbox est écrite dans la même transaction que le changement d'état, et un dispatcher la vide quand le pair est joignable. C'est un pattern familier utilisé autrement : d'habitude l'outbox existe pour qu'un effet de bord échoué ne puisse pas annuler une écriture, mais ici le pair hors ligne est le cas normal, donc c'est l'abstraction centrale plutôt qu'un filet de sécurité.
L'expiration tourne sur l'horloge monotone du receveur, à partir de la livraison. Deux portables sur un partage de connexion ne sont pas d'accord sur l'heure, et l'horloge de l'expéditeur est contrôlée par l'attaquant. Un secret de dix minutes resté vingt minutes en file obtient quand même ses dix minutes lisibles une fois arrivé.
La crypto est assemblée à partir de primitives auditées, jamais conçue. age, Noise XK, SPAKE2, ed25519. Aucune construction nouvelle et aucune dérivation de clé faite main.
Le compilateur possède le découpage en couches
Direction des dépendances : daemon/cli → core → net/store/crypto → proto. Jamais l'inverse. core ne peut pas importer le démon, un type IPC ou quoi que ce soit lié à l'UI, parce qu'il doit rester pilotable depuis le démon, la CLI ou un harnais de test avec un faux réseau, sans rien changer.
La crate réseau ne peut pas importer la crate crypto, donc la vérification du roster arrive comme un prédicat injecté sur des octets de clé bruts. Le réseau l'impose mécaniquement et n'entre jamais en mode transport sans un passage ; la couche domaine fournit la décision réelle adossée au roster. Aucune des deux couches ne peut se retrouver à faire confiance à l'autre par accident.
proto ne contient que des types, des enums et des codes d'erreur — aucune logique. Les 37 types de bindings TypeScript en sont générés avec ts-rs plutôt que retranscrits à la main, et la CI fait échouer le build sur tout diff.
Trois surfaces, un seul démon local
Le démon est la seule chose qui parle le protocole pair-à-pair. Tout ce qu'un humain touche — la CLI, une extension VS Code, et une coque de bureau Tauri par-dessus une interface React de 22 composants — lui parle sur un socket local à trames de longueur, et jamais directement à une autre machine.
C'est pour ça que le pairing vit dans la CLI plutôt que dans le démon. Il faut un humain pour lire une phrase de vérification à voix haute, et le démon est sans tête, sans terminal garanti. Une étape qui exige une personne ne peut pas vivre dans un processus qui ne peut pas en atteindre une.
La barre de test, c'est un harnais docker compose qui fait tourner deux démons sur un réseau et pilote un vrai envoi, une déconnexion et une reconnexion entre eux. Un test à deux nœuds en mémoire me renseigne sur la machine à états ; celui-ci me renseigne sur un partage de connexion avec un portable en veille dessus.
Comment ça a été construit
24 août 2026
Ferry — le cœur atterrit
Écrit contre une spécification terminée avant tout code. Le workspace, les types de protocole, la crypto, le réseau, le stockage, le moteur de transfert et l'outbox, et le squelette du démon.
27 août 2026
Ferry parle à d'autres machines
Un vrai écouteur qui accepte les connexions Noise XK autorisées contre le roster, la découverte mDNS, et le pairing SPAKE2 avec confirmation humaine.
Les problèmes qui sont sortis de ce projet9
- Un panneau VS Code vide, et un test qui passait quand même2026-09-15La première vraie installation de l'extension packagée ouvrait un webview vide. Le test end-to-end existant était vert depuis le début, parce qu'il vérifiait seulement qu'un panneau nommé Ferry existait — pas que quoi que ce soit à l'intérieur s'affichait.
- Un trou dans le journal d'audit par omission, pas par bug2026-09-08Après un cycle complet de pairing, envoi, livraison et burn, le journal d'audit ne montrait que deux événements sur six. Pairing, import de roster, livraison et open/burn — les événements sensibles pour la sécurité — n'écrivaient aucune ligne, parce que personne n'avait écrit le code pour le faire.
- Trois bugs qu'un réseau simulé ne pouvait pas voir2026-09-06Le harnais en mémoire à deux nœuds était vert depuis des semaines. La première vraie exécution à deux démons a trouvé trois bugs d'un coup — aucun n'était un bug de machine à états. Tous étaient des bugs dans la frontière que le mock remplace.
- bincode ne peut pas désérialiser un enum étiquetéintégrité des donnéesL'enveloppe de fil utilisait la représentation tag/content de serde et encodait avec bincode. Cette combinaison ne peut pas marcher : l'étiquetage interne a besoin d'un format auto-descriptif pour que le désérialiseur puisse regarder en avant une étiquette de chaîne, et le format binaire de bincode
- Le pairing ne peut pas vivre dans un processus sans têtedistribué & temps réelferry pair fait tourner l'échange SPAKE2 dans la CLI sur un socket TCP brut, pas dans le démon. Le démon est sans tête et ne peut pas garantir un TTY, et le flux de pairing exige qu'un humain lise et confirme une phrase de vérification. Les deux côtés échangent
- Tenir un verrou de base de données à travers une lecture réseaudistribué & temps réelL'écouteur P2P et la boucle de découverte ont chacun obtenu leur propre connexion SQLite dédiée plutôt que de partager un store derrière un mutex avec le thread IPC. Partager aurait signifié potentiellement tenir un verrou à travers une lecture bloquante depuis un pair,
- Un défaut de boîte de réception resté d'une conception antérieureintégrité des donnéesferry_store::inbox::insert mettait les nouvelles lignes par défaut à Queued. Mais le create_inbound de core, et chaque fake implémentant ce port, crée les lignes entrantes en Offered — le premier moment observé d'un élément entrant est toujours « une Offer est arrivée ». Il n'y a pas
- Un message de protocole qui était structurellement du code mortdistribué & temps réelLe protocole dit qu'un accusé Opened n'est envoyé que si l'expéditeur l'a demandé. Il n'y avait aucun champ nulle part pour exprimer cette demande, donc rien ne pouvait jamais demander, donc le message ne pouvait jamais être envoyé. Ajout de notify_on_open à Offer lui-même plutôt que
- Une constante de chunk qui n'aurait jamais pu marcherdistribué & temps réelnet::chunk::DEFAULT_CHUNK_BYTES valait 64 Kio. Un vrai chunk encodé en bincode à cette taille, plus la surcharge d'enveloppe — chaîne d'id d'élément, numéro de séquence, préfixe de longueur, tag d'enum — plus le tag AEAD de Noise, dépasse la taille de trame max de 65535 octets.