Files
DeepSeek-Reasonix/internal/fileutil/encoding/encoding_test.go
T
esengine 55c307834e fix(encoding): a file is GB18030 only if a write can restore its bytes
edit_file on a UTF-8 file carrying CJK text rewrote bytes it never touched:
continuation bytes of characters far from the edit came back as 84 31 A4 37.

Detect took a nil error from the GB18030 decoder as proof the file was
GB18030. The decoder never fails: it maps every invalid sequence to U+FFFD.
So any file that was not valid UTF-8 -- one stray byte is enough -- was read
as GB18030, and the read-modify-write tools (edit_file, multi_edit,
delete_range, write_file over an existing file), checkpoint restore and the
config migrations then re-encoded every unreadable sequence as the GB18030
form of U+FFFD.

Detect now names a charset only when decoding and re-encoding restores the
input byte for byte. GB18030 is tried first, then GBK, which restores what
GB18030 cannot such as CP936's single-byte euro 0x80, so such a file stays
editable in its own encoding; anything else is LossyUTF8, whose bytes pass
through untouched. The new Kind is appended because checkpoints persist it
by value.

GBK cannot hold every character, so Encode now returns an error instead of
falling back to UTF-8, which rewrote a whole CP936 file as UTF-8 when an edit
added a character such as an emoji. A legacy charset that cannot represent a
character answers an *UnencodableError naming it, its byte offset and the
charset, with ErrUnencodable as its identity; the file tools, checkpoint
restore and the config migrations refuse the write and leave the file as it
was. Fixtures encode through MustEncode.

Readers that hold only part of a stream say so rather than inherit the
whole-file answer:
- DetectFragment runs the round trip on the prefix the decoder consumes when
  not at EOF, so a character the cut split is dropped structurally. grep's
  8 KiB peek, read_file's 256 KiB sample and the desktop's 2 MiB file preview
  use it; the preview's next page starts at the split character, and lossy
  text is shown as text rather than as binary.
- DecodeOutput is the one entry point for bytes that are only displayed.
  The producer says which ends its bound cut (Cut{Head, Tail}); UTF-8 is read
  without a character split at a cut end, and an uncut end keeps its bytes,
  since a GBK pair there is output rather than half a rune. What no charset
  restores is still read as GB18030. Hook output passes its tail cut when
  truncated.
- DetectAndDecode decodes a legacy charset once; the edit tools and
  DecodeToUTF8 use it instead of detecting and decoding separately.

Tests that fail before this change (on the base or an earlier revision of it)
and pass here:
- TestE2EEditKeepsBytesOfUTF8FileWithStrayByte: e4 b8 ad became
  e4 b8 84 31 a4 37 outside the edit (fails on origin/main-v2).
- TestDetectUTF8WithStrayByteIsNotGB18030 (fails on origin/main-v2).
- TestWritesRefuseCharacterCP936CannotHold (edit_file, multi_edit,
  write_file): nil error and the whole CP936 file rewritten as UTF-8.
- TestDecodeOutputTrimsCutUTF8Edges: UTF-8 cut at the front read as mojibake.
- TestDecodeOutputKeepsUncutCodePageEdges, TestDecodeHookOutputKeepsUncutCodePageEdges:
  GBK pairs at an uncut edge (bc db, b0 a1 0d 0a, 6f 6b 20 e4 a1,
  b0 a1 20 6f 6b) were dropped as partial UTF-8 runes.
- TestEditKeepsCP936FileWithEuroByte: an edit adding Chinese wrote UTF-8
  into a CP936 file.
- TestGrepGB18030PeekWithoutNewline, TestGrepGB18030PeekCutMidCharacter:
  no matches past a peek cut mid-character.
- TestReadFileGB18030SampleWithoutNewline: garbled text past the sample.
- TestReadFileGB18030TruncatedWithoutNewline,
  TestReadFileGB18030TruncatedMidCharacter (desktop): the 2 MiB preview of a
  GB18030 file was garbled or ended in U+FFFD.
