Files
HomeAgent/internal/tracker/tracker_test.go
root bc9ef15eb0 三层回退恢复机制(L0写前留档/L1恢复梯子/L2离线回滚)+ guard 父守护
- L0: files 插件写受保护系统路径(/etc 等)前自动留档,AbstractBeforeWrite 到 data/file_baseline
- L1: failback 受限 worker 执行恢复梯子 probe→还原DNS/proxy→还原LLM配置+ReloadFromConfig→probe,N轮有界
- L2: tracker changeset 持久化原文 blob,guard 离线 RollbackFromDisk 回滚 agentfs;SystemSnapshot 支撑
- guard 父守护: 心跳 IPC(PING/ACK unix socket, 文件心跳回退)、失败计数、退出码协议(42/43/44)、最后手段
- 发行版路径适配: system.protected_paths/network_paths 可注入,默认面向主流 Linux
- Windows 兼容: guard.go/failback.go 加 //go:build linux, guard_windows.go 提供 no-op 桩
- 修复: guard.yaml last_resort 键冲突、changeset Content 不落盘导致离线回滚丢原文

Build 全绿, vet 干净, system/recovery/ipc/tracker 单元测试全过
2026-08-05 16:00:08 +08:00

446 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tracker
import (
"os"
"path/filepath"
"testing"
)
func TestNewTracker(t *testing.T) {
tr := NewTracker("/tmp/tracker_data", "/tmp/tracker_work")
if tr == nil {
t.Fatal("tracker should not be nil")
}
if tr.mergeDir != "/tmp/tracker_work/merged" {
t.Errorf("unexpected mergeDir: %s", tr.mergeDir)
}
}
func TestInit(t *testing.T) {
dir := t.TempDir()
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
if err := tr.Init(); err != nil {
t.Fatal(err)
}
for _, d := range []string{tr.lowerDir, tr.upperDir, tr.mergeDir} {
if _, err := os.Stat(d); os.IsNotExist(err) {
t.Errorf("dir %s should exist", d)
}
}
}
func TestNewChangeSet(t *testing.T) {
cs := NewChangeSet("test_action")
if cs.Action != "test_action" {
t.Errorf("expected 'test_action', got %q", cs.Action)
}
if cs.ID == "" {
t.Error("ID should not be empty")
}
if len(cs.Files) != 0 {
t.Errorf("expected 0 files, got %d", len(cs.Files))
}
}
func TestFileHash(t *testing.T) {
f := t.TempDir() + "/test.txt"
if err := os.WriteFile(f, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
hash, size, err := fileHash(f)
if err != nil {
t.Fatal(err)
}
if size != 5 {
t.Errorf("expected size 5, got %d", size)
}
if hash == "" {
t.Error("hash should not be empty")
}
// SHA256 of "hello"
expected := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if hash != expected {
t.Errorf("expected hash %s, got %s", expected, hash)
}
}
func TestFileHashNotFound(t *testing.T) {
_, _, err := fileHash("/nonexistent/file")
if err == nil {
t.Error("expected error for nonexistent file")
}
}
func TestFileInfo(t *testing.T) {
f := t.TempDir() + "/info.txt"
os.WriteFile(f, []byte("test"), 0644)
size, modTime, err := fileInfo(f)
if err != nil {
t.Fatal(err)
}
if size != 4 {
t.Errorf("expected size 4, got %d", size)
}
if modTime.IsZero() {
t.Error("modTime should not be zero")
}
}
func TestFileInfoNotFound(t *testing.T) {
_, _, err := fileInfo("/nonexistent/file")
if err == nil {
t.Error("expected error for nonexistent file")
}
}
func TestCaptureFSStateEmpty(t *testing.T) {
dir := t.TempDir()
state, err := captureFSState(dir)
if err != nil {
t.Fatal(err)
}
if len(state.Files) != 0 {
t.Errorf("expected 0 files, got %d", len(state.Files))
}
if state.Root != dir {
t.Errorf("expected root %s, got %s", dir, state.Root)
}
}
func TestCaptureFSState(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "a.txt"), []byte("aaa"), 0644)
os.WriteFile(filepath.Join(dir, "b.txt"), []byte("bbb"), 0644)
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
os.WriteFile(filepath.Join(dir, "sub", "c.txt"), []byte("ccc"), 0644)
state, err := captureFSState(dir)
if err != nil {
t.Fatal(err)
}
if len(state.Files) != 3 {
t.Errorf("expected 3 files, got %d", len(state.Files))
}
// should contain relative paths
for _, p := range []string{"a.txt", "b.txt", "sub/c.txt"} {
if _, ok := state.Files[p]; !ok {
t.Errorf("missing file %s", p)
}
}
}
func TestDiffStatesCreated(t *testing.T) {
dir := t.TempDir()
before, _ := captureFSState(dir)
os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new file"), 0644)
after, _ := captureFSState(dir)
changes := diffStates(before, after)
if len(changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(changes))
}
if changes[0].Type != ChangeFileCreated {
t.Errorf("expected created, got %s", changes[0].Type)
}
if changes[0].Path != "new.txt" {
t.Errorf("expected 'new.txt', got %s", changes[0].Path)
}
}
func TestDiffStatesModified(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "f.txt"), []byte("original"), 0644)
before, _ := captureFSState(dir)
os.WriteFile(filepath.Join(dir, "f.txt"), []byte("modified"), 0644)
after, _ := captureFSState(dir)
changes := diffStates(before, after)
if len(changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(changes))
}
if changes[0].Type != ChangeFileModified {
t.Errorf("expected modified, got %s", changes[0].Type)
}
}
func TestDiffStatesDeleted(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "f.txt"), []byte("delete me"), 0644)
before, _ := captureFSState(dir)
os.Remove(filepath.Join(dir, "f.txt"))
after, _ := captureFSState(dir)
changes := diffStates(before, after)
if len(changes) != 1 {
t.Fatalf("expected 1 change, got %d", len(changes))
}
if changes[0].Type != ChangeFileDeleted {
t.Errorf("expected deleted, got %s", changes[0].Type)
}
}
func TestDiffStatesNoChange(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "f.txt"), []byte("stable"), 0644)
before, _ := captureFSState(dir)
after, _ := captureFSState(dir)
changes := diffStates(before, after)
if len(changes) != 0 {
t.Errorf("expected 0 changes, got %d", len(changes))
}
}
func TestDiffStatesMixed(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("unchanged"), 0644)
os.WriteFile(filepath.Join(dir, "delete.txt"), []byte("gone"), 0644)
before, _ := captureFSState(dir)
os.Remove(filepath.Join(dir, "delete.txt"))
os.WriteFile(filepath.Join(dir, "add.txt"), []byte("new"), 0644)
os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("changed"), 0644)
after, _ := captureFSState(dir)
changes := diffStates(before, after)
if len(changes) != 3 {
t.Fatalf("expected 3 changes, got %d", len(changes))
}
types := make(map[ChangeType]bool)
for _, c := range changes {
types[c.Type] = true
}
if !types[ChangeFileCreated] {
t.Error("missing created")
}
if !types[ChangeFileModified] {
t.Error("missing modified")
}
if !types[ChangeFileDeleted] {
t.Error("missing deleted")
}
}
func TestDiffStatesNilBefore(t *testing.T) {
dir := t.TempDir()
after, _ := captureFSState(dir)
changes := diffStates(nil, after)
if len(changes) != 0 {
t.Errorf("expected 0 changes when before is nil, got %d", len(changes))
}
}
func TestPreActionResetsBefore(t *testing.T) {
dir := t.TempDir()
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
tr.Init()
// capture initial state
cs := tr.PreAction("test")
if cs == nil {
t.Fatal("changeset should not be nil")
}
if cs.Action != "test" {
t.Errorf("expected 'test', got %q", cs.Action)
}
}
func TestPostActionNoChanges(t *testing.T) {
dir := t.TempDir()
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
tr.Init()
tr.PreAction("noop")
cs := tr.PostAction("noop")
if cs == nil {
t.Fatal("changeset should not be nil")
}
if len(cs.Files) != 0 {
t.Errorf("expected 0 files for noop, got %d", len(cs.Files))
}
}
func TestStats(t *testing.T) {
dir := t.TempDir()
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
tr.Init()
stats := tr.Stats()
if stats["mounted"].(bool) {
t.Error("should not be mounted")
}
if stats["change_sets"].(int) != 0 {
t.Errorf("expected 0 changesets, got %d", stats["change_sets"])
}
}
func TestMergeDir(t *testing.T) {
tr := NewTracker("/data", "/work")
if tr.MergeDir() != "/work/merged" {
t.Errorf("unexpected mergeDir: %s", tr.MergeDir())
}
}
func TestHasChanges(t *testing.T) {
dir := t.TempDir()
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
tr.Init()
if tr.HasChanges() {
t.Error("should have no changes initially")
}
}
func TestChangeSetsEmpty(t *testing.T) {
dir := t.TempDir()
tr := NewTracker(filepath.Join(dir, "data"), filepath.Join(dir, "work"))
tr.Init()
cs := tr.ChangeSets()
if len(cs) != 0 {
t.Errorf("expected 0 changesets, got %d", len(cs))
}
}
func TestCaptureDirNotExist(t *testing.T) {
_, err := captureFSState("/tmp/nonexistent_test_dir_12345")
if err == nil {
t.Error("expected error for nonexistent directory")
}
}
func TestDiffStatesModifiedContent(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "f.txt")
os.WriteFile(f, []byte("original-content"), 0644)
before, err := captureFSStateWithContent(dir)
if err != nil {
t.Fatal(err)
}
os.WriteFile(f, []byte("tampered-content"), 0644)
after, _ := captureFSState(dir)
changes := diffStates(before, after)
if len(changes) != 1 || changes[0].Type != ChangeFileModified {
t.Fatalf("expected 1 modified, got %+v", changes)
}
if string(changes[0].Content) != "original-content" {
t.Fatalf("modified change should carry original content, got %q", changes[0].Content)
}
}
func TestDiffStatesDeletedContent(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "f.txt")
os.WriteFile(f, []byte("do-not-lose"), 0644)
before, err := captureFSStateWithContent(dir)
if err != nil {
t.Fatal(err)
}
os.Remove(f)
after, _ := captureFSState(dir)
changes := diffStates(before, after)
if len(changes) != 1 || changes[0].Type != ChangeFileDeleted {
t.Fatalf("expected 1 deleted, got %+v", changes)
}
if string(changes[0].Content) != "do-not-lose" {
t.Fatalf("deleted change should carry original content, got %q", changes[0].Content)
}
}
func TestRollbackLatest(t *testing.T) {
dir := t.TempDir()
work := filepath.Join(dir, "work")
tr := NewTracker(filepath.Join(dir, "data"), work)
if err := tr.Init(); err != nil {
t.Fatal(err)
}
upper := tr.upperDir
os.WriteFile(filepath.Join(upper, "keep.txt"), []byte("stable"), 0644)
os.WriteFile(filepath.Join(upper, "gone.txt"), []byte("do-not-lose"), 0644)
// 动作1改 keep、加 new应保留
tr.PreAction("action1")
os.WriteFile(filepath.Join(upper, "keep.txt"), []byte("tampered"), 0644)
os.WriteFile(filepath.Join(upper, "new.txt"), []byte("added"), 0644)
tr.PostAction("action1")
// 动作2删 gone仅撤销这条
tr.PreAction("action2")
os.Remove(filepath.Join(upper, "gone.txt"))
tr.PostAction("action2")
if !tr.HasChanges() {
t.Fatal("expected changes after PostAction")
}
if err := tr.RollbackLatest(); err != nil {
t.Fatalf("RollbackLatest: %v", err)
}
restored, err := os.ReadFile(filepath.Join(upper, "gone.txt"))
if err != nil || string(restored) != "do-not-lose" {
t.Fatalf("gone.txt should be recreated with original content, got %q err=%v", restored, err)
}
// action1 的改动不受影响
got, err := os.ReadFile(filepath.Join(upper, "keep.txt"))
if err != nil || string(got) != "tampered" {
t.Fatalf("keep.txt should keep action1 changes, got %q err=%v", got, err)
}
if _, err := os.Stat(filepath.Join(upper, "new.txt")); err != nil {
t.Fatalf("new.txt should still exist after latest-only rollback, err=%v", err)
}
if tr.HasChanges() != true {
t.Fatal("earlier change sets should remain after latest-only rollback")
}
if got := len(tr.ChangeSets()); got != 1 {
t.Fatalf("expected 1 remaining change set, got %d", got)
}
}
func TestRollbackFull(t *testing.T) {
dir := t.TempDir()
work := filepath.Join(dir, "work")
tr := NewTracker(filepath.Join(dir, "data"), work)
if err := tr.Init(); err != nil {
t.Fatal(err)
}
upper := tr.upperDir
os.WriteFile(filepath.Join(upper, "keep.txt"), []byte("stable"), 0644)
os.WriteFile(filepath.Join(upper, "deleteme.txt"), []byte("bye"), 0644)
// 动作1改 keep、加 new
tr.PreAction("a")
os.WriteFile(filepath.Join(upper, "keep.txt"), []byte("tampered"), 0644)
os.WriteFile(filepath.Join(upper, "new.txt"), []byte("added"), 0644)
tr.PostAction("a")
// 动作2删 deleteme在受追踪的动作内
tr.PreAction("b")
os.Remove(filepath.Join(upper, "deleteme.txt"))
tr.PostAction("b")
if err := tr.Rollback(); err != nil {
t.Fatalf("Rollback: %v", err)
}
got, _ := os.ReadFile(filepath.Join(upper, "keep.txt"))
if string(got) != "stable" {
t.Fatalf("keep.txt should be restored, got %q", got)
}
if _, err := os.Stat(filepath.Join(upper, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("new.txt should be gone after full rollback")
}
restored, err := os.ReadFile(filepath.Join(upper, "deleteme.txt"))
if err != nil || string(restored) != "bye" {
t.Fatalf("deleted file should be recreated with original content, got %q err=%v", restored, err)
}
}