From f98d3864b8f226c395930a93683474a46fc883f2 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 29 Sep 2026 15:04:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA=EF=BC=9Azip=20=E7=94=A8=20Cr?= =?UTF-8?q?eateRaw=20=E5=86=99=E9=95=BF=E5=BA=A6/CRC=EF=BC=8C=E5=88=AB?= =?UTF-8?q?=E5=86=8D=E5=B8=A6=20data=20descriptor=EF=BC=88=E7=9C=9F?= =?UTF-8?q?=E6=9C=BA=E5=88=A4=E6=95=B4=E5=8C=85=E6=8D=9F=E5=9D=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真正的病根(09-16 起每一份站点导出都被拒都是它):Go 的 zip.Writer 默认走流式写入——本地头里 的长度与 CRC 留 0、真值挪到条目尾部的数据描述符里,并在 flag 里置 bit3。**真机的读取器只认 「本地头里就写着长度」的条目**,遇到这种包直接判成损坏,报「备份恢复失败,你的当前数据未被更改」。 铁证(直接读文件头): 站点 Go 写的每一份导出 本地头 flags=0x0008 csize/usize=0/0 → 全部被拒 20260920-fixed-A-数据修复 本地头 flags=0x0000 有长度 → 真机接受(Python 重打包) 真机自己的包 本地头 flags=0x0800 有长度 → 正常(只有 UTF-8 位) 这也解释了之前为什么全都修错了方向:09-20 那次「修悬空引用后就能导入」其实是 Python 重打包顺手把 zip 写法修对了;而 09-29 一整天修的内容问题(活动颜色为 NULL、交通方式重名)都不是拦路虎。 修复:zipBackup 改用 zw.CreateRaw,先把三个文件读进内存算出 CRC32 与长度写进本地头, 不再用会置 bit3 的 zw.CreateHeader。 用例:TestZipBackupWritesNoDataDescriptor —— 直接读第 0 个条目的**本地头**,断言 bit3 为 0、 压缩/原始长度非 0、CRC 正确(变异验证:改回 CreateHeader → 立刻变红,且精确复现 0x0008 + 长度 0)。 真实数据验证:修复后新导出的本地头 flags=0x0000、长度正确。 全量验收 296 PASS / 0 FAIL / 0 SKIP。 --- internal/rond/export.go | 31 ++++++++------- internal/rond/export_zip_test.go | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 internal/rond/export_zip_test.go 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)) + } +}