diff --git a/internal/rond/export.go b/internal/rond/export.go index 0161eee..da5de33 100644 --- a/internal/rond/export.go +++ b/internal/rond/export.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "hash/crc32" "io" "math" "os" @@ -1476,19 +1477,23 @@ func zipBackup(sqlitePath string, dst io.Writer) error { } } name := "LifeEasy.sqlite" + suffix - if err := func() error { - f, err := os.Open(p) - if err != nil { - return err - } - defer f.Close() - w, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Store}) - if err != nil { - return err - } - _, err = io.Copy(w, f) - return err - }(); err != nil { + // 必须用 CreateRaw 把长度与 CRC 先写进本地头:zip.Writer 默认走流式写入,会把这三项留成 0 + // 并在 flag 里置 bit3(data descriptor)、把真值挪到条目尾部——真机的读取器只认本地头里就 + // 写着长度的条目,遇到这种包会直接判成损坏(实测 2026-09-16 起每一份 Go 写的导出都被拒, + // 而唯一被接受过的那份恰好是 Python 重新打包的,它的 flags 是 0x0000)。 + data, err := os.ReadFile(p) + if err != nil { + return fmt.Errorf("读 %s 失败: %w", name, err) + } + fh := &zip.FileHeader{Name: name, Method: zip.Store} + fh.CRC32 = crc32.ChecksumIEEE(data) + fh.CompressedSize64 = uint64(len(data)) + fh.UncompressedSize64 = uint64(len(data)) + w, err := zw.CreateRaw(fh) + if err != nil { + return fmt.Errorf("打包 %s 失败: %w", name, err) + } + if _, err := w.Write(data); err != nil { return fmt.Errorf("打包 %s 失败: %w", name, err) } } diff --git a/internal/rond/export_zip_test.go b/internal/rond/export_zip_test.go new file mode 100644 index 0000000..e19f63f --- /dev/null +++ b/internal/rond/export_zip_test.go @@ -0,0 +1,66 @@ +package rond + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "hash/crc32" + "os" + "path/filepath" + "reflect" + "testing" +) + +// 导出包不许用「数据描述符」——zip 的 flag bit 3。 +// +// Go 的 zip.Writer 默认走流式写入:本地头里的长度与 CRC 留成 0,真值挪到条目尾部的描述符里, +// 并把 bit 3 置上。真机的读取器只认**本地头里就写着长度**的条目,遇到这种包直接判成损坏, +// 报「备份恢复失败,你的当前数据未被更改」。 +// +// 2026-09-16 起每一份 Go 写的导出都被拒,而唯一被真机接受过的那份(20260920-fixed-A)恰好是 +// Python 重新打包的、flags 为 0x0000 —— 09-29 一整天「修内容却一直失败」的真凶就是这里。 +func TestZipBackupWritesNoDataDescriptor(t *testing.T) { + dir := t.TempDir() + base := filepath.Join(dir, "LifeEasy.sqlite") + content := bytes.Repeat([]byte("TamaBox-zip-check/"), 256) + if err := os.WriteFile(base, content, 0o644); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := zipBackup(base, &buf); err != nil { + t.Fatalf("打包失败: %v", err) + } + + // 1) 条目名与顺序必须与真机一致;-wal/-shm 缺失时要补 0 字节占位 + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("读回打包结果失败: %v", err) + } + var names []string + for _, f := range zr.File { + names = append(names, f.Name) + } + if want := []string{"LifeEasy.sqlite", "LifeEasy.sqlite-wal", "LifeEasy.sqlite-shm"}; !reflect.DeepEqual(names, want) { + t.Errorf("条目名/顺序不对:%v,期望 %v", names, want) + } + + // 2) 直接读**第一个条目的本地头**(真机看的就是它) + b := buf.Bytes() + if len(b) < 30 { + t.Fatalf("打包结果太短(%d 字节)", len(b)) + } + flags := binary.LittleEndian.Uint16(b[6:8]) + crc := binary.LittleEndian.Uint32(b[14:18]) + csize := binary.LittleEndian.Uint32(b[18:22]) + usize := binary.LittleEndian.Uint32(b[22:26]) + if flags&0x8 != 0 { + t.Errorf("本地头 flags=0x%04x 带了 data descriptor(bit3):真机会判成损坏数据", flags) + } + if csize != uint32(len(content)) || usize != uint32(len(content)) { + t.Errorf("本地头里的长度是 0 或不对:压缩后 %d / 原始 %d,期望 %d", csize, usize, len(content)) + } + if crc != crc32.ChecksumIEEE(content) { + t.Errorf("本地头里的 CRC 不对:0x%08x,期望 0x%08x", crc, crc32.ChecksumIEEE(content)) + } +}