// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "crypto/sha256" "encoding/hex" "io/fs" "os" "path/filepath" "reflect" "testing" "time" "krino/internal/journal" "krino/internal/plan" ) // snapshot records every file under root: path, content hash, mode and mtime. func snapshot(t *testing.T, root string) map[string]string { t.Helper() out := map[string]string{} err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } b, err := os.ReadFile(p) if err != nil { return err } fi, err := d.Info() if err != nil { return err } rel, _ := filepath.Rel(root, p) sum := sha256.Sum256(b) out[rel] = hex.EncodeToString(sum[:]) + " " + fi.Mode().String() + " " + fi.ModTime().UTC().Format(time.RFC3339Nano) return nil }) if err != nil { t.Fatal(err) } return out } // treeSnapshot is snapshot plus one "dir" entry per directory under root, // so a comparison also sees directories left behind or missing. func treeSnapshot(t *testing.T, root string) map[string]string { t.Helper() out := snapshot(t, root) err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() && p != root { rel, _ := filepath.Rel(root, p) out[rel+"/"] = "dir" } return nil }) if err != nil { t.Fatal(err) } return out } func TestApplyThenUndoRestoresTheTree(t *testing.T) { h := sandbox(t) dl := filepath.Join(h, "dl") if err := os.MkdirAll(filepath.Join(dl, "sub"), 0o755); err != nil { t.Fatal(err) } files := map[string]string{ "inv1.pdf": "invoice one", "inv2.pdf": "invoice two", "notes.txt": "not a pdf", "sub/deep.pdf": "nested", } old := time.Now().Add(-2 * time.Hour) for rel, body := range files { p := filepath.Join(dl, rel) if err := os.WriteFile(p, []byte(body), 0o640); err != nil { t.Fatal(err) } if err := os.Chtimes(p, old, old); err != nil { t.Fatal(err) } } before := snapshot(t, dl) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (recursive yes) (rule "pdfs" (when (type pdf)) (copy "~/backup") (move "Work/{mtime:%Y}")) `}) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims()) if err != nil { t.Fatal(err) } approved := map[string]bool{} for _, c := range dp.Chains { approved[c.File.Rel] = true } logPath := filepath.Join(h, ".local", "state", "krino", "krino.log") j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } run := journal.NewRunID(time.Now()) res, err := e.Apply(context.Background(), dp, approved, j, run) if err != nil { t.Fatal(err) } j.Close() if res.Failed != 0 { t.Fatalf("%d files failed: %+v", res.Failed, res) } if reflect.DeepEqual(snapshot(t, dl), before) { t.Fatal("apply changed nothing") } if _, err := os.Stat(filepath.Join(h, "backup", "inv1.pdf")); err != nil { t.Errorf("the copy did not land: %v", err) } up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } for _, f := range up.Files { if f.Refused != "" { t.Fatalf("undo refused %s: %s", f.File, f.Refused) } } j2, err := journal.Open(logPath) if err != nil { t.Fatal(err) } if _, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now())); err != nil { t.Fatal(err) } j2.Close() // Verify the move cycle within dl was undone completely. got := snapshot(t, dl) for rel, want := range before { if got[rel] != want { t.Errorf("%s after undo:\n got %s\nwant %s", rel, got[rel], want) } } for rel := range got { if _, ok := before[rel]; !ok { t.Errorf("%s exists after undo but did not before", rel) } } // Verify the copy-undo removed all copies from ~/backup. // undo-copy sends them to trash, so backup should be gone (or exist but // contain none of inv1.pdf, inv2.pdf, deep.pdf). backupDir := filepath.Join(h, "backup") copied := []string{"inv1.pdf", "inv2.pdf", "deep.pdf"} for _, name := range copied { p := filepath.Join(backupDir, name) if _, err := os.Stat(p); err == nil { t.Errorf("copy %s still exists after undo", name) } else if !os.IsNotExist(err) { t.Errorf("checking %s after undo: %v", name, err) } } // Also check that if backupDir exists, it is empty (no copies remain). if entries, err := os.ReadDir(backupDir); err == nil { if len(entries) > 0 { t.Errorf("backup dir not empty after undo: %v", entries) } } else if !os.IsNotExist(err) { t.Errorf("reading backup dir after undo: %v", err) } } // TestUndoReversesChainsWithinOneFile is the property test's first finding: // undo judged each move and rename against the disk as it is now, so the // first step of a chain - a rename then a move, two moves, a move then // trash - found its destination empty, because the later step had already // moved the file on, and the whole file was refused. func TestUndoReversesChainsWithinOneFile(t *testing.T) { for name, rule := range map[string]string{ "rename then move": `(rule "r" (rename "r-{name}") (move "Out"))`, "move then move": `(rule "r" (move "Out") (move "Out/{mtime:%Y}"))`, "move then trash": `(rule "r" (move "Out") (delete))`, "rename twice": `(rule "r" (rename "r-{name}") (rename "r-{name}"))`, } { t.Run(name, func(t *testing.T) { checkApplyUndo(t, propertyCase{files: map[string]string{"a.pdf": "one"}, rules: "(path \"~/dl\")\n" + rule + "\n"}) }) } }