Skip to content

Commit 8683350

Browse files
melgarafaelclaude
andcommitted
fix(catalogo): um byte ruim deixa de condenar o arquivo inteiro a windows-1252
REGRESSÃO achada por varredura adversarial dos merges do próprio dia — e ela piorou o caminho que não era o alvo do #500. O desempate era `if (utf8.includes("�"))`: decisão por ARQUIVO tomada sobre um sinal por BYTE, sem proporção. Um único byte inválido no meio de um arquivo perfeitamente UTF-8 — 0x92, a aspa curva do Word, sobra comum de copiar-colar — jogava TODAS as linhas para o windows-1252. Medido com as funções deste arquivo: arquivo limpo → "Ação" corretos=500 mojibake= 0 + 1 byte 0x92 no meio → "Ação" corretos= 0 mojibake=500 antes do #500 → "Ação" corretos=500, com U+FFFD=1 Ou seja: nesse caminho, o `file.text()` que o #500 substituiu era MELHOR — ele corrompia um caractere, não o arquivo. E o `upsert` por `(organization_id, codigo)` sobrescreve os nomes bons que já estavam no catálogo, com `erros: []` e 200 OK. Falha aberto, e apaga dado certo. O CONSERTO é a densidade, e o número veio de medição, não de palpite — as duas causas ficam a três ordens de grandeza de distância no mesmo texto: latin-1 de verdade (o que o #500 conserta) → 1 U+FFFD a cada 5 bytes UTF-8 com 1 byte inválido → 1 U+FFFD a cada 11.392 misto: 499 linhas UTF-8 + 1 linha latin-1 → 1 U+FFFD a cada 5.692 `MAX_BYTES_POR_SUBSTITUICAO = 100` fica no meio desse vale, com mais de uma ordem de grandeza de folga para cada lado. E é generoso de propósito, o que está escrito no código: errar para o lado do UTF-8 corrompe um caractere; errar para o outro corrompe o arquivo inteiro. Os dois erros não custam o mesmo. PROVA nos três casos, com a função real: 500/500 corretos em todos — inclusive o latin-1 puro, que é o conserto original do #500 continuando de pé. O TESTE traz o par que impede o degenerado: "latin-1 de verdade CONTINUA sendo detectado", senão "sempre devolva UTF-8" satisfaria o caso novo e desfaria o #500 inteiro. E um terceiro caso documenta a folga do número, para avisar se um dia as duas causas se aproximarem. Sabotagem (voltar ao desempate binário): previ 2 vermelhos, medi 1. A previsão estava errada — o caso da densidade mede o `TextDecoder` diretamente, não a função, então não alcançava o mecanismo sabotado. Gates: planilha-em-latin-1 13/13 · typecheck exit=0 (cache apagado, e --listFilesOnly confirma o csv.ts carregado) · changelog-cabe-na-tela 5/5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 79c7bef commit 8683350

2 files changed

Lines changed: 110 additions & 2 deletions

File tree

