iddqd, ou o tipo mais difícil de unsafe Rust
(oxide.computer)- iddqd é uma biblioteca de mapas Rust que toma emprestadas as chaves a partir dos valores, e mantém índices em memória para registros grandes como discos e inventário de sled no plano de controle Omicron da Oxide, onde a correção é crucial
- O
BTreeMappadrão armazena chaves e valores separadamente, o que torna o repasse inconveniente ou pode fazer chaves duplicadas ficarem desencontradas, mas o IdOrdMap faz buscas extraindo a chave de um campo dentro do registro - unsafe Rust é uma válvula de escape para expressar programas seguros que o compilador não consegue provar, e código genérico precisa suportar até mesmo Rust seguro patológico ao chamar implementações de trait fornecidas pelo usuário
- A iteração mutável de
iddqddepende do invariante de que todos os índices são distintos para estender lifetimes, e uma implementação patológica de Ord podia desencontrar a B-tree e o conjunto de itens, criando índices duplicados para o mesmo item - A correção compara chave e índice juntos e, se isso falhar, recua para uma varredura linear que não chama código do usuário; para obter confiança suficiente, é preciso combinar Miri, testes baseados em modelo, injeção de falhas com panic e revisão adversarial com LLM
O problema que o iddqd resolve
iddqdé uma biblioteca Rust que fornece mapas cujas chaves são emprestadas dos valores, e a Oxide a usa amplamente no plano de controle Omicron- O Omicron é o software no centro de um rack da Oxide que provisiona recursos como compute e storage e mantém o rack funcionando continuamente; se o
iddqdfalhar, o plano de controle pode se comportar incorretamente de maneiras imprevisíveis e difíceis de diagnosticar - Uma estrutura como
BTreeMap<Email, User>da biblioteca padrão do Rust armazena o email, que é a chave, separadamente do valor - Para passar chave e valor juntos, é preciso obter
(email, user)comget_key_value, e se um tipo separado comoUserRecordfor criado no momento da busca, isso fica difícil de lidar quando há muitos tipos de registro - Se o email também for duplicado dentro de
User, surge o risco de o email na chave do mapa e o email dentro do valor ficarem diferentes IdOrdMapusa a traitIdOrdItempara extrair a chave do registro, e o tipo da chave pode ser emprestado do valor, como emtype Key<'a> = &'a Email- Na Oxide, esse padrão se encaixou bem em registros grandes, como resultados de consultas ao banco de dados, e foi usado com utilidade para indexar grandes registros em todo o plano de controle
Recursos adicionais
- O
iddqdoferece suporte de primeira classe a chaves complexas emprestadas de vários campos, permitindo tratá-las sem desvios como dynamic dispatch BiHashMapeTriHashMapindexam cada item por duas ou três chaves, respectivamente, evitando o padrão comum de manter vários mapas manualmente- As implementações de Serde serializam como sequência, e não como mapa, para permitir serializar chaves não string em JSON, além de rejeitar chaves duplicadas
- Por compatibilidade retroativa, a serialização em formato de mapa também é suportada
Onde unsafe Rust fica mais difícil
- Em Rust, o risco central é o comportamento indefinido (undefined behavior, UB); se código seguro não puder causar UB de forma alguma, ele é sound, caso contrário é unsound
- Dentro do Rust seguro, o compilador rejeita programas com UB, mas, por limitações da análise estática, também rejeita alguns programas sem UB
- A palavra-chave
unsafeé a válvula de escape para expressar esses programas, numa abordagem em que quem escreve assume a responsabilidade pela soundness e pede ao compilador que confie nisso - Entre as regras de Rust que código unsafe precisa respeitar estão: proibição de data races, proibição de ler memória não inicializada ou já liberada, proibição de múltiplos aliases
&mutpara a mesma região de memória e proibição de modificar dados imutáveis - Essas regras são difíceis de inferir, especialmente por causa de aliasing mutável, e normalmente é preciso encapsular o unsafe por trás de abstrações seguras
Etapas para validar uma abstração segura
split_at_muté um método seguro que divide um slice mutável em duas partes, mas esse tipo de divisão não pode ser expresso apenas com Rust seguro, então unsafe é necessário- A soundness de
split_at_mutdepende de invariantes do código seguro ao redor, como receber&mut [T], verificarmid <= slice.len()e calcular corretamente o comprimento restante - O
getdeMyVec<T>depende da condição de quelenesteja correto e dentro dos limites, e essa condição precisa ser mantida por todos os outros métodos do mesmo módulo - Quando código genérico unsafe chama código fornecido pelo usuário, o nível de dificuldade é o mais alto
- Código Rust seguro, por mais patológico ou hostil que seja escrito, não deve conseguir fazer código unsafe causar UB
- Código como
collect_exact, que confia emlen()deExactSizeIteratore escreve até a capacidade, em geral é unsound porque, se o iterador retornar um tamanho falso, ele acabará escrevendo em memória não alocada std::io::Readtambém é uma trait cujas implementações bugadas podem retornar incorretamente a quantidade de bytes lidos, e a Rust RFC 2930 trata desse problema- O
iddqdse enquadra nessa categoria mais difícil porque é uma estrutura de dados genérica que chama implementações de trait fornecidas pelo usuário
Estrutura interna do iddqd
- O
iddqdcombina uma lista de itens,ItemSet, com tabelas que armazenam índices de slot IdHashMap<T>usa umItemSet<T>e umahashbrown::HashTableque armazenaItemIndexItemSet<T>tem umVec<ItemSlot<T>>, eItemSlot<T>pode serOccupied(T)ouVacant { next: ItemIndex }free_headaponta para o slotVacantliberado mais recentemente, ou para um sentinela que indica ausência de slots livres, efree_headjunto com os slotsVacantforma a cadeia livre- Ao inserir um novo item, o código verifica em
free_headse existe um slot reutilizável; se existir, sobrescreve o slotVacantcomOccupiede então movefree_headpara o próximo valor - Se não houver slot livre, ele dá
pushem um novo slotOccupiedno fim e depois obtém a chave, calcula o hash e registra o novo índice na hash table - A remoção faz o inverso: encontra e remove o índice na hash table pela chave, depois transforma o
ItemSlotcorrespondente emVacante liga ofree_headanterior comonext IdOrdMapusa um índice B-tree em vez de hash table, eBiHashMapeTriHashMaparmazenam duas e três hash tables, respectivamente
Iteração mutável e extensão de lifetime
IdOrdMap::iter_mutfaz iteração mutável dos itens na ordem das chaves- Em iteradores Rust, os valores retornados não devem tomar emprestado do próprio iterador, e combinadores como
collectpodem deixar valores comoVec<&mut T>existirem mesmo depois que o iterador já desapareceu - Para isso ser seguro, o iterador não pode retornar o mesmo valor duas vezes
- O borrow checker não consegue saber, olhando apenas o acesso sequencial à lista, que todos os índices são distintos
- O
iddqdusa extensão de lifetime comstd::mem::transmute::<&mut T, &'a mut T>, o que depende do invariante de que os índices retornados porself.itersão todos diferentes
O bug causado por uma implementação patológica de Ord
- Em um
IdOrdMapcom cinco itens inseridos em ordem com chaves de 0 a 4, consultar a chave 0 pelaEntry APIfaz com que umOccupiedEntryarmazene internamente o índice 0 - Se depois a implementação de
OrddeKeypassar a retornar sempreEqual, independentemente do valor,OccupiedEntry::removepoderá remover o item errado ao descer novamente pela B-tree usando apenas comparação de chave - Por exemplo, se na B-tree ele comparar primeiro com
(key = 2, index = 2), esse entry será removido por causa deEqual, enquanto, por causa do índice 0 mantido peloOccupiedEntry, no item set será removido o item 0 - Nesse estado, o índice 0 continua na B-tree, mas o slot 0 no item set vira vacant, e o item 2 continua no item set mesmo tendo perdido o ponteiro da B-tree
- Se depois
Ordvoltar a funcionar normalmente e um item com chave 1000 for inserido, o slot 0 apontado porfree_headserá reutilizado - O resultado é que a B-tree passa a ter índices duplicados apontando para o mesmo slot, e
IterMutpode criar múltiplas referências&mutpara a mesma memória, tornando isso unsound
Como a correção funciona e o compromisso de desempenho
- Ao descer pela B-tree, o código foi alterado para verificar igualdade não só da chave, mas também do índice
- Ao procurar uma chave com um índice conhecido, ele compara ambos
(key, index), então, mesmo se umOrdpatológico retornarEqual, a comparação entre(key = 2, index = 2)e o índice 0 procurado resultará emLesspor causa do desempate pelo índice - Para essa busca ter sucesso, o índice armazenado precisa ser de fato igual ao índice procurado
- O desempate impede correspondência com o item errado, mas não garante que o item correto sempre será encontrado
- A B-tree é ordenada por chave, enquanto o desempate compara índice, então as duas ordens em geral são independentes
- Se a busca na árvore falhar, o código recorre a uma varredura linear na B-tree para remover aquele índice sem chamar código do usuário
- Esse fallback transforma a remoção de tempo logarítmico em tempo linear, mas como ele só é alcançado na presença de código de usuário bugado, isso foi considerado um compromisso aceitável
Camadas de verificação
- Como o
iddqdé usado como uma estrutura de dados fundamental, ele combina revisão analítica com várias formas de validação empírica - Cada bloco unsafe e cada padrão unsafe são analisados por um a três autores e revisores de Rust
- Acima de cada bloco unsafe, comentários
// SAFETY:registram o raciocínio, e o lintundocumented_unsafe_blocksdo Clippy verifica a presença desses comentários - Testes baseados em exemplos criam mapas, executam operações e depois verificam os resultados; o
iddqdtem testes unitários internos e testes de integração da API pública - Esses testes também existem como doctests, funcionando ao mesmo tempo como documentação
- Testes patológicos fornecem implementações bugadas de
Orde de outras traits - No CI, tanto os testes regulares quanto os patológicos rodam sob Miri para detectar vários tipos de UB
- Condições como o invariante de não haver índices duplicados também podem ser verificadas em um ambiente de testes comum, fora do Miri
Testes baseados em modelo e injeção de falhas
- O
iddqdusa duas camadas de testes aleatórios: model-based testing comparado com um oracle correto, e fault injection por cima disso - Model-based testing, ou stateful property-based testing, aplica sequências aleatórias de operações à instância do tipo e compara os resultados com um oracle conhecido como correto
- O
iddqdexecuta testes baseados em modelo extensivos usando oNaiveMaporacle, que é ineficiente, mas claramente correto - Fault injection consiste em inserir bugs aleatórios no código do usuário, e no
iddqdum eixo especialmente útil foi panic safety - Mesmo que o código do usuário dê panic no meio de uma operação, os invariantes do mapa não podem ser quebrados
- Cada operação do mapa recebe uma contagem regressiva aleatória de panic, e a cada chamada ao código do usuário essa contagem é reduzida em 1; quando chega a 0, ocorre panic, permitindo testes aleatórios de panic safety
- Essa abordagem encontrou vários bugs sutis no
iddqd, e alguns levavam a unsoundness - Os testes baseados em modelo também verificam, após cada operação, invariantes internos como ausência de índices duplicados
- Esses testes são lentos demais para rodar sob Miri, então os invariantes dos quais soundness e correção dependem são verificados separadamente
Revisão adversarial com LLM e técnicas formais
- Modelos de fronteira da geração atual encontraram várias implementações patológicas de código de usuário que conseguiam corromper o mapa
- Em um caso notável, o LLM construiu um caminho em que um allocator customizado dava panic e fazia unwind, quebrando os invariantes do mapa
- Isso era um problema de panic safety diferente dos panics comuns de código de usuário, como implementações de
Ord, cobertos pelos testes já existentes - Como LLMs também podem produzir alegações de soundness plausíveis porém erradas, uma defesa é o red-green TDD usando Miri como oracle
- Em um bug de soundness, primeiro se cria um teste que mostre UB sob Miri, e depois se confirma que o mesmo teste passa após a correção
- Uma abordagem de provar invariantes do mapa com model checkers como
Kaniesbarra no fato de que oiddqdé complexo demais, fazendo a prova necessária crescer além do que a ferramenta consegue suportar Creusotpode ajudar a provar que código Rust está livre de panic e outros erros, mas hoje não consegue provar invariantes que precisam ser mantidos mesmo quando o código do usuário entra em panic ou se comporta incorretamente- O
NaiveMapfunciona como a especificação mais próxima doiddqd, e o CI executa os testes baseados em modelo milhares de vezes para obter alta confiança de que a implementação corresponde à especificação cargo-annealé uma ferramenta em desenvolvimento de interesse porque permitiria colocar provas de soundness em Lean em comentários de documentação ao lado do unsafe Rust- O
iddqdtem invariantes claras e um escopo limitado, embora não trivial, o que o torna um candidato a benchmark para ferramentas de verificação formal
Conclusão
- unsafe generic Rust é extremamente difícil porque precisa manter cada invariante mesmo diante de implementações arbitrárias e adversariais de traits
- O bug do
iddqdmostra que uma implementação patológica deOrdpode enganar o mapa e criar aliasing mutável para a mesma memória - Como nenhuma técnica isolada consegue encontrar todos os bugs, são usados em conjunto raciocínio humano linha por linha sobre unsafe, testes baseados em exemplos, testes patológicos, testes aleatórios e revisão adversarial com modelos de fronteira
- A Oxide considera esse nível de rigor justificado em infraestrutura fundamental
1 comentários
Comentários do Lobste.rs
Se entendi direito (estou em trânsito e vi pelo celular), isso parece dar para resolver com um tipo wrapper e usando
HashSet/BTreeSetem vez deHashMap/BTreeMapO tipo wrapper não é estritamente necessário, mas parece uma boa escolha pensando em segurança e manutenção futura
Tudo o que você precisa é de um wrapper de tamanho zero em volta do objeto, com implementações customizadas de
PartialEq/Hashque olhem só para os campos de interesse. Se quiser buscar sem construir o valor completo, dá para criar um segundo tipo que implementeAsRefpara o tipo do valorIsso parece uma forma de reaproveitar a interface existente de
HashSet/BTreeSetsemunsafe. Em vez de encapsular os tipos de valor/chave, também daria para criar um novo wrapper deHashSet/BTreeSetque fizesse esse papelTambém há coisas como a API
Entry, acesso mutável/iteração etc. Dentro doiddqd, a mutabilidade é tratada com bastante cuidado, e em alguns pontos valores atômicos são usados para permitir mutabilidade interna. Isso provavelmente teria sido bem difícil sem indireção por índicePara verificar formalmente o
iddqd, minha primeira tentativa provavelmente seria usar um model checker como o Kani para provar que o mapa não viola seus invariantes internos. Mas fiquei curioso com a parte em que oiddqdparece ser um pouco complexo demais para o Kani, e a prova necessária cresce além do que a ferramenta consegue lidarVocê poderia compartilhar mais detalhes sobre isso? Fico me perguntando se isso significa que a complexidade computacional do algoritmo degrada para o pior caso
No geral, isso parece um estudo de caso interessante em métodos formais, e gostei de ver esse tema abordado. Se você olhar casos de sucesso de ferramentas existentes de verificação formal em projetos grandes, pode ingenuamente imaginar que ao menos algo como a correção de estruturas de dados seria demonstrável, mas na prática o grau de dificuldade varia entre estruturas, e isso mostra que, mesmo em linguagens consideradas mais favoráveis à prova do que Rust, que permite aliasing irrestrito, esse tipo de coisa não é trivial em termos práticos
Fugindo um pouco do assunto, também fiquei curioso sobre como os diagramas foram feitos. Você escreveu algum script descartável, ou isso foi algo sob medida para combinar com o design do blog/site da Oxide? Não parece uma ferramenta genérica
Tentei reduzir o escopo. Por exemplo, assumi que o
hashbrownestava correto, mas isso também não ajudou muito. Nessa altura eu praticamente desisti. Tenho bastante confiança de que oiddqdestá correto para implementações de trait que se comportam bem, e o que realmente me interessava em métodos formais era uma prova que também valesse para implementações arbitrárias e com comportamento incorretoDito isso, eu não sou a pessoa que melhor usa Kani. Seria ótimo se você ou outra pessoa tentasse isso
Os diagramas foram primeiro esboçados em Mermaid, e depois o excelente designer Ben Leonard os refinou manualmente para virar diagramas alinhados à nossa paleta de cores e tema
O padrão de mapa baseado em campo, que indexa um objeto usando como chave um de seus campos, é algo que eu sempre quis poder usar em C# também
Há algum tempo tentei fazer algo simples por conta própria, mas a interface não ficou muito elegante, então abandonei. Este texto me deu vontade de tentar de novo
cargo-nextestaté projetos pessoaisUsar apenas um campo como chave é o caso mais comum, mas o
iddqdé flexível o bastante para usar qualquer combinação de campos, subcampos ou funções puras e baratas de calcular a partir dos campos. Por exemplo, basta procurarArtifactKeyem https://docs.rs/iddqd/latest/iddqd/ (desculpe se você não estiver acostumado com a sintaxe de Rust)Ao projetar o
iddqd, eu sentia fortemente que os usuários precisavam poder usar todo o poder do sistema de tipos de Rust, não importando quantas voltas eu, como autor da biblioteca, tivesse de dar para isso. Eu realmente queria que oiddqdfosse uma biblioteca prazerosa de usar para os usuários, especialmente para os meus colegas