package checkpoint import ( "errors" "os" "path/filepath" "strings" "testing" ) // ---- N3 WP3:检查点收口 ---- // 符号链接目标:恢复会跟出根外——归入 Skipped,链接与目标都不动。 func TestRewindSkipsSymlinkTarget(t *testing.T) { root := t.TempDir() log, err := Open(filepath.Join(root, "cp.jsonl")) if err != nil { t.Fatal(err) } real := filepath.Join(root, "real.txt") if err := os.WriteFile(real, []byte("被跟踪写入后"), 0o644); err != nil { t.Fatal(err) } if err := log.Record("m1", root, "real.txt", []byte("写入前"), true, []byte("被跟踪写入后")); err != nil { t.Fatal(err) } outside := filepath.Join(t.TempDir(), "outside.txt") if err := os.WriteFile(outside, []byte("根外内容"), 0o644); err != nil { t.Fatal(err) } if err := os.Remove(real); err != nil { t.Fatal(err) } if err := os.Symlink(outside, real); err != nil { t.Fatal(err) } res, err := log.Rewind("m1", []string{"m1"}) if err != nil { t.Fatal(err) } if len(res.Skipped) != 1 || res.Skipped[0] != "real.txt" { t.Fatalf("符号链接必须进 Skipped:%+v", res) } if len(res.Restored) != 0 || len(res.Failed) != 0 { t.Fatalf("符号链接不得恢复也不得算失败:%+v", res) } body, _ := os.ReadFile(outside) if string(body) != "根外内容" { t.Fatalf("根外目标被写:%q", body) } if info, _ := os.Lstat(real); info == nil || info.Mode()&os.ModeSymlink == 0 { t.Fatal("符号链接本身不得被改动") } } // 部分恢复:一个文件恢复动作失败(所在目录只读,Remove 失败), // 另一个照常恢复,失败进 Result.Failed,不中止。 func TestRewindPartialFailureContinues(t *testing.T) { root := t.TempDir() log, err := Open(filepath.Join(root, "cp.jsonl")) if err != nil { t.Fatal(err) } dirA, dirB := filepath.Join(root, "a"), filepath.Join(root, "b") for _, d := range []string{dirA, dirB} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } // 两个都是「被跟踪创建」的文件(existed=false,当前内容==写入后 → stateRestore)。 if err := os.WriteFile(filepath.Join(dirA, "keep.txt"), []byte("A后"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dirB, "fail.txt"), []byte("B后"), 0o644); err != nil { t.Fatal(err) } if err := log.Record("m1", root, "a/keep.txt", nil, false, []byte("A后")); err != nil { t.Fatal(err) } if err := log.Record("m1", root, "b/fail.txt", nil, false, []byte("B后")); err != nil { t.Fatal(err) } // 测试缝注入恢复失败(权限法对 root 不可重现——CI 以 root 跑)。 restoreForTesting = func(abs string) error { if strings.HasSuffix(abs, "b/fail.txt") { return errors.New("注入的恢复失败") } return nil } defer func() { restoreForTesting = nil }() res, err := log.Rewind("m1", []string{"m1"}) if err != nil { t.Fatalf("单文件失败不得中止:%v", err) } if len(res.Failed) != 1 || res.Failed[0] != "b/fail.txt" { t.Fatalf("失败文件应进 Failed:%+v", res) } if len(res.Restored) != 1 || res.Restored[0] != "a/keep.txt" { t.Fatalf("其余文件照常恢复:%+v", res) } if _, err := os.Stat(filepath.Join(dirA, "keep.txt")); !os.IsNotExist(err) { t.Fatal("keep.txt 应被删除") } if _, err := os.Stat(filepath.Join(dirB, "fail.txt")); err != nil { t.Fatal("fail.txt 应保留") } } // Compact:只留最近 N 个 anchor 的记录,原 Seq 保留,后续 Record 序号单调。 func TestCompactKeepsRecentAnchors(t *testing.T) { root := t.TempDir() log, err := Open(filepath.Join(root, "cp.jsonl")) if err != nil { t.Fatal(err) } for _, tc := range []struct{ anchor, rel string }{ {"m1", "a"}, {"m2", "b"}, {"m2", "c"}, {"m3", "d"}, } { if err := log.Record(tc.anchor, root, tc.rel, []byte("x"), true, []byte("y")); err != nil { t.Fatal(err) } } if err := log.Compact(2); err != nil { t.Fatal(err) } all, err := log.read() if err != nil { t.Fatal(err) } if len(all) != 3 { t.Fatalf("Compact(2) 应留 3 条(m2×2 + m3),得到 %d", len(all)) } for _, rec := range all { if rec.Anchor == "m1" { t.Fatalf("m1 应被裁掉:%+v", all) } } // Seq 保留原值且后续递增单调。 maxSeq := 0 for _, rec := range all { if rec.Seq > maxSeq { maxSeq = rec.Seq } } if err := log.Record("m4", root, "e", []byte("x"), true, []byte("y")); err != nil { t.Fatal(err) } all, _ = log.read() if all[len(all)-1].Seq <= maxSeq { t.Fatalf("Compact 后序号必须单调:%d → %d", maxSeq, all[len(all)-1].Seq) } } // 空 ordered 边界:只回滚 anchor 自己的记录(既有语义钉住)。 func TestRewindEmptyOrderedKeepsAnchorOnly(t *testing.T) { root := t.TempDir() log, err := Open(filepath.Join(root, "cp.jsonl")) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("A后"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "b.txt"), []byte("B后"), 0o644); err != nil { t.Fatal(err) } if err := log.Record("m1", root, "a.txt", []byte("A前"), true, []byte("A后")); err != nil { t.Fatal(err) } if err := log.Record("m2", root, "b.txt", []byte("B前"), true, []byte("B后")); err != nil { t.Fatal(err) } res, err := log.Rewind("m2", nil) if err != nil { t.Fatal(err) } if len(res.Restored) != 1 || res.Restored[0] != "b.txt" { t.Fatalf("空 ordered 只回滚 anchor 自己:%+v", res) } if body, _ := os.ReadFile(filepath.Join(root, "a.txt")); string(body) != "A后" { t.Fatalf("m1 的文件不得被动:%q", body) } } // 「外部同字节改写」分类为 Already(内容等价即视为同步,钉住语义)。 func TestIdenticalExternalRewriteIsAlready(t *testing.T) { root := t.TempDir() log, err := Open(filepath.Join(root, "cp.jsonl")) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil { t.Fatal(err) } if err := log.Record("m1", root, "a.txt", []byte("相同内容"), true, []byte("相同内容")); err != nil { t.Fatal(err) } // 外部用相同字节重写一遍(mtime 变、内容同)。 if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil { t.Fatal(err) } res, err := log.Plan("m1", []string{"m1"}) if err != nil { t.Fatal(err) } if len(res.Restored) != 0 || len(res.Skipped) != 0 { t.Fatalf("同字节改写应为 Already(不进任何分类):%+v", res) } } // root 移动:记录按各自 Root 恢复,不窜根。 func TestRewindUsesPerRecordRoot(t *testing.T) { root1, root2 := t.TempDir(), t.TempDir() log, err := Open(filepath.Join(root1, "cp.jsonl")) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root2, "a.txt"), []byte("root2 的内容"), 0o644); err != nil { t.Fatal(err) } if err := log.Record("m1", root2, "a.txt", []byte("写入前"), true, []byte("root2 的内容")); err != nil { t.Fatal(err) } res, err := log.Rewind("m1", []string{"m1"}) if err != nil { t.Fatal(err) } if len(res.Restored) != 1 { t.Fatalf("应按记录内 Root 恢复:%+v", res) } if body, _ := os.ReadFile(filepath.Join(root2, "a.txt")); string(body) != "写入前" { t.Fatalf("root2 未恢复:%q", body) } if _, err := os.Stat(filepath.Join(root1, "a.txt")); !os.IsNotExist(err) { t.Fatal("不得在 root1 造出同名文件") } }