I sistemi operativi sono di solito associati al C.

L’associazione è comprensibile. C è piccolo, prevedibile, vicino alla macchina e storicamente dominante nello sviluppo dei kernel. Dà al programmatore accesso diretto a memoria, registri, layout e convenzioni di chiamata.

Sono esattamente le cose di cui un kernel ha bisogno.

Sono anche esattamente le cose che rendono i kernel difficili da rendere sicuri.

EriX è scritto principalmente in Rust perché il progetto ruota attorno a un’idea centrale:

L’autorità deve essere esplicita.

Questo vale per capability, strutture di handoff del boot, messaggi IPC, oggetti di memoria e accesso ai dispositivi. Vale anche per il linguaggio di implementazione. Rust non rende automaticamente sicuro lo sviluppo di kernel, ma dà al codice un modo per rendere visibili ownership, mutazione, lifetimes e insicurezza.

Quella visibilità conta.


Il kernel è un posto scomodo per i bug di memoria

I bug di memoria sono cattivi ovunque.

In un kernel sono peggiori.

Un use-after-free in un’applicazione ordinaria può corrompere quell’applicazione. Un use-after-free nel kernel può corrompere lo stato dello scheduler, le tabelle di capability, gli spazi di indirizzamento o i mapping dei dispositivi.

Un errore di bounds in un parser in spazio utente può far cadere un processo. Un errore di bounds in un parser di boot può influenzare quale codice la macchina eseguirà dopo.

Un puntatore sbagliato in un’applicazione è un bug locale. Un puntatore sbagliato in un kernel può essere un bug di autorità.

Per questo la memory safety non è una preoccupazione cosmetica per EriX. Il kernel fa parte della trusted computing base. Il bootloader fa parte della trusted computing base. Diverse librerie di parser e ABI sono TCB o vicine alla TCB.

Se questi componenti gestiscono male la memoria, il modello di isolamento può fallire prima che una verifica di capability abbia modo di aiutare.


Cosa dà Rust a un kernel

Rust dà a un kernel diversi default utili.

Dà slice con controllo dei bounds invece di coppie puntatore-lunghezza non controllate nel codice ordinario.

Dà regole di ownership che rendono più difficile l’aliasing accidentale.

Dà lifetimes che possono esprimere viste prese in prestito su immagini di boot, binari ELF, blob di handoff e buffer IPC senza fingere che quelle viste possiedano lo storage sottostante.

Result ed enum come strumenti normali per rendere espliciti i percorsi di errore.

Dà un sistema di tipi abbastanza forte da distinguere molti stati che il codice C spesso rappresenterebbe come commenti, flag o convenzioni.

Nulla di questo elimina la necessità di progettare il kernel.

Rust non deciderà cosa significhi una capability. Non deciderà quale servizio debba ricevere un endpoint di dispositivo. Non dimostrerà che un protocollo IPC sia corretto. Non impedirà ogni race in uno scheduler.

Ma cambia il default.

In C, il default è che quasi ogni funzione può fare aritmetica di puntatori non controllata, creare alias di memoria mutabile, reinterpretare byte come strutture e andare in overflow silenziosamente se il programmatore non è disciplinato ovunque.

In Rust, il codice ordinario non può fare queste cose senza passare da unsafe o da un’operazione controllata esplicita.

Questa è una differenza pratica di sicurezza.


no_std è l’ambiente normale

Rust per kernel non è Rust applicativo ordinario.

Il crate del kernel EriX è #![no_std]. Non gira sopra un sistema operativo. È il substrato del sistema operativo.

Molte librerie EriX sono anch’esse no_std, tra cui:

  • lib-bootimg
  • lib-elf
  • lib-handoff
  • lib-ipc
  • lib-capabi
  • lib-bootstrap
  • lib-console
  • lib-device
  • lib-driver
  • lib-time

Diversi di questi crate usano anche #![deny(unsafe_code)].

Questa combinazione è importante. Significa che le stesse librerie possono essere usate nel boot, nel kernel e in servizi freestanding senza dipendere dai servizi dell’OS host. Significa anche che il codice di parser e ABI può spesso essere scritto senza puntatori raw.

Questo è uno degli adattamenti più forti di Rust a EriX.

Il progetto ha molti crate piccoli il cui compito non è toccare direttamente l’hardware. Il loro compito è fare parsing, validare, codificare, decodificare e rifiutare. Sono esattamente i compiti in cui Rust sicuro è prezioso.


I parser sicuri sono confini di sicurezza

