1 pontos por GN⁺ 2 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • 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.assert não é macro, e sim uma função comum; ele usa unreachable para marcar caminhos impossíveis e também pode ser aproveitado em otimizações
  • Em Debug e ReleaseSafe, um assert que 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 um assert incorreto, 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 os asserts 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 *Foo não pode ser null, e um ponteiro opcional ?*Foo pode ser null, mas exige verificação antes de acessar o valor
  • assert é adequado para declarar pré-condições, pós-condições e invariantes
    • Um bom assert pode ser mais poderoso do que testes unitários para capturar erros de programação
    • Quando usado com fuzzing, o efeito de assert pode ser ainda maior

unreachable e assert em Zig

  • O assert de Zig se baseia em unreachable, 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
    • unreachable pode 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
  • O std.debug.assert da biblioteca padrão do Zig é implementado assim
    pub fn assert(ok: bool) void {
      if (!ok) unreachable; // assertion failure
    }
    
  • A informação de unreachable pode 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 assert melhora 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 @setRuntimeSafety também é possível ajustar a segurança de runtime por bloco dentro de uma função
  • Uma falha de assert vira “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
  • O resultado de unchecked illegal behavior não é garantido
    • No switch do 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
  • A diferença entre ReleaseSafe e ReleaseFast para assert e o switch seguinte 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

O assert de Zig não é macro

  • O std.debug.assert de 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 assert expressões com efeitos colaterais
    • Quando assert é desativado, essa operação pode simplesmente deixar de existir
  • 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 assert expressões com efeitos colaterais, como no exemplo abaixo
    // assert that the remove operation is not a noop:
    assert(my_map.remove("expected-to-exist"));
    
  • Por outro lado, se calcular a condição do assert exigir 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);
    }
    
  • Para quem está acostumado com a semântica de C/C++, isso pode soar estranho, mas em Zig a premissa é que assert normalmente 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 assert estiver errado
    • Desativá-lo completamente
  • std.debug.assert não oferece suporte padrão para desativação completa de assert
    • É possível implementar um assert próprio que verifique uma flag de build e se comporte mais como em C/C++
  • A vontade de desligar assert geralmente 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 assert esteja 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 assert que 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 assert ligado
    • Se desempenho for extremamente importante e o risco de comportamento incorreto for aceitável, faz sentido usar assert como oportunidade de otimização
    • Desativar assert faz você perder desempenho e ainda facilita a ilusão de estar mais seguro do que realmente está

Como asserts errados enganam a codebase

  • O risco principal é que um assert incorreto pode não aparecer nos testes e só falhar em produção
    • Se fosse possível garantir que todo assert é sempre verdadeiro, usar assert em 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
  • Quando você desativa assert em produção, perde a chance de descobrir asserts errados o quanto antes
    • O problema mais sério é que o código posterior continua sendo escrito com base nesse assert incorreto
  • No código de exemplo, a suposição é que processThing só deve ser chamada com um thing que já foi iniciado
    fn processThing(thing: Thing) void {
       // this function must always be invoked on
       // a thing that has already been started
       assert(thing.is_started);
    
       // ...
    }
    
  • Esse assert pode 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 thing já foi iniciado e que, portanto, é seguro chamar baz sem preparação extra
    fn 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 assert esteja logicamente correto, ainda existe risco se o primeiro na prática puder ser falso
    • Nos testes, como o primeiro assert não falha, o segundo também não falha
    • Em produção, com assert desativado, talvez ninguém perceba o momento em que essa vulnerabilidade entra na codebase
  • 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 assert em oportunidade de otimização é uma escolha natural
  • Desativar assert em 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
    • Ser muito crítico em relação ao ReleaseFast, mas aceitar sem questionamento a desativação de assert, é uma postura contraditória
  • 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

