A correção que reescreveu bytes que nunca lhe pediram para tocar


O pedido é deliberadamente minúsculo: renomear uma variável num ficheiro de parser codificado em ISO-8859-1 desde antes de entrar na equipa. O agent reporta sucesso, o diff parece um único bloco e você abre o ficheiro. O seu editor mostra é onde antes havia é, em linhas que o seu pedido nunca mencionou. O git diff --stat diz que mudaram muito mais linhas do que pediu. Nada deu erro, nada avisou, e a verificação da própria ferramenta dizia que o patch tinha persistido. Esta é a história de dois caminhos de escrita que podiam perder bytes que nem sequer estavam a tocar — e de porque é que a rede de segurança lhes dava razão.

Um caminho de escrita é tão exato quanto a sua leitura

Aqui está a parte fácil de não ver quando se pensa em ferramentas de ficheiros: para alterar um ficheiro, a ferramenta tem primeiro de o ler, e o que essa leitura faz aos bytes é o que é escrito de volta. Duas leituras nestes caminhos tinham perdas.

O modo de patch predefinido passava pelo transporte de texto. mode="replace" — o predefinido — e o modo de edição V4A, para qualquer byte após a primeira amostra binária de 1 000 bytes, descodificavam conteúdo com errors="replace". Esse é o descodificador educado que se quer para visualização: qualquer byte que o UTF-8 não consiga descodificar passa a U+FFFD, o carácter de substituição. Para um humano a ler um log, isso é um favor. Para uma leitura que alimenta uma escrita, é uma mutação silenciosa — cada byte indecodificável em linhas que a sua edição nunca tocou saía como EF BF BD. Um patch de uma linha nesse parser Latin-1 reescreveu todos os 0xE9 do ficheiro.

A leitura V4A corria uma limpeza de que já não precisava. O caminho de leitura em bruto empurrava o ficheiro inteiro pela limpeza de fugas de fences do terminal, que retira sequências de escape OSC e bytes BEL, e descarta qualquer linha que contenha um literal __HERMES_FENCE_. Esse wrapper era um contorno para uma camada de backend entretanto substituída; nada o emite agora, o que significa que a limpeza só podia remover bytes que pertenciam ao seu ficheiro — caracteres de controlo num fixture, numa captura, num ficheiro de protocolo legado.

Porque é que nada o apanhou

A ferramenta tem um passo de verificação. O diff que lhe foi mostrado também. Ambos releem o ficheiro através do mesmo transporte com perdas que acabara de o corromper. Os bytes corrompidos descodificam para os mesmos caracteres de substituição no caminho de volta, a comparação coincide e a ferramenta reporta um patch limpo e persistido.

Essa combinação — mutar na leitura, depois verificar com a mesma leitura — é o padrão que vale a pena recordar para além deste projeto. Uma verificação que partilha a transformação que causou o dano não a consegue detetar; só pode confirmar que o dano é estável. O sintoma reportado nunca era, por isso, uma mensagem de erro. Era uma autópsia: um revisor a notar mojibake no git diff, ou a CI a falhar num ficheiro cuja codificação tinha mudado em silêncio por baixo dela.

O que a correção faz

PR #120515 faz com que ambos os modos de patch leiam exatamente os bytes que escrevem de volta. As leituras são nativas num host POSIX local para ficheiros regulares (um FIFO continua a ir pelo caminho da shell, com o seu timeout), e base64 pela ligação para backends remotos. O conteúdo atravessa depois a camada de texto com surrogateescape, o codec que faz ida e volta de qualquer byte indecodificável sem perdas — é o inverso exato da codificação que o write_file já usava para este fim, fixada por um teste de ida e volta.

Um detalhe explica porque é que não fizeram simplesmente de «não é UTF-8 válido» um erro de leitura: as operações V4A Add File, Delete File e ambos os extremos de Move File usam a falha de leitura como teste de existência. Transformar um ficheiro com codificação invulgar num erro de leitura teria feito a ferramenta reportar que um ficheiro que existe claramente não existe. O surrogateescape mantém esses ficheiros legíveis e exatos ao byte.