EriX tratta i parser come confini di sicurezza.

lib-bootimg parse e verifica boot.img. Il suo percorso di lettura fa parte della catena di fiducia del boot. Valida struttura, allineamento, bounds, hash e firme prima che il bootloader si fidi dell’immagine.

lib-elf parse binari ELF64 prima che il bootloader usi i segmenti da caricare.

lib-handoff valida strutture di handoff versionate tra fasi del boot e tra il kernel e rootd.

lib-capabi definisce tipi di capability, diritti, costanti di slot, descrittori di trasferimento e helper di validazione dello startup.

Questi crate sono volutamente noiosi.

Usano slice di byte. Usano aritmetica controllata. Restituiscono errori strutturati. Rifiutano versioni non supportate, dimensioni sbagliate, offset sbagliati e tabelle malformate.

Non fanno I/O. Non possiedono autorità runtime. Non tirano a indovinare.

Quel design sarebbe possibile in C, ma richiederebbe disciplina sostenuta:

  • ogni puntatore deve essere controllato
  • ogni offset deve essere verificato rispetto ai range
  • ogni operazione intera deve essere auditata per overflow
  • ogni vista restituita deve essere legata manualmente al lifetime del buffer di input

Rust rende queste regole meno costose da mantenere.

Per esempio, un’immagine di boot parsata può prendere in prestito dallo slice di byte originale. Un segmento ELF validato può esporre byte presi in prestito invece di produrre un nuovo puntatore non controllato. Un parser di handoff può restituire un iteratore che controlla il range di ogni entry prima di parsarla.

Il linguaggio non prova che il parser sia semanticamente perfetto.

Elimina però una grande classe di errori accidentali di memoria dal percorso normale del parser.


unsafe non scompare

Lo sviluppo di kernel non può essere interamente Rust sicuro.

L’interfaccia hardware non è type-safe. Registri CPU, page table, descrittori di interruzione, port I/O, MMIO, buffer DMA e puntatori di ingresso del boot non arrivano come valori Rust amichevoli.

A un certo punto il kernel deve dire:

So cosa significa questo indirizzo raw.

Questa affermazione è unsafe.

EriX ha unsafe nei punti in cui la macchina lo impone:

  • il codice di ingresso del boot converte un puntatore e una lunghezza raw di handoff in uno slice di byte
  • il glue syscall x86_64 usa assembly inline e una ABI di registri fissa
  • il context switch usa assembly specifico dell’architettura
  • GDT, IDT, TSS e inizializzazione syscall toccano lo stato dei descrittori CPU
  • la manipolazione delle page table usa letture e scritture volatili
  • i percorsi seriali e legacy usano port I/O
  • i driver MMIO e DMA usano accessor volatili
  • alcuni runtime single-threaded usano UnsafeCell dietro buffer statici documentati

Questa lista non è un fallimento di Rust.

È il confine onesto tra il linguaggio e la macchina.

L’obiettivo non è non avere unsafe da nessuna parte. L’obiettivo è mantenere unsafe piccolo, locale, documentato e circondato da interfacce sicure.


I confini unsafe devono essere stretti

La domanda importante non è:

Il codebase contiene unsafe?

La domanda importante è:

Dov’è l’unsafe, e quale invariante lo rende valido?

In EriX, il ponte syscall raw in ipc-syscall_x86_64 è un buon esempio.

Il crate espone wrapper sicuri come ipc_call, ipc_recv, ipc_reply, query_local_cap e drop_local_cap. Internamente ha un solo confine di assembly raw che mappa gli argomenti sulla ABI syscall a registri x86_64 e restituisce valori raw dei registri.

Il wrapper non dereferenzia da solo i puntatori utente. Passa valori di puntatore e lunghezza al kernel, dove avvengono i controlli di capability, endpoint e lunghezza del messaggio.

Questa è la forma giusta:

  • l’assembly raw è isolato
  • l’ABI è documentata
  • l’API pubblica è tipizzata
  • la validazione semantica resta nel kernel

Il confine di context switch del kernel segue lo stesso modello. C’è assembly specifico dell’architettura per salvare e ripristinare i registri, ma il resto dello scheduler può lavorare con una struttura KernelContext e un trait ContextSwitcher. I test host possono usare uno switcher software che modella il passaggio di stato senza far saltare davvero la CPU.

Questa separazione conta perché mantiene testabile gran parte della logica dello scheduler senza eseguire istruzioni privilegiate.


L’accesso all’hardware resta accesso all’hardware