Os asserts implícitos do Zig não desaparecem por completo

  • Mesmo que você desative seus próprios asserts, não dá para desativar os asserts 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 assert em produção pode fazer asserts 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 assert e encarar o resultado
  • A conclusão inevitável é que, em vez de esconder o problema desativando assert, é preciso corrigir os asserts errados
    • A correção do programa deve ser buscada no todo, não apenas em um subconjunto

1 comentários

 
GN⁺ 2 시간 전
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 usar assert como dica de otimização seja sempre melhor do que simplesmente removê-lo.
    Primeiro, um assert arbitrá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 assert em 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 um assert no 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 assert inseguro 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, e assert_unchecked() entra em pânico em debug e vira dica de otimização em release.

    • Se você está preocupado com o raio de impacto dos erros, então deveria usar ReleaseSafe em vez de ReleaseFast
    • Não sou contra desativar assert individualmente, 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, assert com 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.

    • Não acho tão estranho. Mesmo que você acredite no relatório de que 70% das vulnerabilidades graves estão relacionadas à memória, isso é com base em C e C++, e o Zig pode ser um pouco melhor em segurança de memória. Além disso, com um tamanho de amostra de 2, não seria estranho que algo assim aparecesse em cerca de um entre dez projetos.
      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 assert e 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, check e 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.

    • E em que isso difere de check?
  • Como uma das pessoas linkadas aqui, digo que isso transforma minha visão sobre assert em uma falsa dicotomia ridícula e caricatural. Como também escrevi em outro comentário, prefiro decidir por assert individual se ele deve virar comportamento indefinido. Minha crítica ao ReleaseFast é que ele amarra essa escolha não só a todos os assert de um certo escopo, mas também a todas as verificações de segurança.
    Concordo com kristoff quando ele diz que é tolice desativar assert só porque um assert nã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 os assert em 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.

    • Não há padrão; a estrutura é que o usuário escolhe explicitamente entre ReleaseSafe e ReleaseFast/ReleaseSmall
  • Na 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 satisfazer
    Essa 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 assert para afirmações sobre o código e assume para afirmações sobre o contexto. Isso também se conecta à forma como alguns frameworks de teste, especialmente os de testes aleatórios, interpretam isso. Se assert falha, marcam como falha de teste; se assume falha, pulam o teste

    • O BIND9 segue um estilo próximo de Design by Contract, verificando pré-condições que o chamador deve satisfazer com a macro REQUIRE() e pós-condições garantidas pela função com ENSURE(). Também há INSIST() para verificações intermediárias e INVARIANT() para loops ou estruturas de dados. A documentação das funções deve incluir notas de “requires” e “ensures” correspondentes às pré-condições e pós-condições
  • Isso 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 unreachable deveria 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 muitos assert que 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

    • Está certo dizer que, se houver qualquer preocupação, use ReleaseSafe. Estratégias mais interessantes também são possíveis. Enquanto você estiver modificando o código, isto é, enquanto houver chance de introduzir bugs, pode mantê-lo em ReleaseSafe e, depois que o código estabilizar e tiver sido validado em produção, mudar para ReleaseFast se o ganho de desempenho valer a pena
      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 assert falhou, 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 usar assert para verificar erros. Para ser justo, não me parece que o autor esteja defendendo isso
    Por outro lado, também aparece a explicação de que, se assert depender de um cálculo complexo, esse cálculo não será necessariamente removido no modo unchecked, então deve ser protegido com comptime if
    Espero 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 if defensivos por toda parte

    • Por que isso é uma prática ruim?
  • Eu escrevo um pouco de código de computação numérica em C# e uso muitos assert que 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 NaN
    Esses 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 quebrar

  • 90% das divergências de opinião sobre assert surgem 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 rigor
    assert(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 de assume()
    check(bool) entra em pânico se a condição for falsa e continua se for verdadeira, e sempre funciona assim
    debug_check(bool) é igual a check() 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 debug
    Também é preciso uma flag de compilador --check_asserts que transforme assert() em check(). Ela serviria para verificar seus próprios assert quando 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