O colega exporta um CSV pelo Excel, ou salva um JSON no Bloco de Notas como "ANSI", e manda o arquivo para o repositório de integração. O seu código chama Files.readString(path) e entrega o texto ao parser. O pipeline no Linux quebra com java.nio.charset.MalformedInputException: Input length = 1. Ninguém editou o conteúdo, e o diff do arquivo está vazio.
A primeira reação costuma ser culpar o Linux do CI, mas ele não tem nada a ver com isso. Pelo javadoc, Files.readString(Path) decodifica os bytes como UTF-8, em qualquer sistema operacional. O arquivo do colega não está em UTF-8.
Abaixo está a reprodução com um arquivo de um byte só, a explicação de por que o JEP 400 não muda esse método e de por que -Dfile.encoding também não muda, e a correção em duas partes: um contrato UTF-8 explícito para os arquivos que você mesmo grava e um charset declarado para o arquivo que chega do parceiro.
Ambiente
- Temurin 25.0.4 (
25.0.4+7-LTS), sem--enable-preview - Maven 3.9.12,
maven.compiler.release=25 maven-compiler-plugin3.14.0,maven-surefire-plugin3.6.0org.junit.jupiter:junit-jupiter6.1.3- Linux,
platform encoding: UTF-8, sem Spring
O pom.xml, resumido:
<groupId>devdojo</groupId>
<artifactId>files-readstring-charset-fixture</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>25</maven.compiler.release>
<junit.version>6.1.3</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<release>25</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
O project.build.sourceEncoding=UTF-8 define como o compilador lê os seus .java. Ele não muda em nada a forma como readString decodifica o CSV do parceiro. Essa propriedade aparece em muito "fix" de encoding copiado da internet, e neste problema ela não faz nada.
Um byte, um arquivo e o readString sem charset
Para reproduzir, não é preciso CSV nem JSON. Basta o byte 0xE9:
- Em windows-1252 e em ISO-8859-1,
0xE9é oé(U+00E9). - Em UTF-8,
0xE9sozinho é uma sequência mal formada. Ele abre uma sequência de três bytes, e os outros dois não vêm. Oéem UTF-8 são dois bytes:C3 A9.
Um nome como "José" salvo como ANSI vira, no disco, exatamente esse caso: a letra acentuada ocupa um byte que não é UTF-8 válido.
O javadoc de Files no JDK 25 diz que Files.readString(Path) lê o conteúdo e decodifica os bytes usando o charset UTF-8. Ele não consulta Charset.defaultCharset() nem segue o -Dfile.encoding. Quando você quer outro charset, a sobrecarga é Files.readString(Path, Charset). O mesmo vale para Files.newBufferedReader(Path): UTF-8 declarado no javadoc, com a sobrecarga newBufferedReader(Path, Charset).
A classe Demo do projeto grava {(byte) 0xE9} num arquivo temporário e tenta ler de várias formas. Esta é a saída neste host com o default da JVM (UTF-8):
bytes=[-23]
Charset.defaultCharset()=UTF-8
new String(bytes, defaultCharset)=�
new String(bytes, ISO-8859-1)=é
Files.readString(path) threw java.nio.charset.MalformedInputException: Input length = 1
Files.readString(path, windows-1252)=é
Files.readString(path, ISO-8859-1)=é
Files.newBufferedReader(path) threw java.nio.charset.MalformedInputException: Input length = 1
newBufferedReader(path, windows-1252)=é
Repare nas duas reações diferentes ao mesmo byte. O arquivo é um só, e o que muda é a política do decodificador:
Files.readStringeFiles.newBufferedReaderreportam a entrada mal formada e lançamMalformedInputException.new String(bytes, UTF-8)substitui a entrada mal formada por U+FFFD (�) e segue em frente.
Das duas, a exceção é a mais honesta. O � não quebra nada na hora. Ele só aparece semanas depois, num relatório com o cliente "Jos�".
JEP 400 mudou o default da JVM, não este método
O JEP 400, entregue no Java 18 e ainda válido no JDK 25, tornou o UTF-8 o charset default das APIs que dependem do default. O objetivo foi fazer new String(byte[]), InputStreamReader sem charset, FileReader e companhia se comportarem igual em qualquer sistema operacional.
Os métodos de Files sem charset não estão nesse grupo. Eles já diziam UTF-8 no javadoc antes do JEP 400, então o JEP não os "consertou" nem os "quebrou". Por isso trocar o -Dfile.encoding não ajuda. Rodando a Demo com -Dfile.encoding=ISO-8859-1 e com -Dfile.encoding=windows-1252, o resultado foi este:
| API chamada | Charset sem argumento | Default UTF-8 | -Dfile.encoding=ISO-8859-1 ou windows-1252 |
|---|---|---|---|
Files.readString(path) | UTF-8 (javadoc) | MalformedInputException | MalformedInputException |
Files.newBufferedReader(path) | UTF-8 (javadoc) | MalformedInputException | MalformedInputException |
new String(bytes, Charset.defaultCharset()) | default da JVM | � | é |
new InputStreamReader(Files.newInputStream(path)) | default da JVM | code point 65533 (�) | code point 233 (é) |
Files.readString(path, Charset.forName("windows-1252")) | o que você passou | é | é |
Nas três execuções, native.encoding ficou UTF-8. O file.encoding e o Charset.defaultCharset() acompanharam a flag, e Files.readString(path) lançou a mesma exceção nas três.
Resumindo: colocar -Dfile.encoding=windows-1252 no MAVEN_OPTS do CI e manter readString(path) não resolve. Muda a leitura feita pelo new String em outro canto do código, deixa o readString exatamente como estava e ainda torna o build dependente de uma flag que ninguém vai lembrar por que existe.
A documentação de Charset explica o default charset. A de InputStreamReader mostra o construtor sem charset usando esse default. São contratos diferentes do contrato de Files.readString(Path), e é por isso que o mesmo arquivo se comporta de um jeito em cada API.
O teste que grava 0xE9 no @TempDir
Não versione um .csv "quebrado" no repositório. Editor, .gitattributes e ferramenta de merge mexem em encoding sem avisar, e o teste passaria a testar o editor de quem fez o último commit. O teste grava os bytes direto no @TempDir, sem nenhum String no caminho:
package com.devdojo.encoding;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.MalformedInputException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ReadStringCharsetTest {
private static final byte[] LATIN1_E_ACUTE = {(byte) 0xE9};
private static Path latin1File(Path dir) throws IOException {
Path p = dir.resolve("colega-windows.csv");
Files.write(p, LATIN1_E_ACUTE);
return p;
}
@Test
void readStringWithoutCharsetThrowsMalformedInputException(@TempDir Path dir) throws IOException {
Path p = latin1File(dir);
assertEquals("[-23]", Arrays.toString(Files.readAllBytes(p)));
MalformedInputException ex = assertThrows(
MalformedInputException.class,
() -> Files.readString(p));
assertTrue(ex.getMessage() != null && ex.getMessage().contains("Input length = 1"),
() -> "unexpected message: " + ex.getMessage());
}
@Test
void readStringWithWindows1252AndIso88591ReturnsEAcute(@TempDir Path dir) throws IOException {
Path p = latin1File(dir);
assertEquals("é", Files.readString(p, Charset.forName("windows-1252")));
assertEquals("é", Files.readString(p, Charset.forName("ISO-8859-1")));
assertEquals("é", Files.readString(p, StandardCharsets.ISO_8859_1));
assertEquals("é", PartnerFiles.readWindows1252(p));
assertEquals("é", PartnerFiles.readString(p, Charset.forName("windows-1252")));
}
@Test
void newBufferedReaderWithoutCharsetAlsoUtf8OnJdk25(@TempDir Path dir) throws IOException {
Path p = latin1File(dir);
MalformedInputException ex = assertThrows(MalformedInputException.class, () -> {
try (BufferedReader r = Files.newBufferedReader(p)) {
r.readLine();
}
});
assertTrue(ex.getMessage() != null && ex.getMessage().contains("Input length = 1"));
try (BufferedReader r = Files.newBufferedReader(p, Charset.forName("windows-1252"))) {
assertEquals("é", r.readLine());
}
try (BufferedReader r = new BufferedReader(
new InputStreamReader(Files.newInputStream(p), Charset.forName("windows-1252")))) {
assertEquals("é", r.readLine());
}
}
@Test
void defaultCharsetStringCtorIsNotTheSameContractAsReadString(@TempDir Path dir) throws IOException {
Path p = latin1File(dir);
String viaDefault = new String(Files.readAllBytes(p), Charset.defaultCharset());
assertEquals("UTF-8", Charset.defaultCharset().name());
assertEquals("\uFFFD", viaDefault, "UTF-8 decode of 0xE9 yields U+FFFD, readString throws instead");
assertThrows(MalformedInputException.class, () -> Files.readString(p));
}
}
mvn -q test
Resultado neste host: ReadStringCharsetTest com Tests run: 4, Failures: 0, Errors: 0, Skipped: 0.
O último teste prende o default em UTF-8 de propósito, porque é o default do JDK 25 desde o JEP 400. Se alguém injetar -Dfile.encoding no Surefire, esse teste quebra, e quebra com razão: ele está documentando que o build passou a depender de uma flag.
Arquivo seu em UTF-8, arquivo do parceiro em windows-1252
A correção depende de quem grava o arquivo.
Se o arquivo é seu (seu serviço grava e seu serviço lê), o contrato é UTF-8, escrito no código dos dois lados:
Files.writeString(path, conteudo, StandardCharsets.UTF_8);
String lido = Files.readString(path, StandardCharsets.UTF_8);
Na prática, readString(path, UTF_8) faz o mesmo que readString(path). A diferença é que o charset fica visível no código. Quem ler essa linha daqui a um ano vê que o contrato é UTF-8 sem precisar abrir o javadoc.
Se o arquivo vem de fora (parceiro, planilha exportada, "salvar como ANSI"), não chame Files.readString(Path) sem charset. Declare o charset em um único ponto de leitura:
package com.devdojo.encoding;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Inbound partner files (CSV/JSON saved as Windows-1252 / ISO-8859-1) must
* never go through {@link Files#readString(Path)} — that overload is UTF-8.
*/
public final class PartnerFiles {
private PartnerFiles() {}
public static String readString(Path path, Charset charset) throws IOException {
return Files.readString(path, charset);
}
public static String readWindows1252(Path path) throws IOException {
return readString(path, Charset.forName("windows-1252"));
}
}
Quando o encoding muda de parceiro para parceiro, ele vira configuração: PartnerFiles.readString(path, Charset.forName(configEncoding)). Os detalhes que costumam pegar:
windows-1252não existe em StandardCharsets. UseCharset.forName("windows-1252"). A constante que existe éStandardCharsets.ISO_8859_1.- Para
0xE9, windows-1252 e ISO-8859-1 dão o mesmoé, e o teste confirma isso. Os dois diferem na faixa0x80–0x9F, onde o windows-1252 coloca caracteres imprimíveis como aspas curvas e o€. Para arquivos "ANSI" vindos do Windows,windows-1252é o nome certo. - A mensagem da exceção é só
Input length = 1, sem o caminho e sem o charset esperado. Num job com quarenta arquivos, isso não ajuda ninguém. No ponto de leitura, captureCharacterCodingException(que é a superclasse deMalformedInputException) e registre no log opathe o charset usado antes de relançar. Quando o CI quebrar, o log vai dizer qual arquivo chegou em qual encoding.
A recomendação da DevDojo: em código de produção, nenhuma leitura de arquivo externo usa Files.readString(Path) ou Files.newBufferedReader(Path) sem charset, e os arquivos internos passam StandardCharsets.UTF_8 explicitamente na escrita e na leitura. O helper com charset fixo deixa de ser suficiente quando o mesmo parceiro manda arquivos em encodings diferentes de um envio para o outro. Nesse caso o problema é de contrato com o parceiro: combine o encoding no acordo de integração ou peça UTF-8. Adivinhar charset a partir dos bytes é outro assunto, e este texto não cobre.
O que fica de fora e próximo passo
Este texto trata de arquivo em disco lido com NIO. Charset de resposta HTTP, Content-Type e clientes Spring são outro contrato, e estão em charset no client HTTP do Spring. O InputStreamReader sem charset, assim como o FileReader, segue o default da JVM. É uma armadilha parecida, mas com regra diferente: ali o -Dfile.encoding muda o resultado, como a tabela mostrou.
Para conferir no seu projeto, procure as leituras de arquivo sem charset. O grep abaixo é aproximado e deixa passar chamadas com vírgula dentro do argumento, mas pega a maioria:
grep -rnE 'Files\.(readString|newBufferedReader)\([^,]*\)' src/main/java
Para cada ocorrência que lê arquivo de outra pessoa, passe o charset explícito e escreva um teste que grava 0xE9 no @TempDir, como o ReadStringCharsetTest. Depois rode mvn -q test. Se quiser comprovar que a flag da JVM não ajuda, rode java -Dfile.encoding=ISO-8859-1 -cp target/classes com.devdojo.encoding.Demo e veja Files.readString(path) lançar a mesma MalformedInputException, enquanto Charset.defaultCharset() informa ISO-8859-1 duas linhas acima.