Rust non rende MMIO sicuro da solo.

Se un driver scrive il valore sbagliato nel registro sbagliato, Rust non salverà il dispositivo. Se un buffer DMA è descritto male, l’hardware può comunque scrivere in memoria. Se una entry di page table è sbagliata, la CPU applicherà il mapping sbagliato in modo molto efficiente.

EriX lo gestisce in due modi.

Primo, l’autorità hardware è resa esplicita. Un driver non dovrebbe ricevere autorità ampia sulla macchina. Dovrebbe ricevere solo il device frame, range I/O, linea di interruzione o famiglia di endpoint di cui ha bisogno.

Secondo, il codice unsafe che tocca hardware viene tenuto vicino al confine hardware.

Per esempio, drv-virtio-block ha helper volatili per MMIO e DMA. Questo è normale per un driver a blocchi. Il punto importante è che questo codice gira in un driver in spazio utente con una superficie stretta di autorità sul dispositivo, non dentro un grande kernel monolitico con piena autorità sulla macchina.

Rust aiuta dentro quel driver, ma EriX continua ad affidarsi all’architettura:

  • capability di dispositivo esplicite
  • memoria di dispositivo tipizzata come CAP_TYPE_DEVICE_FRAME
  • endpoint kernel-control stretti
  • isolamento dei driver in spazio utente
  • validazione di startup fail-closed

Il linguaggio e il design dell’OS si rafforzano a vicenda.

Nessuno dei due sostituisce l’altro.


Rust si adatta al pensiero a capability

Il modello di ownership di Rust non è la stessa cosa di un sistema a capability.

Un borrow Rust non è una capability EriX. Un tipo Rust non è autorità applicata dal kernel. Il compilatore non può sapere se un processo dovrebbe poter mappare un frame o inviare a un endpoint.

Eppure Rust e capability si incastrano bene.

Entrambi incoraggiano l’esplicitazione.

In EriX, un componente può agire solo quando possiede la capability giusta con i diritti giusti. In Rust, il codice può mutare dati solo quando ha il tipo giusto di accesso a quei dati.

Questa somiglianza è utile culturalmente e meccanicamente.

Incoraggia API in cui l’autorità è passata come valore, non scoperta attraverso stato globale. Incoraggia le funzioni a dichiarare ciò di cui hanno bisogno. Incoraggia il codice di startup a validare il bundle reale di capability ricevuto invece di assumere che un numero di slot significhi permesso.

lib-capabi è un esempio di questo stile. Non applica autorità a runtime, ma definisce il linguaggio condiviso dell’autorità: ID di tipi di capability, flag di diritti, costanti di slot, descrittori di trasferimento, helper di ruolo e validazione dello startup.

Poiché è no_std e deny(unsafe_code), questo vocabolario di autorità può essere usato ampiamente senza trasformare ogni consumatore in un consumatore di memoria raw.


Il confronto con C

C dà a un kernel massimo controllo con minimo costo di astrazione.

È la sua forza.

È anche la sua debolezza.

In C, una tabella di capability può essere indicizzata fuori bounds. Un buffer di messaggio può essere copiato con la lunghezza sbagliata. Una struttura packed su disco può essere letta attraverso un puntatore non allineato. Un puntatore può sopravvivere allo storage che descrive. Un overflow intero può trasformarsi in un’allocazione più piccola. Una funzione può mutare accidentalmente stato attraverso un alias che il chiamante non si aspettava.

Tutto questo può essere evitato in C.

Si evita con review, convenzioni, analisi statica, sanitizer, fuzzing e disciplina.

Questi strumenti contano. EriX avrebbe ancora bisogno di review, test e fuzzing se fosse scritto interamente in Rust.

La differenza è che Rust sposta molti di quei controlli nel modello predefinito del linguaggio.

Quando EriX parse un’immagine di boot, attraversa una tabella di trasferimenti, costruisce un messaggio IPC o valida un envelope di handoff, la rappresentazione ordinaria è uno slice, enum, iteratore, operazione intera controllata o Result strutturato.

In C, la rappresentazione ordinaria sarebbe spesso un puntatore, una lunghezza, un cast e una promessa.

Le promesse non sono inutili.

Ma i kernel accumulano promesse in fretta.

Rust riduce quante di quelle promesse devono essere fidate.


Rust non elimina i bug di design

Rust non è una prova di sicurezza.

Non impedisce che venga concessa la capability sbagliata.

Non impedisce a un endpoint di avere diritti troppo ampi.

Non decide se rootd, procd o deviced debba possedere una decisione di policy.

