Você precisa corrigir seus `assert`s
(kristoff.it)asserté um mecanismo para explicitar pré-condições, pós-condições e invariantes no código; quando uma restrição pode ser imposta pelo sistema de tipos, é preferível expressá-la com recursos da linguagem- Em Zig,
std.debug.assertnão é macro, e sim uma função comum; ele usaunreachablepara marcar caminhos impossíveis e também pode ser aproveitado em otimizações - Em Debug e ReleaseSafe, um
assertque falha causa panic e derruba o programa, mas em ReleaseFast e ReleaseSmall pode virar comportamento ilegal sem verificação e levar a funcionamento incorreto - Desativar
asserts em produção faz você perder a chance de descobrir suposições erradas cedo, e depois o código pode passar a depender de umassertincorreto, abrindo caminho para vulnerabilidades - A escolha entre ReleaseSafe e ReleaseFast depende das prioridades do programa, mas o ponto central é: não esconda o problema desligando
assert; corrija osasserts errados
O papel de assert e o comportamento padrão do Zig
asserté um mecanismo para expressar no código que certas condições devem ser sempre verdadeiras, como “este argumento não pode ser null” ou “este inteiro não pode ser par”- Ex.:
assert(my_arg != null);,assert(my_num % 2 != 0); - Se a restrição puder ser imposta pelo sistema de tipos, é melhor usar o recurso da linguagem do que
assert - Em Zig, um ponteiro comum
*Foonão pode ser null, e um ponteiro opcional?*Foopode ser null, mas exige verificação antes de acessar o valor
- Ex.:
asserté adequado para declarar pré-condições, pós-condições e invariantes- Um bom
assertpode ser mais poderoso do que testes unitários para capturar erros de programação - Quando usado com fuzzing, o efeito de
assertpode ser ainda maior
- Um bom
unreachable e assert em Zig
- O
assertde Zig se baseia emunreachable, um recurso da linguagem para marcar caminhos de código inválidos- Em um
switch, é possível marcar um ramo inalcançável como.a => unreachable unreachablepode ser usado tanto como instrução quanto em lugares onde se espera uma expressão de qualquer tipo- Isso evita ter que inventar um valor temporário só para satisfazer o tipo
- Em um
- O
std.debug.assertda biblioteca padrão do Zig é implementado assimpub fn assert(ok: bool) void { if (!ok) unreachable; // assertion failure } - A informação de
unreachablepode ser aproveitada em otimizações- O compilador pode remover caminhos inalcançáveis, e essa informação pode se propagar para permitir otimizações não locais
- Nem todo
assertmelhora desempenho, mas ele também pode habilitar otimizações que o programador não preveria facilmente
Modos de build e segurança em tempo de execução
- O Zig tem os modos de build Debug, ReleaseSafe, ReleaseFast e ReleaseSmall
- Essa configuração não precisa necessariamente ser aplicada de forma global ao programa inteiro
- Cada dependência pode ser compilada em um modo diferente, e com
@setRuntimeSafetytambém é possível ajustar a segurança de runtime por bloco dentro de uma função
- Uma falha de
assertvira “illegal behavior” em Zig- Nos modos verificados, como Debug, ReleaseSafe e
@setRuntimeSafety(true), isso causa panic e derruba o programa - Nos modos não verificados, como ReleaseFast, ReleaseSmall e
@setRuntimeSafety(false), ocorre “unchecked illegal behavior”, e o programa passa a se comportar incorretamente
- Nos modos verificados, como Debug, ReleaseSafe e
- O resultado de unchecked illegal behavior não é garantido
- No
switchdo exemplo, pelas características do código de máquina gerado hoje, pode parecer que a execução cai em outro ramo - Em outra versão do compilador, o comportamento incorreto pode ser completamente diferente
- O comportamento relacionado pode ser visto neste exemplo no godbolt
- No
- A diferença entre ReleaseSafe e ReleaseFast para
asserte oswitchseguinte pode ser vista neste outro exemplo no godbolt- Em ReleaseFast, aparece uma forma em que a função ignora todas as comparações e retorna
true - Esse tipo de otimização é amplamente explorado por videogames e outras aplicações de mídia em tempo real
- Em ReleaseFast, aparece uma forma em que a função ignora todas as comparações e retorna
O assert de Zig não é macro
- O
std.debug.assertde Zig é uma função comum, não uma macro- Zig não tem macros
- Isso costuma surpreender especialmente quem vem de C/C++
- Em C/C++, ao desativar
assert, é comum que a chamada inteira e a expressão passada a ela se comportem como se tivessem sido comentadas- Por isso, em C/C++, não se deve colocar em
assertexpressões com efeitos colaterais - Quando
asserté desativado, essa operação pode simplesmente deixar de existir
- Por isso, em C/C++, não se deve colocar em
- Em Zig, pelas regras de chamada de função, os argumentos são avaliados antes da chamada
- Independentemente da lógica interna de
std.debug.assert, a expressão do argumento será avaliada - Portanto, é possível colocar em
assertexpressões com efeitos colaterais, como no exemplo abaixo
// assert that the remove operation is not a noop: assert(my_map.remove("expected-to-exist")); - Independentemente da lógica interna de
- Por outro lado, se calcular a condição do
assertexigir uma operação complexa, essa conta pode não ser necessariamente eliminada nos modos não verificados- Nesse caso, é preciso proteger o código com
comptime if
const builtin = @import("builtin"); if (builtin.mode == .Debug) { var condition = ...; // whatever bookkeeping is necessary // to compute the condition assert(condition == .ok); } - Nesse caso, é preciso proteger o código com
- Para quem está acostumado com a semântica de C/C++, isso pode soar estranho, mas em Zig a premissa é que
assertnormalmente não é desativado
O problema de desligar assert em produção
- Existem basicamente três caminhos para
assert- Mantê-lo como verificação em runtime e fazer o processo cair com panic em caso de falha
- Usá-lo como ferramenta de otimização, aceitando que o programa pode se comportar incorretamente se o
assertestiver errado - Desativá-lo completamente
std.debug.assertnão oferece suporte padrão para desativação completa deassert- É possível implementar um
assertpróprio que verifique uma flag de build e se comporte mais como em C/C++
- É possível implementar um
- A vontade de desligar
assertgeralmente vem da combinação de dois fatores- Não querer manter o custo da verificação em runtime nem lidar com crashes da aplicação
- Não confiar totalmente que o
assertesteja sempre correto e, por isso, temer o comportamento incorreto que pode surgir quando ele é usado em otimizações
- Como matklad lembrou em uma discussão relacionada, há contextos em que existem motivos legítimos de engenharia para evitar crashes
- Mas, em software geral, tratar a prevenção de crashes como padrão tende a ser uma escolha ruim
- Ao desativar
assert, o programa continua rodando mesmo quando uma condição considerada impossível acontece- O programa segue adiante com uma suposição errada, o que já é uma forma de comportamento incorreto, mesmo que não seja unchecked illegal behavior
- O motivo de unchecked illegal behavior e o undefined behavior de C serem perigosos é que eles podem transformar o programa em uma weird machine
- Em software suficientemente complexo, um programa pode se desviar de formas não intencionais mesmo sem UIB
- Um
assertque se torna falso em runtime já representa uma violação da especificação, e isso por si só pode levar o programa a executar ações não pretendidas - SQL injection é um exemplo concreto e amplamente conhecido de comportamento tipo weird machine sem depender de UIB
- Se o custo de comportamento incorreto for alto demais, faz sentido manter
assertligado- Se desempenho for extremamente importante e o risco de comportamento incorreto for aceitável, faz sentido usar
assertcomo oportunidade de otimização - Desativar
assertfaz você perder desempenho e ainda facilita a ilusão de estar mais seguro do que realmente está
- Se desempenho for extremamente importante e o risco de comportamento incorreto for aceitável, faz sentido usar
Como asserts errados enganam a codebase
- O risco principal é que um
assertincorreto pode não aparecer nos testes e só falhar em produção- Se fosse possível garantir que todo
asserté sempre verdadeiro, usarassertem otimizações não seria controverso - Se fosse possível garantir que os testes capturam todos os
asserts errados, as otimizações em produção também seriam seguras - Na prática, dá para escrever
asserts errados, e os testes não necessariamente vão detectá-los
- Se fosse possível garantir que todo
- Quando você desativa
assertem produção, perde a chance de descobrirasserts errados o quanto antes- O problema mais sério é que o código posterior continua sendo escrito com base nesse
assertincorreto
- O problema mais sério é que o código posterior continua sendo escrito com base nesse
- No código de exemplo, a suposição é que
processThingsó deve ser chamada com umthingque já foi iniciadofn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... } - Esse
assertpode nunca falhar nos testes, e em produção, por estar desativado, ninguém percebe que ele na prática pode ser falso- Se o usuário não observar comportamento incorreto, o problema pode passar despercebido e o desenvolvimento continua
- Depois, alguém pode adicionar código assumindo que
thingjá foi iniciado e que, portanto, é seguro chamarbazsem preparação extrafn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... // Since thing is already started, we don't // need to foo the bar before bazzing the qux. // It would be really bad to baz the qux otherwise, // so we add an assert for good measure. assert(thing.is_fooed); thing.baz(qux); } - Mesmo que o segundo
assertesteja logicamente correto, ainda existe risco se o primeiro na prática puder ser falso- Nos testes, como o primeiro
assertnão falha, o segundo também não falha - Em produção, com
assertdesativado, talvez ninguém perceba o momento em que essa vulnerabilidade entra na codebase
- Nos testes, como o primeiro
- Se os
asserts do código estão enganando os desenvolvedores, escrever código correto se torna desnecessariamente difícil
A escolha depende das prioridades do programa
- Cada programa tem prioridades diferentes, e em alguns casos pode ser totalmente justificável priorizar desempenho acima de minimizar o risco de comportamento incorreto
- Nesse caso, transformar
assertem oportunidade de otimização é uma escolha natural
- Nesse caso, transformar
- Desativar
assertem produção por inércia é visto como uma escolha pior do que mantê-lo ligado, e também pior do que explorar otimizações de desempenho de forma deliberada - Zine é um gerador de sites estáticos, hoje usado principalmente para compilar blogs pessoais
- O modelo de ameaça não está definido, e essa não é a maior prioridade do projeto
- Por preferir uma execução quase uma ordem de grandeza mais rápida que o Hugo, o projeto distribui builds ReleaseFast
- Awebo é uma alternativa ao Discord, auto-hospedável e em estágio pre-alpha
- Já está claro que ele lida com dados privados e será exposto à internet
- A intenção é distribuir builds ReleaseSafe no momento do lançamento
- Ainda assim, algumas dependências centrais, como FFmpeg, Xiph Opus e SQLite, deverão ser compiladas em ReleaseFast
- Nelas, o ganho de desempenho é considerado claramente mais importante do que reduzir ainda mais o risco de comportamento incorreto do programa
As escolhas de projetos reais e casos de segurança
- TigerBeetle é um banco de dados financeiro e mantém
assertsempre ligado - Ghostty é um emulador de terminal e distribui builds ReleaseFast para macOS
- Também recomenda a mesma abordagem a consumidores downstream, como mantenedores de distribuições Linux
- Dois CVEs públicos relativamente sérios do Ghostty foram casos em que era possível executar comandos arbitrários sem corrupção de memória
- Corrupção de memória e UIB não são tudo quando se fala em risco
Os asserts implícitos do Zig não desaparecem por completo
- Mesmo que você desative seus próprios
asserts, não dá para desativar osasserts implícitos que a própria linguagem Zig adiciona ao código- Isso inclui overflow de inteiro, divisão por zero, acesso fora dos limites do array etc.
- Essas condições tanto podem causar panic em runtime quanto ser usadas para fins de otimização
- A prática de desativar
assertem produção pode fazerasserts errados apodrecerem e se multiplicarem dentro da codebase- Como resultado, a paranoia em torno de UIB pode crescer, e os desenvolvedores podem passar a temer, mesmo que de forma inconsciente, reativar
asserte encarar o resultado
- Como resultado, a paranoia em torno de UIB pode crescer, e os desenvolvedores podem passar a temer, mesmo que de forma inconsciente, reativar
- A conclusão inevitável é que, em vez de esconder o problema desativando
assert, é preciso corrigir osasserts errados- A correção do programa deve ser buscada no todo, não apenas em um subconjunto
1 comentários
Comentários do Lobste.rs
Concordo que, em
assert, simplesmente causar um crash, ou um comportamento como o pânico do Rust que derruba apenas a tarefa, geralmente é a melhor opção. Mas acho difícil concordar que usarassertcomo dica de otimização seja sempre melhor do que simplesmente removê-lo.Primeiro, um
assertarbitrário muitas vezes quase não ajuda na otimização, e há muitas condições que o otimizador não consegue aproveitar diretamente. A menos que você esteja inserindo uma suposição direta como “este ramo nunca será executado”, o ganho de desempenho obtido ao espalhar suposições aleatórias pelo código provavelmente não será grande.Segundo, transformar
assertem suposição amplia muito o raio de impacto de um erro. Por exemplo, em um sistema que processa dados separados por projeto ou por usuário, imagine que haja umassertno meio de uma função de cálculo para capturar um estado que originalmente deveria ser impossível. Se ele for desativado numa build de release porque seu custo é alto, então, se for apenas uma desativação simples, o dano pode ficar restrito a um projeto ou usuário e ainda pode ser capturado por validações posteriores. Já se isso virar comportamento indefinido, o cálculo pode saltar para código aleatório, corromper memória arbitrariamente e danificar os dados de todos os projetos.No fim, escolher
assertinseguro como padrão para builds de release significa otimizar prematuramente pontos arbitrários do código em troca de reduzir a chance de localizar o dano quando algo dá errado. Acho que o Rust foi bem projetado nisso:assert!()sempre entra em pânico,debug_assert!()só entra em pânico no modo de depuração, eassert_unchecked()entra em pânico em debug e vira dica de otimização em release.ReleaseSafeem vez deReleaseFastassertindividualmente, e sim contra desativá-los em massa como se fosse uma prática geralmente recomendada.Julgar que o impacto em desempenho é grande demais para mantê-los em builds de release é totalmente razoável. Além disso, como foi dito antes,
assertcom custo computacional alto quase não têm chance de resultar em ganho de desempenho.Há alguns exemplos disso no Zine:
https://github.com/kristoff-it/zine/…
https://github.com/kristoff-it/zine/…
O Zig não tem um “modo release padrão”. Você sempre precisa escolher como tratar
assert, e as opções globais são crash ou otimização; não dá para dizer que qualquer uma das duas seja mais padrão do que a outra.Acho muito estranho o fato de que os dois CVEs relativamente sérios divulgados até agora no Ghostty tenham levado à execução arbitrária de comandos sem corrupção de memória. O fato de isso ter acontecido mesmo sendo distribuído com ReleaseFast contradiz frontalmente meu entendimento de como o mundo funciona.
Como alguém que já trabalhou com emuladores de terminal, essas vulnerabilidades são exatamente o tipo de problema espinhoso que eu esperaria. Não é para menosprezar os desenvolvedores ou pesquisadores, mas esse tipo de injeção de comando em lugares inesperados praticamente vem no pacote nessa área, assim como outras áreas acabam trazendo outros tipos de vulnerabilidade por injeção.
É engraçado ouvir há quase 40 anos o argumento de que devemos desativar
asserte verificações de limite em produção “por desempenho”. Nesse período, os computadores ficaram várias ordens de magnitude mais rápidos, e o software passou a fazer parte muito mais profundamente da vida de todos, então a correção em tempo de execução é mais importante do que nunca.Falando de algo mais produtivo, na antiga Microsoft havia, além de
assert,checke coisas do tipo, um tipo de assert para reporte que eu raramente vi em outros lugares. Ele era usado quando existia uma condição que eu não controlava completamente, eu assumia que seria verdadeira, mas tratava defensivamente o caso em que fosse falsa e queria saber, via logs ou telemetria, se isso realmente acontecia em campo. Por exemplo, usar um algoritmo quadrático assumindo que o usuário não colocará mais de 1000 itens em uma lista, ou usar um protocolo com muitas idas e voltas assumindo que a latência da rede ficará abaixo de 200 ms.check?Como uma das pessoas linkadas aqui, digo que isso transforma minha visão sobre
assertem uma falsa dicotomia ridícula e caricatural. Como também escrevi em outro comentário, prefiro decidir porassertindividual se ele deve virar comportamento indefinido. Minha crítica ao ReleaseFast é que ele amarra essa escolha não só a todos osassertde um certo escopo, mas também a todas as verificações de segurança.Concordo com kristoff quando ele diz que é tolice desativar
assertsó porque umassertnão corrigido pode causar crash. Mas não concordo que as únicas alternativas razoáveis sejam “crash ou comportamento indefinido”. A posição do goldstein, no comentário irmão, está mais próxima da minha.Tornar o comportamento de
assert_unchecked()o padrão global é difícil de defender, mas isso pode ser razoável como técnica de otimização de desempenho. Se transformar todos osassertem suposições deixa a build de produção significativamente mais rápida, então pode existir um pequeno número de suposições, idealmente apenas uma, responsável pela maior parte desse ganho, e elas poderiam ser encontradas com algo como busca binária.ReleaseSafeeReleaseFast/ReleaseSmallNa literatura de análise de programas, há uma dualidade que divide as afirmações no código, ou
assert, em duas formas. Uma é sobre o contexto ao redor do código; no caso de uma função, são as condições que o chamador deve satisfazer. A outra é sobre o próprio código; no caso de uma função, são as condições que a função deve satisfazerEssa distinção fica clara quando vista pela “responsabilidade (blame)”, um conceito acadêmico padrão na literatura sobre contratos e tipos graduais. Se uma afirmação sobre o contexto falha, a culpa não é nossa, mas do contexto ou do chamador; embora também seja possível que o chamador esteja certo e a própria afirmação esteja com bug. Se uma afirmação sobre o próprio código falha, a responsabilidade é nossa; embora também seja possível que o código esteja certo e a própria afirmação esteja com bug
No nível de função, pré-condições são afirmações sobre o contexto, e pós-condições são afirmações sobre o próprio código. Ainda assim, ambas podem ser colocadas no meio do código. Alguns frameworks de verificação usam
assertpara afirmações sobre o código eassumepara afirmações sobre o contexto. Isso também se conecta à forma como alguns frameworks de teste, especialmente os de testes aleatórios, interpretam isso. Seassertfalha, marcam como falha de teste; seassumefalha, pulam o testeIsso parece fazer alusão ao Bun, então eu gostaria de deixar a conexão um pouco mais formal. Há uma issue do Zig de 2024 em que o criador do Bun, Jarred Sumner, propôs que
unreachabledeveria causar pânico em ReleaseFast. Os comentários de Andrew Kelley e Matthew Lugg nessa thread são relevantes para esta discussão=> https://github.com/ziglang/zig/issues/19664
O Bun usa suas próprias funções
assert, que entram em pânico no modo release ou são removidas, mas não introduzem comportamento indefinido. Ainda assim, vale lembrar a nota de rodapé do Loris: “como linguagem, Zig adiciona implicitamente ao código muitosassertque não podem ser desativados”Não quero me alongar demais no assunto Bun, porque é um projeto único de uma equipe pequena. O ponto central é que, se houver qualquer preocupação, deve-se usar ReleaseSafe. ReleaseSafe tem fama de ser lento, mas nos meus pequenos projetos em Zig eu não consegui medir diferença de benchmark entre ReleaseSafe e ReleaseFast. Mesmo assim, ainda é bem provável que continue mais rápido do que muitas outras linguagens
Ou, se fizer sentido no contexto, você pode distribuir um executável ReleaseFast e, se começarem a chegar relatos de bugs não determinísticos por causa de comportamento indefinido, voltar para ReleaseSafe. Assim dá para coletar relatórios de bug acionáveis mostrando qual
assertfalhou, inclusive acessos fora dos limites ou overflow, e corrigir o código. Eu recomendaria essa abordagem até mesmo se você tiver decidido distribuir em ReleaseFast num contexto em que isso nem deveria acontecer :^)Também é possível aplicar a mesma abordagem apenas a partes do projeto, ajustando dependências e usando
@setRuntimeSafety. No fim, se houver vontade de agir com inteligência, as ferramentas necessárias estão todas aíNão se deve escrever como se fosse aceitável colocar expressões com efeitos colaterais dentro de chamadas de
assert. Isso é uma prática ruim. Também é melhor evitar usarassertpara verificar erros. Para ser justo, não me parece que o autor esteja defendendo issoPor outro lado, também aparece a explicação de que, se
assertdepender de um cálculo complexo, esse cálculo não será necessariamente removido no modo unchecked, então deve ser protegido comcomptime ifEspero que o autor não tenha deixado passar a ironia da frase “uma boa oportunidade para abandonar o trauma que as macros deixaram e abraçar a simplicidade”. Na prática, isso equivale a aceitar “a simplicidade” de ter de considerar o modo de build do programa e espalhar
comptime ifdefensivos por toda parteEu escrevo um pouco de código de computação numérica em C# e uso muitos
assertque são desativados em release. É caro demais executá-los em loops apertados, mas nos testes unitários é útil que a rotina exploda assim que vir sua primeira entrada NaNEsses NaNs muitas vezes não vêm da entrada do usuário, mas de bugs no código, como o otimizador indo para onde não deveria, e exigem restrições de contorno melhores. Claro, a entrada do usuário pode precisar de sanitização, mas isso deve ser feito no limite mais externo, não no fundo do algoritmo. Seria ótimo haver algum sistema de prova em que o resultado da sanitização da entrada do usuário permitisse provar invariantes internas do algoritmo sem
assert, mas isso é um projeto paralelo e ninguém vai morrer se quebrar90% das divergências de opinião sobre
assertsurgem porque a definição da palavra é fraca e múltipla, o que embaralha tanto o pensamento quanto a comunicação. Por isso, o conceito deveria ser dividido nos três nomes a seguir e usado com rigorassert(bool)ou, em Rust,assert_unchecked(), é algo que o programador acredita ser sempre verdadeiro e que o compilador também assume como sempre verdadeiro para usar em otimizações. Para evitar a associação com os asserts verificáveis das linguagens antigas, talvez seja melhor chamar isso deassume()check(bool)entra em pânico se a condição for falsa e continua se for verdadeira, e sempre funciona assimdebug_check(bool)é igual acheck()no modo debug e sempre continua no modo release. Na prática, isso seria controlado por uma flag--debug_checks, ativada por padrão no modo debugTambém é preciso uma flag de compilador
--check_assertsque transformeassert()emcheck(). Ela serviria para verificar seus própriosassertquando você desconfia deles, e viria ativada por padrão no modo debug. Se não estiver absolutamente claro o que se quer dizer ao falar “assert”, é impossível ter uma discussão madura e só se desperdiçam palavras