lib/contacts/csv.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,33 @@ import { normalizePhoneBR } from "@/lib/webhooks/inbound";
2626
*
2727
* O desempate não é detecção de charset, é uma prova: `TextDecoder("utf-8")` só
2828
* produz U+FFFD quando o byte-stream NÃO é UTF-8 válido. Ausência de U+FFFD é
29-
* prova de que UTF-8 é a leitura certa; presença é prova de que não é.
29+
* prova de que UTF-8 é a leitura certa.
30+
*
31+
* ⚠️ MAS A PRESENÇA NÃO É PROVA DO CONTRÁRIO, e a primeira versão disto tratava
32+
* como se fosse — a decisão era por ARQUIVO sobre um sinal por BYTE, sem
33+
* proporção. Um único byte inválido no meio de um arquivo perfeitamente UTF-8
34+
* (a aspa curva do Word, 0x92, sobra comum de copiar-colar) jogava as 500 linhas
35+
* boas para o `windows-1252`. Medido, com as funções deste arquivo:
36+
*
37+
* arquivo limpo → utf-8 "Ação" corretos=500 mojibake= 0
38+
* + 1 byte 0x92 no meio → windows-1252 "Ação" corretos= 0 mojibake=500
39+
* antes deste arquivo → "Ação" corretos=500, com U+FFFD=1
40+
*
41+
* Ou seja: no caminho do byte solto, a versão anterior a `file.text()` era
42+
* MELHOR — ela corrompia um caractere, não o arquivo. E o `upsert` por
43+
* `(organization_id, codigo)` sobrescreve os nomes bons que já estavam no
44+
* catálogo, com `erros: []` e 200 OK.
45+
*
46+
* A prova certa é a DENSIDADE, porque as duas causas ficam a três ordens de
47+
* grandeza de distância. Medido no mesmo texto de 500 linhas:
48+
*
49+
* latin-1 de verdade (o que este arquivo conserta) → 1 U+FFFD a cada 5 bytes
50+
* UTF-8 com 1 byte inválido → 1 U+FFFD a cada 11.392
51+
* misto: 499 linhas UTF-8 + 1 linha latin-1 → 1 U+FFFD a cada 5.692
52+
*
53+
* `MAX_BYTES_POR_SUBSTITUICAO` fica no meio dessa distância, e é generoso de
54+
* propósito: errar para o lado do UTF-8 corrompe um caractere; errar para o
55+
* outro corrompe o arquivo inteiro. Os dois erros não custam o mesmo.
3056
*
3157
* O `windows-1252` "consegue" ler qualquer byte, então cair nele sem olhar o
3258
* resultado transformaria um .xlsx renomeado em 300 produtos de nome ilegível.
@@ -39,11 +65,28 @@ import { normalizePhoneBR } from "@/lib/webhooks/inbound";
3965
* planilha — é UTF-8 VÁLIDO, não tem U+FFFD nenhum, e passa limpo. É outro
4066
* defeito, com outra evidência.
4167
*/
68+
/**
69+
* A partir de quantos bytes por substituição o arquivo deixa de ser "latin-1" e
70+
* passa a ser "UTF-8 com um byte ruim".
71+
*
72+
* 100 fica entre as duas causas medidas (5 e 5.692 bytes por U+FFFD) com folga
73+
* de mais de uma ordem de grandeza para cada lado — não é um número escolhido
74+
* para caber num caso, é o meio de um vale largo.
75+
*/
76+
const MAX_BYTES_POR_SUBSTITUICAO = 100;
77+
4278
export function decodificarCsv(bytes: ArrayBuffer | Uint8Array): { texto: string } | { erro: string } {
4379
const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
4480

4581
const utf8 = new TextDecoder("utf-8").decode(buf);
46-
if (!utf8.includes("\uFFFD")) return { texto: semBom(utf8) };
82+
const substituicoes = (utf8.match(/\uFFFD/g) ?? []).length;
83+
// Sem nenhuma: UTF-8 válido, e a prova é completa.
84+
if (substituicoes === 0) return { texto: semBom(utf8) };
85+
// Com poucas: é UTF-8 com sujeira pontual, não outro charset. Trocar de
86+
// decoder aqui estragaria o arquivo inteiro para consertar um caractere.
87+
if (buf.byteLength / substituicoes > MAX_BYTES_POR_SUBSTITUICAO) {
88+
return { texto: semBom(utf8) };
89+
}
4790

4891
const latin = new TextDecoder("windows-1252").decode(buf);
4992
// eslint-disable-next-line no-control-regex -- é exatamente o que se procura

tests/unit/planilha-em-latin-1-nao-entra-corrompida.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,68 @@ describe("as rotas de importação decodificam pelos BYTES", () => {
152152
expect(semComentarios(comDefeito).includes("decodificarCsv(")).toBe(false);
153153
});
154154
});
155+
156+
describe("um byte ruim não condena o arquivo inteiro", () => {
157+
/**
158+
* ⚠️ REGRESSÃO MEDIDA depois do merge, numa varredura adversarial dos próprios
159+
* merges do dia. O desempate original era `if (utf8.includes("\uFFFD"))` —
160+
* decisão por ARQUIVO tomada sobre um sinal por BYTE, sem proporção.
161+
*
162+
* Um único byte inválido no meio de um arquivo perfeitamente UTF-8 (0x92, a
163+
* aspa curva do Word, sobra comum de copiar-colar) jogava TODAS as linhas para
164+
* o windows-1252. Medido com esta mesma função:
165+
*
166+
* arquivo limpo → "Ação" corretos=500 mojibake= 0
167+
* + 1 byte 0x92 no meio → "Ação" corretos= 0 mojibake=500
168+
*
169+
* E o caminho anterior ao conserto (`file.text()`, UTF-8 com substituição
170+
* local) era MELHOR nesse caso: corrompia UM caractere, não o arquivo. O
171+
* `upsert` por `(organization_id, codigo)` ainda sobrescrevia os nomes bons
172+
* que já estavam no catálogo — com `erros: []` e 200 OK.
173+
*/
174+
const cemLinhas = Array.from({ length: 100 }, (_, i) => `Ação Cônica nº ${i + 1}`).join("\n");
175+
176+
it("UTF-8 com UM byte inválido continua sendo lido como UTF-8", () => {
177+
const limpo = Buffer.from(cemLinhas, "utf8");
178+
const meio = Math.floor(limpo.length / 2);
179+
const sujo = Buffer.concat([limpo.subarray(0, meio), Buffer.from([0x92]), limpo.subarray(meio)]);
180+
181+
const r = decodificarCsv(new Uint8Array(sujo));
182+
const texto = "texto" in r ? r.texto : "";
183+
expect(
184+
(texto.match(/Ação/g) ?? []).length,
185+
"um byte solto derrubou o arquivo inteiro para windows-1252 — a decisão " +
186+
"por arquivo voltou a ser tomada sobre um sinal por byte",
187+
).toBeGreaterThanOrEqual(99);
188+
expect((texto.match(/Aç/g) ?? []).length, "mojibake em massa").toBe(0);
189+
});
190+
191+
it("latin-1 de verdade CONTINUA sendo detectado — o par do «não faça X»", () => {
192+
// Sem este caso, "sempre devolva UTF-8" satisfaria o de cima e desfaria o
193+
// conserto que este arquivo inteiro existe para guardar.
194+
const latin = Buffer.from(cemLinhas, "latin1");
195+
const r = decodificarCsv(new Uint8Array(latin));
196+
const texto = "texto" in r ? r.texto : "";
197+
expect(
198+
(texto.match(/Ação/g) ?? []).length,
199+
"latin-1 puro deixou de ser detectado — o conserto original foi desfeito",
200+
).toBeGreaterThanOrEqual(99);
201+
});
202+
203+
it("a densidade separa as duas causas por ordens de grandeza — o porquê do número", () => {
204+
// Documenta a folga que sustenta MAX_BYTES_POR_SUBSTITUICAO: se um dia as
205+
// duas se aproximarem, este caso avisa antes de alguém descobrir na planilha
206+
// de um cliente.
207+
const conta = (b: Buffer) => {
208+
const d = new TextDecoder("utf-8").decode(b);
209+
const n = (d.match(/\uFFFD/g) ?? []).length;
210+
return n === 0 ? Infinity : b.byteLength / n;
211+
};
212+
const limpo = Buffer.from(cemLinhas, "utf8");
213+
const meio = Math.floor(limpo.length / 2);
214+
const sujo = Buffer.concat([limpo.subarray(0, meio), Buffer.from([0x92]), limpo.subarray(meio)]);
215+
216+
expect(conta(Buffer.from(cemLinhas, "latin1")), "latin-1 real ficou esparso demais").toBeLessThan(50);
217+
expect(conta(sujo), "um byte ruim ficou denso demais").toBeGreaterThan(500);
218+
});
219+
});

0 commit comments

Comments
 (0)