TextDecoder.java
/*
* SPDX-License-Identifier: Apache-2.0
* Copyright 2026 Nikolas Charalambidis
*/
package dev.nichar.facepalm.engine;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/**
* Decodes file content for scanning without silently dropping files in legacy encodings.
*
* <ol>
* <li>A UTF-8 or UTF-16 byte order mark selects that encoding.</li>
* <li>Content containing a NUL byte is treated as binary and not scanned.</li>
* <li>Valid UTF-8 is decoded as UTF-8.</li>
* <li>Anything else falls back to ISO-8859-1, which maps every byte, so files such as Latin-1
* {@code .properties} (the Java default before Java 9) are still scanned.</li>
* </ol>
*/
final class TextDecoder {
private TextDecoder() {
}
/**
* Returns the decoded text, or {@code null} if the content is binary.
*/
@Nullable
static String decode(@Nonnull final byte[] bytes) {
if (startsWith(bytes, 0xEF, 0xBB, 0xBF)) {
return new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8);
}
if (startsWith(bytes, 0xFE, 0xFF)) {
return new String(bytes, 2, bytes.length - 2, StandardCharsets.UTF_16BE);
}
if (startsWith(bytes, 0xFF, 0xFE)) {
return new String(bytes, 2, bytes.length - 2, StandardCharsets.UTF_16LE);
}
for (final byte b : bytes) {
if (b == 0) {
return null;
}
}
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException e) {
return new String(bytes, StandardCharsets.ISO_8859_1);
}
}
private static boolean startsWith(@Nonnull final byte[] bytes, final int... prefix) {
if (bytes.length < prefix.length) {
return false;
}
final var head = new byte[prefix.length];
for (int i = 0; i < prefix.length; i++) {
head[i] = (byte) prefix[i];
}
return Arrays.equals(Arrays.copyOf(bytes, prefix.length), head);
}
}