O bug irmão: escritas que esvaziavam o ficheiro

A segunda correção, PR #122012, é pior da forma mais direta. Nos backends de heredoc-stdin — Modal, Daytona e Vercel — o write_file e o patch não corrompiam o alvo. Substituíam-no por um ficheiro vazio.

O mecanismo é um detalhe de shell: o heredoc estava ligado apenas ao último comando do script wrapper (trap - EXIT), por isso o cat > "$tmp" que devia receber o conteúdo não lia nada de todo, e o ficheiro temporário vazio era movido para o lugar. A correção redireciona o heredoc para o comando composto inteiro, e acrescenta a metade que faltava para que o corpo chegue exato ao byte — sem a nova linha final que o heredoc acrescenta, para que as escritas se verifiquem.

O commit de seguimento é uma pequena lição de portabilidade de shell: o payload é reemitido usando apenas builtins da shell, evitando deliberadamente base64/head, porque um binário em falta voltaria a entregar silenciosamente stdin vazio ao comando; e || : é necessário porque, com o set -e herdado, o estado de fim de ficheiro do read matava o leitor e produzia stdin vazio, medido em bash 3.2 e 5.2. Um bug que esvazia ficheiros à distância foi corrigido sem acrescentar uma única dependência externa ao caminho crítico.

O que fazer em relação a isto

  1. Saiba a codificação antes de fazer o patch. file -I path/to/file leva um segundo e diz-lhe se está em UTF-8 ou numa codificação legada de um byte. Tudo o que não seja UTF-8 merece as verificações abaixo.
  2. Verifique com bytes, não com a ferramenta. git diff --stat (contagens de linhas, não «parece bem»), cmp contra uma cópia boa conhecida, ou um hash da região que não tocou. Para ficheiros próximos de binário — fixtures, capturas, blobs de fornecedores — um hash é a única verificação honesta.
  3. Trate a verificação pós-edição como suspeita quando vem da mesma ferramenta. Se a ferramenta que escreveu o ficheiro também lhe diz que o ficheiro está bem, isso é uma afirmação sobre o seu próprio caminho de leitura, não sobre os seus bytes.
  4. Volte a verificar tudo o que foi escrito para um backend de sandbox nas últimas semanas. Um ficheiro de zero bytes é reportado como ficheiro; se uma configuração, um lockfile ou um fixture está misteriosamente vazio, isto é um candidato a explicação. Vazio é mais fácil de ver do que corrompido, mas só se olhar.
  5. Ambas as correções estão apenas em main. Foram integradas a 2026-09-25, um dia depois da tag v0.21.5, por isso não estão nessa tag (veja a página de lançamento da v0.21.5 para saber o que ela contém). Chegam com a próxima release ou com um hermes update que segue main. Se está hoje nos caminhos alojados ou de sandbox, crie o hábito de verificar o tamanho dos ficheiros depois de uma escrita.

A codificação é um tema recorrente nas correções recentes deste projeto — o artigo sobre a correção de locale no Windows cobre a outra metade, em que o crash vinha da descodificação em vez da escrita. E a pergunta geral «os meus dados estão realmente intactos?» tem um tratamento mais longo nas notas sobre corrupção da base de dados de estado: a mesma regra, uma camada de armazenamento diferente.

O que retirar daqui

A falha interessante aqui não é que um ficheiro tenha ficado corrompido. É que a verificação partilhava a falha. Uma verificação que corre pela mesma transformação, codec ou transporte que a operação que está a verificar só pode confirmar consistência — nunca correção. Quando constrói um pipeline que lê, transforma e escreve dados que não escreveu, mantenha pelo menos uma verificação independente que nunca toca na transformação: uma contagem de bytes, um hash, um tamanho, uma cópia feita antes da escrita. Neste caso, essa verificação independente é exatamente o que a correção voltou a tornar possível, e é a razão pela qual a próxima corrupção silenciosa deste tipo vai aparecer como um diff em vez de uma surpresa.