aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 21:30:49 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 21:30:49 +0200
commit643eac4a2cba7b34b8a33af1b3dbdc54d9816a53 (patch)
tree5c53881b5d85e5042080a45e070ef3922904389c /internal
parent7e0d8494074398854f30feb75211c52ea5cc2635 (diff)
downloadkrino-643eac4a2cba7b34b8a33af1b3dbdc54d9816a53.tar.gz
krino-643eac4a2cba7b34b8a33af1b3dbdc54d9816a53.zip
plan 9: undo checks trash identity, groups by directory, re-checks at execution, removes directories it recreates
Diffstat (limited to 'internal')
-rw-r--r--internal/apply/apply.go2
-rw-r--r--internal/apply/fs.go6
-rw-r--r--internal/engine/apply.go98
-rw-r--r--internal/engine/apply_test.go22
-rw-r--r--internal/engine/undo_identity_test.go225
5 files changed, 318 insertions, 35 deletions
diff --git a/internal/apply/apply.go b/internal/apply/apply.go
index 5624b27..684641e 100644
--- a/internal/apply/apply.go
+++ b/internal/apply/apply.go
@@ -180,7 +180,7 @@ func runFileStep(step plan.Step) StepResult {
return StepResult{Step: step, Status: "failed", Detail: err.Error()}
}
- made, err := mkdirAllTracked(filepath.Dir(dst))
+ made, err := MkdirAllTracked(filepath.Dir(dst))
if err != nil {
return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry}
}
diff --git a/internal/apply/fs.go b/internal/apply/fs.go
index 823d5c8..56737e8 100644
--- a/internal/apply/fs.go
+++ b/internal/apply/fs.go
@@ -201,11 +201,11 @@ func splitExt(name string) (stem, ext string) {
return name[:i], name[i:]
}
-// mkdirAllTracked creates dir and any missing ancestors (mode 0755),
+// MkdirAllTracked creates dir and any missing ancestors (mode 0755),
// returning every directory it actually created, outermost first, so undo
// can later remove the empty ones again. A directory that already existed
// is not included, and nothing is created or returned on error.
-func mkdirAllTracked(dir string) ([]string, error) {
+func MkdirAllTracked(dir string) ([]string, error) {
dir = filepath.Clean(dir)
if fi, err := os.Stat(dir); err == nil {
if !fi.IsDir() {
@@ -219,7 +219,7 @@ func mkdirAllTracked(dir string) ([]string, error) {
parent := filepath.Dir(dir)
var made []string
if parent != dir {
- parentMade, err := mkdirAllTracked(parent)
+ parentMade, err := MkdirAllTracked(parent)
if err != nil {
return nil, err
}
diff --git a/internal/engine/apply.go b/internal/engine/apply.go
index 83ea4fe..94d65e4 100644
--- a/internal/engine/apply.go
+++ b/internal/engine/apply.go
@@ -343,21 +343,26 @@ func (e *Engine) PlanUndo(runID string) (*UndoPlan, error) {
return nil, fmt.Errorf("engine: plan undo: run %s is itself an undo and cannot be undone", runID)
}
- var order []string
- byFile := map[string][]journal.Entry{}
+ // Entries are grouped by directory and file together (review M7): one run
+ // spans every directory, and two directories can each hold a file of the
+ // same name.
+ type fileKey struct{ dir, file string }
+ var order []fileKey
+ byFile := map[fileKey][]journal.Entry{}
for _, en := range entries {
if en.File == "" { // run-start / run-end
continue
}
- if _, seen := byFile[en.File]; !seen {
- order = append(order, en.File)
+ k := fileKey{en.Dir, en.File}
+ if _, seen := byFile[k]; !seen {
+ order = append(order, k)
}
- byFile[en.File] = append(byFile[en.File], en)
+ byFile[k] = append(byFile[k], en)
}
up := &UndoPlan{Run: runID}
- for _, file := range order {
- uf := planUndoFile(file, byFile[file])
+ for _, k := range order {
+ uf := planUndoFile(k.dir, k.file, byFile[k])
// Critical finding, Task 8's review: a file every one of whose
// entries has Status != "ok" (declined by the ORIGINAL run's own
// review, or skipped, or failed before anything happened) yields
@@ -459,8 +464,8 @@ func isFileAffecting(action string) bool {
// two steps of one file that touch the same path, and every un-contended
// check keeps behaving exactly as before, since the projection only ever
// overrides a real occupant that this same chain is itself about to clear.
-func planUndoFile(file string, ents []journal.Entry) UndoFile {
- uf := UndoFile{File: file, Dir: dirOf(ents)}
+func planUndoFile(dir, file string, ents []journal.Entry) UndoFile {
+ uf := UndoFile{File: file, Dir: dir}
proj := newUndoProjection()
for i := len(ents) - 1; i >= 0; i-- {
en := ents[i]
@@ -560,17 +565,6 @@ func needsOccupancyCheck(action string) bool {
return false
}
-// dirOf returns the first non-empty Dir among ents, which should all agree
-// since one file is always processed within one configured directory.
-func dirOf(ents []journal.Entry) string {
- for _, en := range ents {
- if en.Dir != "" {
- return en.Dir
- }
- }
- return ""
-}
-
// reverseStep computes the UndoStep for one logged "ok" entry, per spec
// ยง10's reversal table. It only stats the filesystem to decide Refused (the
// "changed since" and "trash entry is gone" checks); it never mutates
@@ -597,9 +591,7 @@ func reverseStep(en journal.Entry) UndoStep {
us.Refused = refuseIfChanged(en)
case "trash", "displace":
us.Src, us.Dst = en.Dst, en.Src
- if _, err := os.Stat(en.Dst); err != nil {
- us.Refused = "the trash entry is gone"
- }
+ us.Refused = refuseIfTrashChanged(en)
case "mkdir":
us.Src = en.Dst
}
@@ -635,6 +627,38 @@ func refuseIfChanged(en journal.Entry) string {
return ""
}
+// refuseIfTrashChanged is the trash reversal's identity check (review M2):
+// the entry must still be the file this run put there - the size and mtime
+// the run logged for it - and its trashinfo must still record the path it
+// came from. Emptying the Trash and trashing another file of the same name
+// would otherwise have undo restore that file instead.
+func refuseIfTrashChanged(en journal.Entry) string {
+ fi, err := os.Lstat(en.Dst)
+ if err != nil {
+ return "the trash entry is gone"
+ }
+ if fi.Size() != en.Size || !fi.ModTime().Equal(en.ModTime) {
+ return fmt.Sprintf("the trash entry %s is not the file this run put there", en.Detail)
+ }
+ if p, err := trash.InfoPath(en.Detail); err != nil || p != en.Src {
+ return fmt.Sprintf("the trash entry %s now belongs to another file", en.Detail)
+ }
+ return ""
+}
+
+// recheck repeats, at execution time, the identity check PlanUndo made
+// (review undo F8): an undo plan is shown and approved first, and a file
+// changed in that window must not be moved back or trashed.
+func recheck(step UndoStep) string {
+ switch step.Action {
+ case "undo-move", "undo-rename", "undo-copy":
+ return refuseIfChanged(step.Original)
+ case "undo-trash", "undo-displace":
+ return refuseIfTrashChanged(step.Original)
+ }
+ return ""
+}
+
// refuseIfSrcExists is the second half of every "reversal puts a file back
// at a fixed path" refusal condition: reversing would silently clobber
// whatever is there now. Fix wave item 1: it is no longer reverseStep's own
@@ -773,7 +797,10 @@ func (e *Engine) ApplyUndo(ctx context.Context, up *UndoPlan, j *journal.Writer,
tallyFile(result, fr.Steps, func(i int) bool { return isFileAffecting(f.Steps[i].Action) })
for i, us := range f.Steps {
if us.Action == "undo-mkdir" && fr.Steps[i].Status == "failed" {
- retries = append(retries, dirRetry{dir: us.Src, dirName: f.Dir, file: f.File, step: i + 1})
+ retries = append(retries, dirRetry{dir: us.Src, dirName: f.Dir, file: f.File, step: i + 1, log: true})
+ }
+ for _, made := range fr.Steps[i].Made {
+ retries = append(retries, dirRetry{dir: made})
}
}
}
@@ -802,6 +829,10 @@ type dirRetry struct {
dirName string
file string
step int
+ // log is false for a directory this undo run itself created on the way
+ // (runUndoStep's Made): the original run's log already says it was
+ // removed, so removing it again needs no entry of its own.
+ log bool
}
// retryDirRemovals is ApplyUndo's run-wide second pass (Task 1, plan 5): once
@@ -833,6 +864,9 @@ func (e *Engine) retryDirRemovals(j *journal.Writer, run string, candidates []di
// original refusal already recorded this, and it stands.
continue
}
+ if !c.log {
+ continue
+ }
if err := j.Append(journal.Entry{
Time: e.Now(), Run: run, Dir: c.dirName, File: c.file, Step: c.step,
Action: "undo-mkdir", Status: "ok", Src: c.dir,
@@ -924,16 +958,24 @@ func (e *Engine) undoFile(f UndoFile, j *journal.Writer, run string) (FileResult
// Step field does not apply here (there is no plan.Kind for an undo) and is
// left zero.
func runUndoStep(step UndoStep) apply.StepResult {
+ if why := recheck(step); why != "" {
+ return apply.StepResult{Status: "failed", Detail: why}
+ }
switch step.Action {
case "undo-move", "undo-rename":
- if err := os.MkdirAll(filepath.Dir(step.Dst), 0o755); err != nil {
- return apply.StepResult{Status: "failed", Detail: err.Error()}
+ // The directories created here are returned in Made: ApplyUndo removes
+ // them again once empty, since an earlier file's undo-mkdir may already
+ // have removed the directory this file passes back through (review
+ // undo F6).
+ made, err := apply.MkdirAllTracked(filepath.Dir(step.Dst))
+ if err != nil {
+ return apply.StepResult{Status: "failed", Detail: err.Error(), Made: made}
}
if err := renameOrCopy(step.Src, step.Dst); err != nil {
- return apply.StepResult{Status: "failed", Detail: err.Error()}
+ return apply.StepResult{Status: "failed", Detail: err.Error(), Made: made}
}
size, mtime := statSizeModTime(step.Dst)
- return apply.StepResult{Status: "ok", Dst: step.Dst, Size: size, ModTime: mtime}
+ return apply.StepResult{Status: "ok", Dst: step.Dst, Size: size, ModTime: mtime, Made: made}
case "undo-copy":
entry, err := trash.Put(step.Src)
diff --git a/internal/engine/apply_test.go b/internal/engine/apply_test.go
index a54bffc..104fb73 100644
--- a/internal/engine/apply_test.go
+++ b/internal/engine/apply_test.go
@@ -749,11 +749,19 @@ func TestRunUndoStepTrashReadsEntryNameFromDetailNotDst(t *testing.T) {
t.Fatal(err)
}
+ // Original is what the run logged for the trash step: the entry name in
+ // Detail, and the entry's own path, size and mtime (checked again at
+ // execution time, review undo F8).
+ entryPath := filepath.Join(trash.Dir(), "files", entry)
+ fi, err := os.Stat(entryPath)
+ if err != nil {
+ t.Fatal(err)
+ }
step := UndoStep{
Action: "undo-trash",
Src: "/this/path/does/not/exist/files/wrong-name",
Dst: target,
- Original: journal.Entry{Detail: entry},
+ Original: journal.Entry{Action: "trash", Src: target, Dst: entryPath, Size: fi.Size(), ModTime: fi.ModTime(), Detail: entry},
}
sr := runUndoStep(step)
if sr.Status != "ok" {
@@ -863,11 +871,15 @@ func TestUndoFileContinuesPastFailedMkdir(t *testing.T) {
defer j.Close()
e := &Engine{Now: time.Now}
+ keepInfo, err := os.Stat(keep)
+ if err != nil {
+ t.Fatal(err)
+ }
uf := UndoFile{
File: "f", Dir: "d",
Steps: []UndoStep{
{Action: "undo-mkdir", Src: nonEmpty},
- {Action: "undo-copy", Src: keep},
+ {Action: "undo-copy", Src: keep, Original: journal.Entry{Action: "copy", Dst: keep, Size: keepInfo.Size(), ModTime: keepInfo.ModTime()}},
},
}
fr, err := e.undoFile(uf, j, "run2")
@@ -967,9 +979,13 @@ func TestApplyUndoDoesNotCountFailedMkdirAsFailed(t *testing.T) {
defer j.Close()
e := &Engine{Now: time.Now}
+ dstInfo, err := os.Stat(dst)
+ if err != nil {
+ t.Fatal(err)
+ }
up := &UndoPlan{Run: "r", Files: []UndoFile{
{File: "a.pdf", Dir: "d", Steps: []UndoStep{
- {Action: "undo-move", Src: dst, Dst: src},
+ {Action: "undo-move", Src: dst, Dst: src, Original: journal.Entry{Action: "move", Src: src, Dst: dst, Size: dstInfo.Size(), ModTime: dstInfo.ModTime()}},
{Action: "undo-mkdir", Src: dir},
}},
}}
diff --git a/internal/engine/undo_identity_test.go b/internal/engine/undo_identity_test.go
new file mode 100644
index 0000000..9c93b3b
--- /dev/null
+++ b/internal/engine/undo_identity_test.go
@@ -0,0 +1,225 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/journal"
+ "krino/internal/plan"
+ "krino/internal/trash"
+)
+
+// appliedRun makes each directory of files under the sandbox home (a map of
+// directory name to file name to content, every file two hours old), writes
+// rules for each, and applies every chain of every directory in one run. It
+// returns the engine, the run id, the home directory and the log path.
+func appliedRun(t *testing.T, files map[string]map[string]string, rules map[string]string) (*Engine, string, string, string) {
+ t.Helper()
+ h := sandbox(t)
+ old := time.Now().Add(-2 * time.Hour)
+ var names []string
+ for dir, fs := range files {
+ names = append(names, dir)
+ for name, body := range fs {
+ p := filepath.Join(h, dir, name)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(p, old, old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ sort.Strings(names)
+ main := writeConfig(t, h, `(include "`+strings.Join(names, `" "`)+`")`, rules)
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ run := journal.NewRunID(time.Now())
+ claims := plan.NewClaims()
+ for _, d := range e.Dirs {
+ dp, err := e.Plan(context.Background(), d, claims)
+ if err != nil {
+ t.Fatal(err)
+ }
+ approved := map[string]bool{}
+ for _, c := range dp.Chains {
+ approved[c.File.Rel] = true
+ }
+ res, err := e.Apply(context.Background(), dp, approved, j, run)
+ if err != nil || res.Failed != 0 {
+ t.Fatalf("apply %s: %v, %+v", d.Name, err, res)
+ }
+ }
+ return e, run, h, logPath
+}
+
+// undoFileNamed returns the plan's file for dir and name, failing the test
+// when there is none.
+func undoFileNamed(t *testing.T, up *UndoPlan, dir, name string) UndoFile {
+ t.Helper()
+ for _, f := range up.Files {
+ if f.Dir == dir && f.File == name {
+ return f
+ }
+ }
+ t.Fatalf("no %s/%s in the undo plan: %+v", dir, name, up.Files)
+ return UndoFile{}
+}
+
+// TestUndoRefusesAReusedTrashEntry: the Trash is emptied and another file of
+// the same name trashed after the run; undo must not restore that file in
+// place of the one the run trashed (review M2).
+func TestUndoRefusesAReusedTrashEntry(t *testing.T) {
+ e, run, h, _ := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}},
+ map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (rename \"r-{name}\") (delete))\n"})
+ if err := os.RemoveAll(trash.Dir()); err != nil {
+ t.Fatal(err)
+ }
+ other := filepath.Join(h, "Documents", "r-a.pdf")
+ if err := os.MkdirAll(filepath.Dir(other), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(other, []byte("an unrelated document"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if entry, err := trash.Put(other); err != nil || entry != "r-a.pdf" {
+ t.Fatalf("trash.Put = %q, %v", entry, err)
+ }
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f := undoFileNamed(t, up, "dl", "a.pdf"); !strings.Contains(f.Refused, "trash entry") {
+ t.Errorf("Refused = %q; want a refusal naming the trash entry", f.Refused)
+ }
+}
+
+// TestUndoRefusesATrashEntryThatChanged: a trash entry that is no longer the
+// file the run put there (its size changed) is refused, like a moved file
+// that changed (review M2).
+func TestUndoRefusesATrashEntryThatChanged(t *testing.T) {
+ e, run, _, _ := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}},
+ map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (delete))\n"})
+ if err := os.WriteFile(filepath.Join(trash.Dir(), "files", "a.pdf"), []byte("a longer, different body"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f := undoFileNamed(t, up, "dl", "a.pdf"); f.Refused == "" {
+ t.Error("a changed trash entry was not refused")
+ }
+}
+
+// TestUndoKeepsSameNamedFilesOfTwoDirectoriesApart: a.pdf from dl and
+// a.pdf from scans, moved in one run, are two files to undo, each refused or
+// restored on its own (review M7).
+func TestUndoKeepsSameNamedFilesOfTwoDirectoriesApart(t *testing.T) {
+ e, run, h, logPath := appliedRun(t,
+ map[string]map[string]string{"dl": {"a.pdf": "from dl"}, "scans": {"a.pdf": "from scans"}},
+ map[string]string{
+ "dl": "(path \"~/dl\")\n(rule \"r\" (move \"~/Archive\"))\n",
+ "scans": "(path \"~/scans\")\n(rule \"r\" (move \"~/Archive\"))\n",
+ })
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(up.Files) != 2 {
+ t.Fatalf("undo plan has %d files, want 2: %+v", len(up.Files), up.Files)
+ }
+ scansCopy := undoFileNamed(t, up, "scans", "a.pdf").Steps[0].Src
+ if err := os.WriteFile(scansCopy, []byte("from scans, edited since"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ up, err = e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if undoFileNamed(t, up, "scans", "a.pdf").Refused == "" || undoFileNamed(t, up, "dl", "a.pdf").Refused != "" {
+ t.Fatalf("want only the edited scans file refused: %+v", up.Files)
+ }
+ j, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ if _, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ if b, err := os.ReadFile(filepath.Join(h, "dl", "a.pdf")); err != nil || string(b) != "from dl" {
+ t.Errorf("dl/a.pdf after undo: %q, %v", b, err)
+ }
+}
+
+// TestUndoRechecksAtExecution: a file edited after the undo was planned (for
+// example while its review was open) is not moved back (review undo F8).
+func TestUndoRechecksAtExecution(t *testing.T) {
+ e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}},
+ map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (move \"Out\"))\n"})
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ moved := filepath.Join(h, "dl", "Out", "a.pdf")
+ if err := os.WriteFile(moved, []byte("edited during review"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ j, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ res, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Failed != 1 {
+ t.Errorf("Failed = %d, want 1", res.Failed)
+ }
+ if b, _ := os.ReadFile(moved); string(b) != "edited during review" {
+ t.Errorf("the edited file was moved or changed: %q", b)
+ }
+}
+
+// TestUndoLeavesNoDirectoriesBehind: undo passing a file back through a
+// directory it had already removed for another file recreates it; that
+// directory must be gone again when the undo ends (review undo F6).
+func TestUndoLeavesNoDirectoriesBehind(t *testing.T) {
+ e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one", "b.pdf": "two"}},
+ map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (move \"Out/{mtime:%Y}\") (move \"Out\"))\n"})
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ if res, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())); err != nil || res.Failed != 0 {
+ t.Fatalf("undo: %v, %+v", err, res)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "Out")); !os.IsNotExist(err) {
+ t.Errorf("dl/Out is left behind: %v", err)
+ }
+}