package tools import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) // ---- S0:冻结基底与 CAS 回收 ---- func write(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func read(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(b) } // 冻结:硬链接基底 + 基线清单;.agentx 不进基底。 func TestFreezeBaseManifest(t *testing.T) { dir := t.TempDir() write(t, filepath.Join(dir, "a.md"), "alpha") write(t, filepath.Join(dir, "sub", "b.txt"), "beta") write(t, filepath.Join(dir, ".agentx", "transcript.jsonl"), "{}") frozen := filepath.Join(dir+"-ws", "t1") manifest, err := freezeBase(dir, frozen) if err != nil { t.Fatal(err) } if len(manifest) != 2 { t.Fatalf("manifest = %v(.agentx 必须排除)", manifest) } if strings.TrimSpace(read(t, filepath.Join(frozen, "a.md"))) != "alpha" { t.Fatal("frozen copy content mismatch") } if _, err := os.Lstat(filepath.Join(frozen, ".agentx")); !os.IsNotExist(err) { t.Fatal(".agentx must not be frozen") } // 基底不可变验证:改 upper 前先确认冻结文件是硬链接(与源同 inode—— // 原地写会穿透,这是文档化的边界;CAS 依赖哈希清单而非 inode)。 } // 回收:新增/修改 CAS 写回;删除(whiteout 形态在集成测试覆盖,这里用 // 直接删 upper 对应目标的语义等价路径——applyUpper 只看 upper 内容)。 func TestApplyUpperAddModDel(t *testing.T) { dir := t.TempDir() write(t, filepath.Join(dir, "keep.txt"), "keep") write(t, filepath.Join(dir, "mod.txt"), "old") write(t, filepath.Join(dir, "gone.txt"), "gone") frozen := filepath.Join(dir+"-ws", "t1") manifest, err := freezeBase(dir, frozen) if err != nil { t.Fatal(err) } upper := filepath.Join(dir+"-ws", "t1", "upper") write(t, filepath.Join(upper, "new.txt"), "new content") write(t, filepath.Join(upper, "mod.txt"), "new value") // 删除:upper 里放 whiteout 等价物不可构造(需真实 overlayfs); // applyUpper 的删除分支由集成测试覆盖。此处验证 add/mod。 changes, err := applyUpper(upper, dir, manifest) if err != nil { t.Fatal(err) } if strings.TrimSpace(read(t, filepath.Join(dir, "new.txt"))) != "new content" { t.Fatal("add must land") } if strings.TrimSpace(read(t, filepath.Join(dir, "mod.txt"))) != "new value" { t.Fatal("mod must land (CAS ok)") } ops := map[string]Change{} for _, c := range changes { ops[c.Rel] = c } if ops["new.txt"].Op != "add" || !ops["new.txt"].Applied { t.Fatalf("new.txt change = %+v", ops["new.txt"]) } if ops["mod.txt"].Op != "mod" || !ops["mod.txt"].Applied { t.Fatalf("mod.txt change = %+v", ops["mod.txt"]) } } // 冲突:目标被外部改动(哈希 ≠ 基线)→ 双存 *.conflict-*,原文件不动。 func TestApplyUpperConflictDoubleStore(t *testing.T) { dir := t.TempDir() write(t, filepath.Join(dir, "doc.md"), "base") frozen := filepath.Join(dir+"-ws", "t1") manifest, err := freezeBase(dir, frozen) if err != nil { t.Fatal(err) } write(t, filepath.Join(dir, "doc.md"), "externally changed") // 会话侧被外部改 upper := filepath.Join(dir+"-ws", "t1", "upper") write(t, filepath.Join(upper, "doc.md"), "sandbox wrote this") changes, err := applyUpper(upper, dir, manifest) if err != nil { t.Fatal(err) } if strings.TrimSpace(read(t, filepath.Join(dir, "doc.md"))) != "externally changed" { t.Fatal("conflict must not overwrite the externally changed file") } found := false for _, c := range changes { if c.Rel == "doc.md" && c.Conflict && !c.Applied { found = true } } if !found { t.Fatalf("changes = %+v", changes) } // 双存文件存在且内容是沙箱侧新内容。 matches, _ := filepath.Glob(filepath.Join(dir, "doc.md.conflict-*")) if len(matches) == 0 || strings.TrimSpace(read(t, matches[0])) != "sandbox wrote this" { t.Fatalf("conflict copy missing: %v", matches) } } // 变更摘要:数量与冲突提示。 func TestWorkspaceChangeSummary(t *testing.T) { s := workspaceChangeSummary([]Change{ {Rel: "a", Op: "add", Applied: true}, {Rel: "b", Op: "mod", Applied: true}, {Rel: "c", Op: "del", Applied: true}, {Rel: "d", Op: "mod", Conflict: true}, }) for _, want := range []string{"新增 1", "修改 1", "删除 1", "冲突 1"} { if !strings.Contains(s, want) { t.Fatalf("summary %q missing %q", s, want) } } if workspaceChangeSummary(nil) != "" { t.Fatal("empty changes must yield empty summary") } } // ---- 集成(本机有 bwrap 时执行):真实 overlay 三件套 + whiteout ---- func TestSandboxWorkspaceOverlayRoundtrip(t *testing.T) { if _, err := exec.LookPath("bwrap"); err != nil { t.Skip("bwrap not available") } oldOverlay := workspaceOverlay workspaceOverlay = true defer func() { workspaceOverlay = oldOverlay }() dir := t.TempDir() write(t, filepath.Join(dir, "原文件.txt"), "orig") write(t, filepath.Join(dir, "将删除.txt"), "delete me") spec := ExecSpec{ Bin: "/bin/bash", Args: []string{"-c", ` set -e echo 'sandbox new' > 新文件.txt echo 'modified' > 原文件.txt rm 将删除.txt echo done `}, Dir: dir, Env: []string{"PATH=/usr/bin:/bin"}, } out := &strings.Builder{} proc, err := sandboxBackend{}.Start(context.Background(), spec, out) if err != nil { t.Fatal(err) } if err := proc.Wait(); err != nil { t.Fatalf("wait: %v (output: %s)", err, out.String()) } if strings.TrimSpace(read(t, filepath.Join(dir, "新文件.txt"))) != "sandbox new" { t.Fatal("new file must be applied") } if strings.TrimSpace(read(t, filepath.Join(dir, "原文件.txt"))) != "modified" { t.Fatal("modified file must be applied") } if _, err := os.Stat(filepath.Join(dir, "将删除.txt")); !os.IsNotExist(err) { t.Fatal("deleted file must be removed after apply") } // -ws 兄弟目录已清理。 matches, _ := filepath.Glob(dir + "-ws/*") if len(matches) != 0 { t.Fatalf("workspace roots must be cleaned: %v", matches) } s := proc.(interface{ Summary() string }).Summary() if !strings.Contains(s, "新增 1") || !strings.Contains(s, "删除 1") { t.Fatalf("summary = %q", s) } } // 超时丢弃:超时杀灭后 upper 不落地(半写状态不进会话目录)。 func TestSandboxWorkspaceOverlayTimeoutDiscards(t *testing.T) { if _, err := exec.LookPath("bwrap"); err != nil { t.Skip("bwrap not available") } oldOverlay := workspaceOverlay workspaceOverlay = true defer func() { workspaceOverlay = oldOverlay }() dir := t.TempDir() spec := ExecSpec{ Bin: "/bin/bash", Args: []string{"-c", "echo partial > 半写.txt; sleep 30"}, Dir: dir, Env: []string{"PATH=/usr/bin:/bin"}, Timeout: 1e9, } ctx, cancel := context.WithTimeout(context.Background(), shortTimeout()) defer cancel() out := &strings.Builder{} proc, err := sandboxBackend{}.Start(ctx, spec, out) if err != nil { t.Fatal(err) } _ = proc.Wait() if _, err := os.Stat(filepath.Join(dir, "半写.txt")); !os.IsNotExist(err) { t.Fatal("partial write must NOT land after timeout") } if !strings.Contains(proc.(interface{ Summary() string }).Summary(), "丢弃") { t.Fatal("summary must state discard") } } func shortTimeout() time.Duration { return 1500 * time.Millisecond } // -ws 壳目录在空时移除(不随会话累积)。 func TestOverlayCleansShellDir(t *testing.T) { if _, err := exec.LookPath("bwrap"); err != nil { t.Skip("bwrap not available") } old := workspaceOverlay workspaceOverlay = true defer func() { workspaceOverlay = old }() dir := t.TempDir() spec := ExecSpec{Bin: "/bin/bash", Args: []string{"-c", "echo hi"}, Dir: dir, Env: []string{"PATH=/usr/bin:/bin"}} proc, err := sandboxBackend{}.Start(context.Background(), spec, &strings.Builder{}) if err != nil { t.Fatal(err) } if err := proc.Wait(); err != nil { t.Fatal(err) } if _, serr := os.Stat(dir + "-ws"); !os.IsNotExist(serr) { t.Fatalf("-ws shell dir must be removed when empty: %v", serr) } }