diff options
Diffstat (limited to 'internal/apply/apply.go')
| -rw-r--r-- | internal/apply/apply.go | 209 |
1 files changed, 209 insertions, 0 deletions
diff --git a/internal/apply/apply.go b/internal/apply/apply.go new file mode 100644 index 0000000..2cd52e4 --- /dev/null +++ b/internal/apply/apply.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package apply is the executor: it carries out one file's plan.Chain, +// actually moving, copying, renaming, trashing or permanently deleting real +// files. See docs/design.md §7.2 for the mechanism each action follows and +// §7.4's last paragraph for the execution-time conflict re-check. +package apply + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "krino/internal/plan" + "krino/internal/scan" + "krino/internal/trash" +) + +// StepResult is what happened to one step, in the order the executor ran +// them. +type StepResult struct { + Step plan.Step + Status string // "ok" | "failed" | "skipped" + Detail string // the failure, or why it was skipped + Dst string // where the file actually ended up (conflict names can change at execution time) + Size int64 // of the file at Dst afterwards + ModTime time.Time // of the file at Dst afterwards + Entry string // trash entry name, for Trash steps; "" otherwise + // DisplacedEntry is the trash entry name of the file this step + // displaced; "" when none. Entry and DisplacedEntry describe two + // different files: the one being acted on (Entry, only for a Trash-kind + // step), and the one that was in this step's way and had to be trashed + // first (DisplacedEntry, only when Displaces was set). Spec §7.4's + // overwrite policy and §9's "displace" action both depend on this name + // being recoverable — it is chosen inside trash.Put, so nothing + // downstream of Chain could otherwise re-derive it for undo. + DisplacedEntry string + Made []string // directories this step created, outermost first +} + +// Chain runs one file's steps in order and stops at the first failure, +// marking the rest skipped. It never touches a file whose size or mtime no +// longer matches what the plan recorded. +func Chain(c plan.Chain) []StepResult { + results := make([]StepResult, len(c.Steps)) + stopped := false + + for i, step := range c.Steps { + if stopped { + results[i] = StepResult{Step: step, Status: "skipped", Detail: "an earlier step in this chain failed"} + continue + } + if step.Skip != "" { + // Planning already decided this step will not run; it must not + // be attempted, so no pre-step check, no directory creation, no + // touching the file (spec: a step already marked Skip is + // reported, not attempted). + results[i] = StepResult{Step: step, Status: "skipped", Detail: step.Skip} + continue + } + if err := checkUnchanged(step.Src, c.File); err != nil { + results[i] = StepResult{Step: step, Status: "failed", Detail: err.Error()} + stopped = true + continue + } + + res := runStep(step) + results[i] = res + if res.Status == "failed" { + stopped = true + } + } + + return results +} + +// checkUnchanged is the guard that matters most: before every step, the +// source is stat'd and compared against the size and mtime the plan +// recorded for the whole file. A file rewritten or replaced between +// planning and applying must never be acted on. +func checkUnchanged(src string, f scan.File) error { + fi, err := os.Stat(src) + if err != nil { + return fmt.Errorf("changed since plan: %w", err) + } + if fi.Size() != f.Size || !fi.ModTime().Equal(f.ModTime) { + return errors.New("changed since plan") + } + return nil +} + +// runStep dispatches one already-checked, non-skipped step to the code that +// actually carries it out. +func runStep(step plan.Step) StepResult { + switch step.Kind { + case plan.Copy, plan.Move, plan.Rename: + return runFileStep(step) + case plan.Trash: + return runTrashStep(step) + case plan.DeletePermanent: + return runDeleteStep(step) + } + panic(fmt.Sprintf("apply: unknown plan.Kind %d", int(step.Kind))) +} + +// runFileStep carries out copy, move and rename. It re-checks the planned +// destination against the filesystem as it is now (spec §7.4): if something +// with Displaces set claims the file to trash first, that happens before +// anything else, and if the displace fails nothing further is attempted for +// this file. Otherwise, if the planned Dst now exists, the step moves to the +// next free stem_N.ext and records the real name in Dst rather than +// overwriting a file the plan never accounted for. Missing destination +// directories are created and recorded in Made, outermost first, whether or +// not the step that needed them goes on to succeed. +func runFileStep(step plan.Step) StepResult { + dst := step.Dst + var displacedEntry string + + if step.Displaces != "" { + // overwrite policy: the file already at dst must be trashed before + // this step's own destination name is used, so no free-name search + // applies here — the whole point of displacing was to clear this + // exact name. The entry name is captured regardless of what happens + // next in this step: spec §9 logs "displace" as its own action with + // its own line, independent of whether the move/copy/rename that + // needed the name then goes on to succeed, so every return below + // (failure included) carries it once trashing has succeeded. + entry, err := trash.Put(step.Displaces) + if err != nil { + return StepResult{Step: step, Status: "failed", Detail: "displacing the existing file: " + err.Error()} + } + displacedEntry = entry + } else if _, err := os.Lstat(dst); err == nil { + free, err := nextFreeName(dst) + if err != nil { + return StepResult{Step: step, Status: "failed", Detail: err.Error()} + } + dst = free + } else if !os.IsNotExist(err) { + return StepResult{Step: step, Status: "failed", Detail: err.Error()} + } + + made, err := mkdirAllTracked(filepath.Dir(dst)) + if err != nil { + return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry} + } + + switch step.Kind { + case plan.Copy: + err = copyFile(step.Src, dst) + case plan.Move: + err = moveFile(step.Src, dst) + case plan.Rename: + err = os.Rename(step.Src, dst) + } + if err != nil { + return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry} + } + + fi, err := os.Stat(dst) + if err != nil { + return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry} + } + return StepResult{Step: step, Status: "ok", Dst: dst, Size: fi.Size(), ModTime: fi.ModTime(), Made: made, DisplacedEntry: displacedEntry} +} + +// runTrashStep carries out (delete): the file goes to the freedesktop.org +// Trash via trash.Put. A file on a different filesystem from the Trash is +// not trashed at all; the spec requires the failure to name the two ways +// forward, since otherwise the user has no path out of it. +// +// Dst, Size and ModTime describe the file at trash.Dir()/files/<entry>, +// even though plan.Step.Dst is always "" for a delete (there is nothing to +// compute or conflict-check at plan time). That is a deliberate reading of +// §9 rather than an oversight forced by the empty plan.Step.Dst: those +// journal columns describe the file at Dst after the step, and after a +// trash step the file genuinely lives there, so recording it is more +// useful than an empty column and stays greppable. It also cannot confuse +// undo: Task 5's refusal condition for reversing a trash step is "the +// entry is gone, or Src now exists" — it reads Entry and Src, never Dst. +func runTrashStep(step plan.Step) StepResult { + entry, err := trash.Put(step.Src) + if err != nil { + detail := err.Error() + if errors.Is(err, trash.ErrOtherFilesystem) { + detail += "; use (delete permanent) or a move instead" + } + return StepResult{Step: step, Status: "failed", Detail: detail} + } + + dst := filepath.Join(trash.Dir(), "files", entry) + var size int64 + var modTime time.Time + if fi, err := os.Stat(dst); err == nil { + size, modTime = fi.Size(), fi.ModTime() + } + return StepResult{Step: step, Status: "ok", Dst: dst, Size: size, ModTime: modTime, Entry: entry} +} + +// runDeleteStep carries out (delete permanent): a plain unlink, with no +// Trash and no way back. +func runDeleteStep(step plan.Step) StepResult { + if err := os.Remove(step.Src); err != nil { + return StepResult{Step: step, Status: "failed", Detail: err.Error()} + } + return StepResult{Step: step, Status: "ok"} +} |