- TestDecodeHookOutputReadsTruncatedCodePageText,
  TestDecodeHookOutputReadsCP936Euro: mojibake for cut or euro-carrying cmd.exe output.
- TestEncodeRefusesCharacterTheCharsetCannotHold, TestDetectGBKWithEuroByte,
  TestDetectFragmentDropsTheSplitCharacter, TestDecodeOutputReadsCutCodePageText:
  the API's contract.
2026-09-26 11:52:22 -07:00

333 lines
8.6 KiB
Go

package encoding
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
"unicode/utf16"
"golang.org/x/text/encoding/simplifiedchinese"
)
// Detect
func TestDetectUTF8Plain(t *testing.T) {
enc, _ := Detect([]byte("hello world\n"))
if enc != UTF8 {
t.Errorf("got %v, want UTF8", enc)
}
}
func TestDetectUTF8BOM(t *testing.T) {
in := append([]byte{0xEF, 0xBB, 0xBF}, []byte("hello")...)
enc, _ := Detect(in)
if enc != UTF8BOM {
t.Errorf("got %v, want UTF8BOM", enc)
}
}
func TestDetectUTF16LE(t *testing.T) {
var b bytes.Buffer
b.Write([]byte{0xFF, 0xFE})
for _, r := range utf16.Encode([]rune("hello")) {
_ = binary.Write(&b, binary.LittleEndian, r)
}
enc, _ := Detect(b.Bytes())
if enc != UTF16LE {
t.Errorf("got %v, want UTF16LE", enc)
}
}
func TestDetectUTF16BE(t *testing.T) {
var b bytes.Buffer
b.Write([]byte{0xFE, 0xFF})
for _, r := range utf16.Encode([]rune("hello")) {
_ = binary.Write(&b, binary.BigEndian, r)
}
enc, _ := Detect(b.Bytes())
if enc != UTF16BE {
t.Errorf("got %v, want UTF16BE", enc)
}
}
func TestDetectGB18030(t *testing.T) {
gb, err := simplifiedchinese.GB18030.NewEncoder().String("你好世界")
if err != nil {
t.Fatalf("encode: %v", err)
}
enc, _ := Detect([]byte(gb))
if enc != GB18030 {
t.Errorf("got %v, want GB18030", enc)
}
}
// The GB18030 decoder accepts any bytes, mapping invalid ones to U+FFFD, so a
// UTF-8 file with one stray byte must not be taken for GB18030: re-encoding it
// would rewrite every character the decoder could not read.
func TestDetectUTF8WithStrayByteIsNotGB18030(t *testing.T) {
data := []byte("// 中文注释\nvalue := 1\n\xff\n")
if enc, _ := Detect(data); enc != LossyUTF8 {
t.Fatalf("got %v, want LossyUTF8", enc)
}
if out := MustEncode(string(Decode(data, LossyUTF8)), LossyUTF8); !bytes.Equal(out, data) {
t.Fatalf("LossyUTF8 round trip changed bytes: % x", out)
}
}
func TestDetectEmpty(t *testing.T) {
enc, _ := Detect(nil)
if enc != UTF8 {
t.Errorf("empty input: got %v, want UTF8", enc)
}
}
// Decode
func TestDecodeUTF8(t *testing.T) {
in := []byte("hello\n世界")
out := Decode(in, UTF8)
if string(out) != "hello\n世界" {
t.Errorf("got %q", out)
}
}
func TestReadFileUTF8DecodesGB18030(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(path, MustEncode(`{"label":"中文"}`, GB18030), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadFileUTF8(path)
if err != nil {
t.Fatal(err)
}
if string(got) != `{"label":"中文"}` {
t.Fatalf("ReadFileUTF8 = %q", got)
}
}
func TestDecodeUTF8BOM(t *testing.T) {
in := append([]byte{0xEF, 0xBB, 0xBF}, []byte("hello")...)
out := Decode(in, UTF8BOM)
if string(out) != "hello" {
t.Errorf("got %q, want 'hello'", out)
}
if bytes.Contains(out, []byte{0xEF, 0xBB, 0xBF}) {
t.Error("BOM leaked into decoded output")
}
}
func TestDecodeUTF16LE(t *testing.T) {
var b bytes.Buffer
b.Write([]byte{0xFF, 0xFE})
for _, r := range utf16.Encode([]rune("hello\nworld")) {
_ = binary.Write(&b, binary.LittleEndian, r)
}
out := Decode(b.Bytes(), UTF16LE)
if string(out) != "hello\nworld" {
t.Errorf("got %q", out)
}
}
func TestDecodeUTF16BE(t *testing.T) {
var b bytes.Buffer
b.Write([]byte{0xFE, 0xFF})
for _, r := range utf16.Encode([]rune("hello\nworld")) {
_ = binary.Write(&b, binary.BigEndian, r)
}
out := Decode(b.Bytes(), UTF16BE)
if string(out) != "hello\nworld" {
t.Errorf("got %q", out)
}
}
func TestDecodeGB18030(t *testing.T) {
gb, _ := simplifiedchinese.GB18030.NewEncoder().String("你好世界\n第二行")
out := Decode([]byte(gb), GB18030)
if string(out) != "你好世界\n第二行" {
t.Errorf("got %q", out)
}
}
func TestDecodeLossyUTF8(t *testing.T) {
in := []byte{0xFF, 0xFE, 'a'}
out := Decode(in, LossyUTF8)
if !bytes.Equal(out, in) {
t.Errorf("LossyUTF8 should pass through, got %q", out)
}
}
// Encode
func TestEncodeUTF8(t *testing.T) {
out := MustEncode("hello", UTF8)
if string(out) != "hello" {
t.Errorf("got %q", out)
}
}
func TestEncodeUTF8BOM(t *testing.T) {
out := MustEncode("hello", UTF8BOM)
if !bytes.HasPrefix(out, []byte{0xEF, 0xBB, 0xBF}) {
t.Error("missing UTF-8 BOM prefix")
}
if string(out[3:]) != "hello" {
t.Errorf("body = %q", out[3:])
}
}
func TestEncodeUTF16LE(t *testing.T) {
out := MustEncode("hi", UTF16LE)
if len(out) < 2 || out[0] != 0xFF || out[1] != 0xFE {
t.Error("missing UTF-16LE BOM")
}
decoded := Decode(out, UTF16LE)
if string(decoded) != "hi" {
t.Errorf("round-trip failed: got %q", decoded)
}
}
func TestEncodeUTF16BE(t *testing.T) {
out := MustEncode("hi", UTF16BE)
if len(out) < 2 || out[0] != 0xFE || out[1] != 0xFF {
t.Error("missing UTF-16BE BOM")
}
decoded := Decode(out, UTF16BE)
if string(decoded) != "hi" {
t.Errorf("round-trip failed: got %q", decoded)
}
}
func TestEncodeGB18030(t *testing.T) {
out := MustEncode("你好", GB18030)
dec, _ := simplifiedchinese.GB18030.NewDecoder().Bytes(out)
if string(dec) != "你好" {
t.Errorf("round-trip failed: got %q", dec)
}
}
// Round-trip
func TestRoundTripGB18030(t *testing.T) {
original := "你好世界\n第二行\n"
gb, _ := simplifiedchinese.GB18030.NewEncoder().String(original)
enc, _ := Detect([]byte(gb))
decoded := string(Decode([]byte(gb), enc))
if decoded != original {
t.Fatalf("decode mismatch: %q", decoded)
}
edited := strings.Replace(decoded, "第二行", "新的行", 1)
reencoded := MustEncode(edited, enc)
redecoded := string(Decode(reencoded, enc))
if redecoded != edited {
t.Errorf("round-trip failed: got %q, want %q", redecoded, edited)
}
}
func TestRoundTripUTF16LE(t *testing.T) {
original := "hello\nworld\n"
encoded := MustEncode(original, UTF16LE)
enc, _ := Detect(encoded)
decoded := string(Decode(encoded, enc))
if decoded != original {
t.Errorf("round-trip failed: got %q, want %q", decoded, original)
}
}
func TestRoundTripUTF8BOM(t *testing.T) {
original := "hello world\n"
encoded := MustEncode(original, UTF8BOM)
enc, _ := Detect(encoded)
decoded := string(Decode(encoded, enc))
if decoded != original {
t.Errorf("round-trip failed: got %q, want %q", decoded, original)
}
}
// BOM-less UTF-16
func utf16NoBOM(t *testing.T, s string, order binary.ByteOrder) []byte {
t.Helper()
var b bytes.Buffer
for _, r := range utf16.Encode([]rune(s)) {
_ = binary.Write(&b, order, r)
}
return b.Bytes()
}
func TestDetectUTF16LENoBOM(t *testing.T) {
in := utf16NoBOM(t, "// Created by 69431 on 2024/12/31\n#include \"x.h\"\n", binary.LittleEndian)
enc, _ := Detect(in)
if enc != UTF16LENoBOM {
t.Errorf("got %v, want UTF16LENoBOM", enc)
}
}
func TestDetectUTF16BENoBOM(t *testing.T) {
in := utf16NoBOM(t, "package main\nfunc main() {}\n", binary.BigEndian)
enc, _ := Detect(in)
if enc != UTF16BENoBOM {
t.Errorf("got %v, want UTF16BENoBOM", enc)
}
}
func TestDetectPlainUTF8NotUTF16(t *testing.T) {
enc, _ := Detect([]byte("the quick brown fox jumps over the lazy dog\n"))
if enc != UTF8 {
t.Errorf("ASCII text misdetected as %v", enc)
}
}
func TestDetectBinaryNotUTF16NoBOM(t *testing.T) {
// NULs on both parities — genuine binary must not look like BOM-less UTF-16.
bin := []byte{0x00, 0x00, 0x01, 0x00, 0x00, 0x02, 0xFF, 0x00, 0x00, 0x03, 0x00, 0x00, 0x04, 0x00, 0x00, 0x05, 0x00, 0x00}
if k, ok := DetectUTF16NoBOM(bin); ok {
t.Errorf("binary misdetected as %v", k)
}
}
func TestDecodeUTF16LENoBOM(t *testing.T) {
in := utf16NoBOM(t, "hello\nworld", binary.LittleEndian)
if out := string(Decode(in, UTF16LENoBOM)); out != "hello\nworld" {
t.Errorf("got %q", out)
}
}
func TestRoundTripUTF16LENoBOM(t *testing.T) {
original := "// c++ source\nint main() { return 0; }\n"
encoded := utf16NoBOM(t, original, binary.LittleEndian)
enc, _ := Detect(encoded)
if enc != UTF16LENoBOM {
t.Fatalf("detect: got %v", enc)
}
decoded := string(Decode(encoded, enc))
if decoded != original {
t.Fatalf("decode mismatch: %q", decoded)
}
edited := strings.Replace(decoded, "return 0", "return 1", 1)
reencoded := MustEncode(edited, enc)
if bytes.HasPrefix(reencoded, []byte{0xFF, 0xFE}) || bytes.HasPrefix(reencoded, []byte{0xFE, 0xFF}) {
t.Error("no-BOM re-encode leaked a BOM")
}
if redecoded := string(Decode(reencoded, enc)); redecoded != edited {
t.Errorf("round-trip failed: got %q, want %q", redecoded, edited)
}
}
// UTF-16 supplementary plane (surrogate pairs)
func TestSurrogatePairRoundTrip(t *testing.T) {
// U+1F600 (😀) is in the supplementary plane and requires a surrogate pair.
original := "hello 😀 world"
encoded := MustEncode(original, UTF16LE)
decoded := string(Decode(encoded, UTF16LE))
if decoded != original {
t.Errorf("surrogate pair round-trip failed: got %q, want %q", decoded, original)
}
}