iddqd oder die schwierigste Art von unsafe Rust
(oxide.computer)- iddqd ist eine Rust-Map-Bibliothek, die Schlüssel aus Werten ausleiht, und wird in Oxides Omicron-Control-Plane verwendet, um In-Memory-Indizes für große Datensätze wie Disk- und Sled-Inventare zu pflegen, bei denen Korrektheit entscheidend ist
- Die Standard-
BTreeMapspeichert Schlüssel und Werte getrennt, was die Übergabe umständlich macht oder zu auseinanderlaufenden doppelten Schlüsseln führen kann; IdOrdMap extrahiert den Schlüssel dagegen aus einem Feld innerhalb des Datensatzes und verwendet ihn für Lookups - unsafe Rust ist ein Ausweg, um sichere Programme auszudrücken, die der Compiler nicht beweisen kann, und generischer Code, der benutzerdefinierte Trait-Implementierungen aufruft, muss sogar pathologisches sicheres Rust aushalten
- Die mutable Iteration von
iddqderweitert Lifetimes auf Basis der Invariante, dass alle Indizes verschieden sind; eine pathologische Ord-Implementierung konnte jedoch B-Tree und Item-Set auseinanderlaufen lassen und doppelte Indizes auf dasselbe Element erzeugen - Die Korrektur vergleicht Schlüssel und Index gemeinsam und fällt bei Fehlschlag auf einen linearen Scan zurück, der keinen Benutzercode aufruft; für hinreichendes Vertrauen braucht es Miri, modellbasiertes Testen, Panic-Fault-Injection und adversariales Review durch LLMs
Das Problem, das iddqd löst
iddqdist eine Rust-Bibliothek, die Maps bereitstellt, deren Schlüssel aus dem Wert ausgeliehen werden, und Oxide verwendet sie breit in der Omicron-Control-Plane- Omicron ist die Software im Zentrum eines Oxide-Racks, die Ressourcen wie Compute und Storage provisioniert und den laufenden Betrieb des Racks sicherstellt; wenn
iddqdfehlfunktioniert, kann die Control-Plane auf unvorhersehbare und schwer zu diagnostizierende Weise falsch arbeiten - Eine Struktur wie
BTreeMap<Email, User>aus der Rust-Standardbibliothek speichert den Schlüssel, hier die E-Mail, getrennt vom Wert - Um Schlüssel und Wert gemeinsam weiterzugeben, muss man mit
get_key_value(email, user)holen; erstellt man stattdessen beim Fetch einen separaten Record-Typ wieUserRecord, wird das bei vielen Record-Typen unhandlich - Dupliziert man die E-Mail auch innerhalb von
User, entsteht das Risiko, dass der Schlüssel der Map und die E-Mail im Wert voneinander abweichen IdOrdMapverwendet das TraitIdOrdItem, um den Schlüssel aus dem Record zu extrahieren; der Schlüsseltyp kann dabei aus dem Wert ausgeliehen werden, etwatype Key<'a> = &'a Email- Bei Oxide passte dieses Muster gut zu großen Records wie Datenbankabfrage-Ergebnissen und erwies sich als nützlich für die Indizierung großer Records in der gesamten Control-Plane
Zusätzliche Funktionen
iddqdunterstützt komplexe Schlüssel, die aus mehreren Feldern ausgeliehen werden, als First-Class-Konzept und kann sie ohne Umwege wie Dynamic Dispatch verarbeitenBiHashMapundTriHashMapindizieren einen Eintrag jeweils über zwei oder drei Schlüssel und vermeiden so das übliche Muster, mehrere Maps von Hand synchron zu halten- Die Serde-Implementierung serialisiert als Sequenz statt als Map, sodass in JSON auch Nicht-String-Schlüssel serialisiert werden können, und lehnt doppelte Schlüssel ab
- Aus Gründen der Abwärtskompatibilität wird auch die Serialisierung im Map-Format unterstützt
Wo unsafe Rust schwierig wird
- Das zentrale Risiko in Rust ist undefiniertes Verhalten (undefined behavior, UB); wenn sicherer Code auf keine Weise UB auslösen kann, ist er sound, andernfalls unsound
- In sicherem Rust lehnt der Compiler Programme mit UB ab, aber wegen der Grenzen statischer Analyse werden auch manche Programme ohne UB abgelehnt
- Das Schlüsselwort
unsafeist der Ausweg, um solche Programme auszudrücken; der Autor übernimmt die Verantwortung für die Soundness und bittet den Compiler um Vertrauen - Zu den Rust-Regeln, die unsafe Code einhalten muss, gehören: keine Data Races, kein Lesen von nicht initialisiertem oder freigegebenem Speicher, kein mehrfaches
&mut-Aliasing desselben Speicherbereichs und keine Mutation unveränderlicher Daten - Diese Regeln sind schwer zu durchdenken, besonders wegen mutable Aliasing, weshalb
unsafemeist hinter sicheren Abstraktionen gekapselt werden muss
Stufen der Verifikation sicherer Abstraktionen
split_at_mutist eine sichere Methode, die einen mutablen Slice in zwei Teile aufspaltet, benötigt intern aberunsafe, weil sich eine solche Aufteilung nicht allein in sicherem Rust ausdrücken lässt- Die Soundness von
split_at_muthängt von Invarianten im umgebenden sicheren Code ab, etwa ob wirklich&mut [T]übergeben wurde, obmid <= slice.len()geprüft wurde und ob die verbleibenden Längen korrekt berechnet wurden getvonMyVec<T>hängt davon ab, dasslenkorrekt ist und der Zugriff im Bereich liegt; diese Bedingungen müssen von allen anderen Methoden im selben Modul aufrechterhalten werden- Am schwierigsten wird es, wenn generischer
unsafe-Code benutzerdefinierten Code aufruft - Sicherer Rust-Code darf
unsafe-Code niemals dazu bringen, UB auszulösen, egal wie pathologisch oder feindselig er geschrieben ist - Code wie
collect_exact, derlen()vonExactSizeIteratorvertraut und genau so viel Kapazität beschreibt, ist im Allgemeinen unsound, weil ein Iterator mit gelogener Länge in nicht allokierten Speicher schreiben lassen kann - Auch
std::io::Readist ein Trait, dessen fehlerhafte Implementierungen eine falsche Zahl gelesener Bytes zurückgeben können; Rust RFC 2930 behandelt dieses Problem iddqdgehört in diese schwierigste Kategorie, weil es eine generische Datenstruktur ist, die benutzerdefinierte Trait-Implementierungen aufruft
Interne Struktur von iddqd
iddqdkombiniert eine Liste von Einträgen, dasItemSet, mit einer Tabelle, die Slot-Indizes speichertIdHashMap<T>verwendet einItemSet<T>und einehashbrown::HashTable, dieItemIndexspeichertItemSet<T>enthält einVec<ItemSlot<T>>, wobeiItemSlot<T>entwederOccupied(T)oderVacant { next: ItemIndex }istfree_headzeigt auf den zuletzt freigegebenenVacant-Slot oder auf ein Sentinel, das anzeigt, dass es keinen freien Slot gibt;free_headund dieVacant-Slots bilden zusammen die Free-Chain- Beim Einfügen eines neuen Eintrags prüft man über
free_head, ob ein wiederverwendbarer Slot existiert; falls ja, wird derVacant-Slot mitOccupiedüberschrieben undfree_headauf den nächsten Wert verschoben - Gibt es keinen freien Slot, wird am Ende ein neuer
Occupied-Slot angehängt; danach wird der Schlüssel geholt, der Hash berechnet und der neue Index in der Hash-Tabelle eingetragen - Beim Entfernen wird umgekehrt zuerst der Index über den Schlüssel in der Hash-Tabelle gesucht und entfernt; anschließend wird der entsprechende
ItemSlotinVacantumgewandelt und der bisherigefree_headalsnextverkettet IdOrdMapverwendet statt einer Hash-Tabelle einen B-Tree-Index, undBiHashMapsowieTriHashMapspeichern jeweils zwei bzw. drei Hash-Tabellen
Mutable Iteration und Lifetime-Erweiterung
IdOrdMap::iter_mutiteriert mutabel über Einträge in Schlüsselsortierung- Ein Rust-Iterator muss Werte zurückgeben, die den Iterator selbst nicht ausleihen; Kombinatoren wie
collectkönnen Werte wieVec<&mut T>behalten, selbst nachdem der Iterator verschwunden ist - Damit dieses Verhalten sicher ist, darf der Iterator denselben Wert nicht zweimal zurückgeben
- Der Borrow Checker sieht bei einer Liste nur aufeinanderfolgende Zugriffe und kann nicht erkennen, dass alle Indizes verschieden sind
iddqdnutzt deshalb Lifetime-Erweiterung mitstd::mem::transmute::<&mut T, &'a mut T>, was auf der Invariante beruht, dass die vonself.itergelieferten Indizes alle verschieden sind
Der Bug durch eine pathologische Ord-Implementierung
- In einer
IdOrdMapmit fünf Einträgen und den Schlüsseln 0 bis 4 in Reihenfolge speichert ein Lookup für Schlüssel 0 über dieEntry APIeinOccupiedEntryintern mit Index 0 - Wenn danach die
Ord-Implementierung vonKeyso verändert wird, dass sie unabhängig vom Wert immerEqualzurückgibt, kannOccupiedEntry::removebeim erneuten Abstieg im B-Tree anhand des Schlüsselvergleichs den falschen Eintrag entfernen - Wenn im B-Tree zum Beispiel zuerst
(key = 2, index = 2)verglichen wird, wird wegenEqualdieser Entry entfernt, während im Item-Set durch den imOccupiedEntrygehaltenen Index 0 Eintrag 0 entfernt wird - In diesem Zustand bleibt Index 0 im B-Tree erhalten, während Slot 0 im Item-Set
vacantwird; Eintrag 2 bleibt im Item-Set, verliert aber seinen Zeiger im B-Tree - Wenn
Orddanach wieder normal funktioniert und ein Eintrag mit Schlüssel 1000 eingefügt wird, wird der vonfree_headreferenzierte Slot 0 wiederverwendet - Dadurch entstehen im B-Tree doppelte Indizes, die auf denselben Slot zeigen, und
IterMutkann mehrere&mut-Referenzen auf denselben Speicher erzeugen, wodurch die Implementierung unsound wird
Korrektur und Performance-Kompromiss
- Beim Abstieg im B-Tree wird nun nicht nur die Gleichheit des Schlüssels, sondern auch die des Index geprüft
- Wenn nach einem Schlüssel mit bekanntem Index gesucht wird, werden sowohl
(key, index)verglichen; selbst wenn pathologischesOrdEqualzurückgibt, wird der Vergleich mit(key = 2, index = 2)und dem gesuchten Index 0 durch den Index als Tie-Breaker zuLess - Damit diese Suche erfolgreich sein kann, muss der gespeicherte Index tatsächlich mit dem gesuchten Index übereinstimmen
- Der Tie-Breaker verhindert falsche Treffer, garantiert aber nicht, dass der richtige Eintrag immer gefunden wird
- Der B-Tree ist nach Schlüssel sortiert, der Tie-Breaker vergleicht aber Indizes; beide Ordnungen sind im Allgemeinen unabhängig voneinander
- Falls die Baumsuche scheitert, fällt der Code auf einen linearen Scan zurück, der den betreffenden Index aus dem B-Tree entfernt, ohne Benutzercode aufzurufen
- Dieses Fallback macht die Entfernung statt logarithmisch linear, wird aber als akzeptabler Kompromiss betrachtet, da es nur bei fehlerhaftem Benutzercode erreicht wird
Verifikationsschichten
- Weil
iddqdals grundlegende Datenstruktur verwendet wird, kommen sowohl analytische Prüfung als auch mehrere empirische Verifikationsmethoden zum Einsatz - Jeder
unsafe-Block und jedesunsafe-Muster wird von ein bis drei Rust-Autoren und Reviewern analysiert - Über jedem
unsafe-Block dokumentieren// SAFETY:-Kommentare die Begründung, und Clippys Lintundocumented_unsafe_blocksprüft, dass diese Kommentare vorhanden sind - Example-based Tests erstellen Maps, führen Operationen aus und prüfen dann die Ergebnisse;
iddqdhat interne Unit-Tests und Integrations-Tests für die öffentliche API - Diese Tests existieren auch als Doctests und dienen damit zugleich als Dokumentation
- Pathologische Tests liefern fehlerhafte
Ord- und andere Trait-Implementierungen ein - In der CI werden sowohl reguläre Tests als auch pathologische Tests unter Miri ausgeführt, um verschiedene Arten von UB zu erkennen
- Bedingungen wie die Invariante, dass es keine doppelten Indizes geben darf, lassen sich auch in normalen Testumgebungen außerhalb von Miri prüfen
Modellbasiertes Testen und Fault Injection
iddqdverwendet zwei Ebenen von Randomized Testing: modellbasiertes Testen gegen ein korrektes Oracle und darauf aufbauend Fault Injection- Model-based Testing oder stateful property-based testing wendet zufällige Operationsfolgen auf eine Typinstanz an und vergleicht die Ergebnisse mit einem als korrekt bekannten Oracle
iddqdführt umfangreiche modellbasierte Tests gegen das ineffiziente, aber klar korrekteNaiveMap-Oracle aus- Fault Injection bedeutet hier, zufällig Bugs in Benutzercode einzubauen; bei
iddqderwies sich besonders Panic-Safety als ergiebige Achse - Selbst wenn Benutzercode mitten in einer Operation panikt, dürfen die Invarianten der Map nicht brechen
- Jede Map-Operation bekommt einen zufälligen Panic-Countdown; bei jedem Aufruf von Benutzercode wird er um 1 verringert, und sobald er 0 erreicht, wird eine Panic ausgelöst, um zufällige Panic-Safety-Tests durchzuführen
- Dieser Ansatz fand in
iddqdmehrere subtile Bugs, von denen einige zu Unsoundness führten - Die modellbasierten Tests prüfen nach jeder Operation auch interne Invarianten wie das Fehlen doppelter Indizes
- Modellbasierte Tests sind zu langsam, um sie unter Miri auszuführen; deshalb werden die Invarianten, von denen Soundness und Korrektheit abhängen, gesondert geprüft
Adversariales LLM-Review und formale Methoden
- Frontier-Modelle der aktuellen Generation fanden mehrere pathologische Benutzercode-Implementierungen, die die Map beschädigen konnten
- In einem bemerkenswerten Fall konstruierte ein LLM einen Pfad, bei dem ein Custom Allocator panikt und beim Unwinding die Map-Invarianten verletzt werden
- Das war ein anderes Panic-Safety-Problem als die bereits abgedeckten Panics in gewöhnlichem Benutzercode wie
Ord-Implementierungen - LLMs können auch plausibel klingende, aber falsche Soundness-Behauptungen erzeugen; als Gegenmaßnahme dient red-green TDD mit Miri als Oracle
- Bei einem Soundness-Bug wird zunächst ein Test erstellt, der unter Miri UB zeigt; nach der Korrektur wird geprüft, dass derselbe Test besteht
- Ein Ansatz, Map-Invarianten mit einem Model Checker wie
Kanizu beweisen, scheitert beiiddqddaran, dass die Struktur zu komplex ist und die nötigen Beweise zu groß für das Werkzeug werden Creusotkann helfen zu beweisen, dass Rust-Code frei von Panics und anderen Fehlern ist, kann aber derzeit keine Invarianten beweisen, die auch dann erhalten bleiben müssen, wenn Benutzercode panikt oder sich fehlerhaft verhältNaiveMapdient als der Spezifikation voniddqdam nächsten kommendes Modell, und in der CI werden die modellbasierten Tests tausendfach ausgeführt, um hohes Vertrauen in die Übereinstimmung von Implementierung und Spezifikation zu gewinnencargo-annealist ein in Entwicklung befindliches Werkzeug von Interesse, das erlaubt, nebenunsafe-Rust in Doc-Comments Soundness-Beweise in Lean einzubetteniddqdhat klare Invarianten und einen begrenzten, aber nicht trivialen Umfang und eignet sich daher als Benchmark-Kandidat für Werkzeuge formaler Verifikation
Fazit
- Generisches
unsafeRust ist extrem schwierig, weil jede Invariante auch gegenüber beliebigen und adversarialen Trait-Implementierungen erhalten bleiben muss - Der Bug in
iddqdzeigt, dass eine pathologischeOrd-Implementierung die Map so täuschen kann, dass mutable Aliase auf denselben Speicher entstehen - Keine einzelne Technik kann alle Bugs finden; deshalb werden zeilenweise menschliche
unsafe-Analyse, example-based Tests, pathologische Tests, Randomized Tests und adversariales Review mit Frontier-Modellen kombiniert - Oxide hält dieses Maß an Strenge für grundlegende Infrastruktur für gerechtfertigt
1 Kommentare
Lobste.rs-Kommentare
Wenn ich das richtig verstanden habe (bin unterwegs und habe es am Handy gelesen), scheint sich das mit einem Wrapper-Typ und der Verwendung von
HashSet/BTreeSetstattHashMap/BTreeMaplösen zu lassenDer Wrapper-Typ ist nicht zwingend nötig, aber im Hinblick auf Sicherheit und spätere Wartbarkeit eine gute Wahl
Man braucht nur einen Wrapper der Größe 0, der das Objekt umhüllt, und darin eine benutzerdefinierte Implementierung von
PartialEq/Hash, die nur auf die relevanten Felder schaut. Wenn man suchen will, ohne den vollständigen Wert zu konstruieren, kann man einen zweiten Typ mit einerAsRef-Implementierung für den Werttyp erstellenDieser Ansatz ist eine Möglichkeit, die bestehende
HashSet/BTreeSet-Schnittstelle ohneunsafeunverändert wiederzuverwenden. Statt den Wert-/Schlüsseltyp zu wrappen, könnte man auch neueHashSet-/BTreeSet-Wrapper bauen, die dieses Verhalten übernehmenDinge wie die
Entry-API sowie veränderlicher Zugriff/Iteration sind ebenfalls enthalten. Iniddqdwird Mutabilität ziemlich vorsichtig behandelt, und an einigen Stellen werden atomare Werte verwendet, um innere Mutabilität zuzulassen. Ohne Index-Indirektion wäre so etwas ziemlich schwierig gewesenUm
iddqdformal zu verifizieren, würde ich anfangs vermutlich mit einem Model Checker wie Kani beweisen wollen, dass die Map ihre internen Invarianten nicht verletzt. Mich interessiert aber die Stelle, an der gesagt wird,iddqdsei für Kani etwas zu komplex und die benötigten Beweise würden so groß, dass das Tool damit schwer zurechtkommtKönntest du diesen Teil etwas genauer erläutern? Ich frage mich, ob das bedeutet, dass die Rechenkomplexität des Algorithmus in den Worst Case entartet
Insgesamt ist das als Fallstudie zu formalen Methoden interessant, und ich fand es gut, dass dieser Teil behandelt wurde. Wenn man die Erfolgsgeschichten bestehender Werkzeuge zur formalen Verifikation in großen Projekten sieht, könnte man naiv annehmen, dass sich zumindest die Korrektheit von Datenstrukturen beweisen lässt. In der Praxis zeigt sich aber, dass der Schwierigkeitsgrad je nach Datenstruktur variiert und dass es selbst in Sprachen, die für Beweise günstiger gelten als solche wie Rust mit unbeschränktem Aliasing, praktisch nicht einfach ist
Etwas am Thema vorbei, aber ich würde auch gern wissen, wie die Diagramme erstellt wurden. Habt ihr dafür Einweg-Skripte geschrieben? Es wirkt eher wie etwas Speziell-Angefertigtes passend zum Design des Oxide-Blogs bzw. der Website und nicht wie ein allgemeines Tool
Ich habe auch versucht, den Umfang einzugrenzen. Zum Beispiel habe ich angenommen, dass
hashbrownkorrekt ist, aber auch das brachte keinen großen Fortschritt. An dem Punkt habe ich fast aufgegeben. Ich bin ziemlich überzeugt, dassiddqdbei gut funktionierenden Trait-Implementierungen korrekt ist, und was mich bei formalen Methoden wirklich interessiert hat, war ein Beweis, der auch für beliebige, fehlerhaft funktionierende Implementierungen giltIch bin allerdings nicht die Person, die Kani am besten beherrscht. Es wäre wirklich toll, wenn du oder jemand anders das einmal versuchen würdet
Die Diagramme wurden zuerst in Mermaid skizziert, danach hat der großartige Designer Ben Leonard sie von Hand so ausgearbeitet, dass sie zu unserer Farbpalette und unserem Theme passen
Das Muster einer feldbasierten Map, bei der ein Objekt anhand eines seiner Felder als Schlüssel indiziert wird, ist etwas, das ich auch in C# jederzeit gern verfügbar hätte
Ich hatte früher einmal versucht, selbst etwas Einfaches zu bauen, habe es aber wieder aufgegeben, weil die Schnittstelle nicht besonders sauber war. Durch diesen Artikel bekomme ich Lust, es noch einmal zu versuchen
cargo-nextestbis hin zu persönlichen ProjektenDie Verwendung nur eines einzelnen Felds als Schlüssel ist zwar der häufigste Anwendungsfall, aber
iddqdist flexibel genug, um jede Kombination aus Feldern, Unterfeldern oder Funktionen zu verwenden, die sich aus einem Feld rein und günstig berechnen lassen. Such zum Beispiel unter https://docs.rs/iddqd/latest/iddqd/ nachArtifactKey(bitte sieh es mir nach, falls du nicht an Rust-Syntax gewöhnt bist)Beim Entwurf von
iddqdwar ich fest davon überzeugt, dass Nutzer die volle Stärke des Rust-Typsystems einsetzen können sollten – ganz gleich, wie viele Umwege ich als Bibliotheksautor dafür gehen musste. Ich wollte wirklich, dassiddqdfür die Nutzer, besonders für meine Kollegen, eine angenehme Bibliothek ist