Non fa automaticamente rifiutare a un parser ogni file semanticamente invalido.

Non elimina i side channel.

Non rende coerente il DMA.

Non rende semplice l’ordine delle interruzioni.

Non rende equo lo scheduler.

Per questo EriX continua a enfatizzare l’architettura:

  • TCB piccola
  • capability esplicite
  • endpoint stretti
  • startup deterministico
  • comportamento fail-closed
  • dipendenze clean-room
  • servizi in spazio utente
  • autorità dei driver specifica per ruolo

Rust migliora il substrato di implementazione. Non sostituisce il modello di sicurezza.


Panic non è una strategia di errore

Il codice kernel ha bisogno di un modello di fallimento chiaro.

In EriX, i fallimenti recuperabili da input esterno dovrebbero essere restituiti come errori. Dati di handoff malformati, versioni non supportate, descrittori di trasferimento invalidi, tipi di endpoint sbagliati e autorità mancante non dovrebbero essere riparati silenziosamente.

Dovrebbero fallire esplicitamente.

Panic è riservato a violazioni di invarianti interni, non alla validazione ordinaria dell’input.

Questo conta perché Rust rende panic facile, ma un kernel non può trattare panic come un normale meccanismo di controllo di flusso. Un parser nella TCB non dovrebbe fare panic su input malformato. Un percorso di boot non dovrebbe fare unwind accidentalmente attraverso lo stato del firmware. Un servizio runtime non dovrebbe trattare input controllato da un attaccante come motivo per entrare in un percorso di recupero indefinito.

Il pattern Rust utile è:

  • validare prima di fidarsi
  • restituire Result
  • mantenere esplicite le varianti di errore
  • riservare panic agli stati interni impossibili
  • fallire chiuso quando lo stato di sicurezza è ambiguo

Questo pattern appare nelle crate di parser e handoff di EriX.


Benefici per i test

Rust aiuta anche nei test.

Il kernel EriX è una crate no_std, ma molta della sua logica può comunque essere testata sull’host con supporto #[cfg(test)].

Questo è utile per codice che non dovrebbe richiedere un boot VM solo per validare un invariante puro:

  • controlli di bounds dei parser
  • validazione di handoff
  • parsing di descrittori di capability
  • logica delle maschere di diritti
  • tabelle di policy degli slot
  • transizioni di stato dello scheduler
  • modelli software di context switch

Le parti specifiche dell’architettura hanno comunque bisogno di test di integrazione. Assembly inline, manipolazione delle page table, consegna delle interruzioni e transizioni a user mode devono essere testati nell’ambiente in cui girano davvero.

Ma Rust rende più facile mantenere pura la logica pura.

Questo è prezioso in un microkernel, perché molti componenti possono essere ridotti a piccole macchine a stati deterministiche attorno a messaggi e capability espliciti.


Perché questo conta per EriX

EriX non usa Rust perché Rust è di moda.

Lo usa perché gli obiettivi del progetto si allineano con i punti di forza del linguaggio:

  • ridurre l’insicurezza di memoria nel codice fidato
  • rendere visibili ownership e mutazione
  • isolare l’accesso raw all’hardware dietro confini stretti
  • mantenere il codice di parser sicuro, deterministico e auditabile
  • esprimere dati di protocollo e autorità con strutture tipizzate
  • supportare codice no_std condiviso tra boot, kernel e servizi in spazio utente
  • rendere molti bug più difficili da scrivere fin dall’inizio

Il codice unsafe restante è ancora serio.

Deve essere revisionato con la stessa cura con cui si revisionerebbe C. La differenza è che EriX può concentrare quello scrutinio nei luoghi dove la macchina forza davvero il controllo raw: codice di ingresso, setup CPU, page table, syscalls, context switches, MMIO, DMA e port I/O.

Questo è il valore pratico.

Rust non rende sicuro il kernel.

Fa risaltare le parti unsafe.


Guardando avanti

Il passo successivo è seguire l’esecuzione dal primo codice caricato dal firmware fino al kernel.

Questo percorso è il punto in cui molte idee dei post precedenti si incontrano: la TCB, i confini Rust, i parser validati, le immagini di boot firmate, le strutture di handoff e il primo trasferimento dell’autorità della macchina verso oggetti kernel espliciti.

Il prossimo post attraverserà il processo di boot di EriX dal firmware al kernel: il punto di partenza UEFI, il modello di handoff del bootloader e perché il kernel viene entrato come eseguibile higher-half.