aboutsummaryrefslogtreecommitdiff
path: root/internal/apply
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
commit3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch)
treeec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /internal/apply
parent24a84671ace373ae331fa83a1ff484990f4dff0e (diff)
downloadkrino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz
krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/apply')
-rw-r--r--internal/apply/apply.go209
-rw-r--r--internal/apply/apply_test.go252
-rw-r--r--internal/apply/fs.go197
-rw-r--r--internal/apply/fs_test.go92
4 files changed, 750 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"}
+}
diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go
new file mode 100644
index 0000000..64b0176
--- /dev/null
+++ b/internal/apply/apply_test.go
@@ -0,0 +1,252 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package apply
+
+import (
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/plan"
+ "krino/internal/scan"
+ "krino/internal/trash"
+)
+
+// chainFor builds a Chain whose File describes path as it is on disk now, so
+// the executor's "changed since plan" check passes.
+func chainFor(t *testing.T, root, rel string, steps ...plan.Step) plan.Chain {
+ t.Helper()
+ p := filepath.Join(root, rel)
+ fi, err := os.Stat(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return plan.Chain{
+ File: scan.File{Path: p, Rel: rel, Name: filepath.Base(rel), Size: fi.Size(), ModTime: fi.ModTime(), Mode: fi.Mode()},
+ Steps: steps,
+ }
+}
+
+func TestChainRunsStepsInOrder(t *testing.T) {
+ root := t.TempDir()
+ write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
+ c := chainFor(t, root, "x.pdf",
+ plan.Step{Kind: plan.Copy, Rule: "backup", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "B", "x.pdf")},
+ plan.Step{Kind: plan.Move, Rule: "acme", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "W", "x.pdf")},
+ )
+ got := Chain(c)
+ if len(got) != 2 || got[0].Status != "ok" || got[1].Status != "ok" {
+ t.Fatalf("results = %+v", got)
+ }
+ if b, err := os.ReadFile(filepath.Join(root, "B", "x.pdf")); err != nil || string(b) != "content" {
+ t.Errorf("the copy is missing: %q %v", b, err)
+ }
+ if b, err := os.ReadFile(filepath.Join(root, "W", "x.pdf")); err != nil || string(b) != "content" {
+ t.Errorf("the move did not arrive: %q %v", b, err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "x.pdf")); !os.IsNotExist(err) {
+ t.Error("the original survived the move")
+ }
+ if len(got[0].Made) == 0 {
+ t.Error("the created directory was not recorded in Made")
+ }
+ if got[1].Size != int64(len("content")) {
+ t.Errorf("Size = %d, want the size at Dst afterwards", got[1].Size)
+ }
+}
+
+// TestChainStopsWhenFileChanged is the guard that matters most: a file
+// rewritten between planning and applying must not be acted on at all.
+func TestChainStopsWhenFileChanged(t *testing.T) {
+ root := t.TempDir()
+ src := write(t, filepath.Join(root, "x.pdf"), "planned", 0o644)
+ c := chainFor(t, root, "x.pdf",
+ plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf")},
+ plan.Step{Kind: plan.Rename, Rule: "b", Src: filepath.Join(root, "W", "x.pdf"), Dst: filepath.Join(root, "W", "y.pdf")},
+ )
+ write(t, src, "rewritten since the plan was made", 0o644)
+
+ got := Chain(c)
+ if got[0].Status != "failed" || !strings.Contains(got[0].Detail, "changed since plan") {
+ t.Fatalf("first step = %+v; want failed \"changed since plan\"", got[0])
+ }
+ if got[1].Status != "skipped" {
+ t.Errorf("second step = %+v; want skipped after the failure", got[1])
+ }
+ if b, _ := os.ReadFile(src); string(b) != "rewritten since the plan was made" {
+ t.Error("the changed file was modified anyway")
+ }
+}
+
+func TestChainTrashAndPermanentDelete(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("HOME", root)
+ t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
+ t.Setenv("XDG_STATE_HOME", "")
+ t.Setenv("XDG_CONFIG_HOME", "")
+ t.Setenv("XDG_CACHE_HOME", "")
+
+ gone := write(t, filepath.Join(root, "gone.pdf"), "trash me", 0o644)
+ c1 := chainFor(t, root, "gone.pdf", plan.Step{Kind: plan.Trash, Rule: "dups", Src: gone})
+ r1 := Chain(c1)
+ if r1[0].Status != "ok" || r1[0].Entry == "" {
+ t.Fatalf("trash step = %+v; want ok with an Entry name", r1[0])
+ }
+ if r1[0].DisplacedEntry != "" {
+ t.Errorf("DisplacedEntry = %q, want empty: a plain trash step displaces nothing", r1[0].DisplacedEntry)
+ }
+ if _, err := os.Stat(gone); !os.IsNotExist(err) {
+ t.Error("the trashed file is still in place")
+ }
+
+ nuked := write(t, filepath.Join(root, "nuked.pdf"), "unlink me", 0o644)
+ c2 := chainFor(t, root, "nuked.pdf", plan.Step{Kind: plan.DeletePermanent, Rule: "old", Src: nuked})
+ if r2 := Chain(c2); r2[0].Status != "ok" {
+ t.Fatalf("permanent delete = %+v", r2[0])
+ }
+ if _, err := os.Stat(nuked); !os.IsNotExist(err) {
+ t.Error("the permanently deleted file is still in place")
+ }
+}
+
+func TestChainReChecksConflictAtExecutionTime(t *testing.T) {
+ root := t.TempDir()
+ src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644)
+ dst := filepath.Join(root, "W", "x.pdf")
+ c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst})
+ // Something took the planned name between planning and applying.
+ write(t, dst, "someone else got here first", 0o644)
+
+ got := Chain(c)
+ if got[0].Status != "ok" {
+ t.Fatalf("step = %+v", got[0])
+ }
+ if got[0].Dst == dst {
+ t.Error("the executor overwrote a name that appeared after planning")
+ }
+ if !strings.HasSuffix(got[0].Dst, "x_1.pdf") {
+ t.Errorf("Dst = %q, want the next free name", got[0].Dst)
+ }
+ if b, _ := os.ReadFile(dst); string(b) != "someone else got here first" {
+ t.Error("the file that took the planned name was overwritten")
+ }
+}
+
+func TestChainSkippedStepIsNotAttempted(t *testing.T) {
+ root := t.TempDir()
+ src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
+ c := chainFor(t, root, "x.pdf",
+ plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf"), Skip: "target exists"},
+ )
+ if got := Chain(c); got[0].Status != "skipped" || got[0].Detail != "target exists" {
+ t.Fatalf("result = %+v; want skipped carrying the planning reason", got[0])
+ }
+ if _, err := os.Stat(filepath.Join(root, "W")); !os.IsNotExist(err) {
+ t.Error("a skipped step created its destination directory")
+ }
+ if _, err := os.Stat(src); err != nil {
+ t.Error("a skipped step moved the file anyway")
+ }
+ _ = time.Now
+}
+
+// TestChainDisplacedFileRestoresFromDisplacedEntry is the fix-round-1 test:
+// DisplacedEntry must be usable for undo, not merely present. It proves
+// that by actually restoring the displaced file from the Trash and checking
+// its content, not just that the field is non-empty. The displaced file
+// sits at its own path, distinct from the step's own Dst: were the two the
+// same (the ordinary overwrite shape), the mover's own file would already
+// occupy that name by the time Restore ran, and Restore correctly refuses
+// to land on an occupied path — this test isolates DisplacedEntry's own
+// round-trip instead of also exercising that refusal.
+func TestChainDisplacedFileRestoresFromDisplacedEntry(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("HOME", root)
+ t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
+ t.Setenv("XDG_STATE_HOME", "")
+ t.Setenv("XDG_CONFIG_HOME", "")
+ t.Setenv("XDG_CACHE_HOME", "")
+
+ src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644)
+ displaced := write(t, filepath.Join(root, "old", "y.pdf"), "displaced content", 0o644)
+ dst := filepath.Join(root, "W", "x.pdf")
+
+ c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst, Displaces: displaced})
+
+ got := Chain(c)
+ if got[0].Status != "ok" {
+ t.Fatalf("step = %+v", got[0])
+ }
+ if got[0].DisplacedEntry == "" {
+ t.Fatal("DisplacedEntry is empty; undo has no way to find the displaced file")
+ }
+ if got[0].Entry != "" {
+ t.Errorf("Entry = %q, want empty: this step is a Move, not itself a Trash step", got[0].Entry)
+ }
+ if _, err := os.Stat(displaced); !os.IsNotExist(err) {
+ t.Error("the displaced file is still at its old path")
+ }
+ if b, err := os.ReadFile(dst); err != nil || string(b) != "mine" {
+ t.Errorf("the move's own destination = %q, %v", b, err)
+ }
+
+ restored, err := trash.Restore(got[0].DisplacedEntry)
+ if err != nil {
+ t.Fatalf("Restore(%q): %v", got[0].DisplacedEntry, err)
+ }
+ if restored != displaced {
+ t.Errorf("restored = %q, want %q", restored, displaced)
+ }
+ if b, err := os.ReadFile(restored); err != nil || string(b) != "displaced content" {
+ t.Errorf("restored content = %q, %v; want the displaced file's own content", b, err)
+ }
+}
+
+// TestChainRunsRenameStep is the fix-round-2 gap: apply_test.go's only other
+// Rename (in TestChainStopsWhenFileChanged) is always reported "skipped",
+// because the Move before it is made to fail on purpose, so
+// "case plan.Rename: err = os.Rename(step.Src, dst)" is never exercised by
+// a passing test. A reversed-argument typo there would compile, pass every
+// other test, pass make ci, and surface only as live data corruption.
+func TestChainRunsRenameStep(t *testing.T) {
+ root := t.TempDir()
+ src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
+ dst := filepath.Join(root, "y.pdf")
+ c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Rename, Rule: "a", Src: src, Dst: dst})
+
+ got := Chain(c)
+ if got[0].Status != "ok" {
+ t.Fatalf("step = %+v", got[0])
+ }
+ if _, err := os.Stat(src); !os.IsNotExist(err) {
+ t.Error("the old name still exists after a successful rename")
+ }
+ if b, err := os.ReadFile(dst); err != nil || string(b) != "content" {
+ t.Errorf("the new name = %q, %v; want the original content at the new name", b, err)
+ }
+}
+
+// TestChainMadeIsOutermostFirstForNestedDirectories is the fix-round-2 gap:
+// every other test creates at most one missing directory level, so
+// mkdirAllTracked's outermost-first ordering is correct by trace but
+// unpinned by any assertion. Task 5 removes these directories in reverse,
+// so a later accidental reordering would break undo while passing
+// everything else here.
+func TestChainMadeIsOutermostFirstForNestedDirectories(t *testing.T) {
+ root := t.TempDir()
+ write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
+ dst := filepath.Join(root, "A", "B", "x.pdf")
+ c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Copy, Rule: "a", Src: filepath.Join(root, "x.pdf"), Dst: dst})
+
+ got := Chain(c)
+ if got[0].Status != "ok" {
+ t.Fatalf("step = %+v", got[0])
+ }
+ want := []string{filepath.Join(root, "A"), filepath.Join(root, "A", "B")}
+ if !slices.Equal(got[0].Made, want) {
+ t.Errorf("Made = %v, want %v (outermost first)", got[0].Made, want)
+ }
+}
diff --git a/internal/apply/fs.go b/internal/apply/fs.go
new file mode 100644
index 0000000..205089f
--- /dev/null
+++ b/internal/apply/fs.go
@@ -0,0 +1,197 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package apply
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+)
+
+// copyFile copies src to dst by streaming its content through a temporary
+// file created in dst's directory, syncing it, then renaming it into place.
+// dst must not already exist. Mode and modification time are preserved from
+// src. On any failure the temporary file is removed and neither src nor a
+// pre-existing dst is touched.
+//
+// This is not internal/config's replaceFile reused: that helper resolves a
+// destination symlink and overwrites a file that is already there, and it
+// takes the whole replacement as a []byte. copy's destination must not exist
+// beforehand, and a []byte cannot stand in for a file that may be many
+// gigabytes, so this is a separate, streaming equivalent kept local to
+// internal/apply rather than shared with internal/config.
+func copyFile(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ fi, err := in.Stat()
+ if err != nil {
+ return err
+ }
+
+ dir := filepath.Dir(dst)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+
+ tmp, err := os.CreateTemp(dir, ".krino-*")
+ if err != nil {
+ return err
+ }
+ tmpName := tmp.Name()
+ done := false
+ defer func() {
+ if !done {
+ os.Remove(tmpName)
+ }
+ }()
+
+ if _, err := io.Copy(tmp, in); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Chmod(fi.Mode().Perm()); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Chtimes(tmpName, fi.ModTime(), fi.ModTime()); err != nil {
+ return err
+ }
+
+ // Deliberate, not redundant: the executor's own conflict re-check
+ // (runFileStep, apply.go) already found a name free of anything on disk
+ // before ever calling copyFile, so dst existing here means something
+ // else claimed it in the meantime. Refusing is the only safe response —
+ // silently overwriting it, via os.Rename below, would destroy whatever
+ // just raced us.
+ if _, err := os.Lstat(dst); err == nil {
+ return fmt.Errorf("copy: destination already exists: %s", dst)
+ } else if !os.IsNotExist(err) {
+ return err
+ }
+ if err := os.Rename(tmpName, dst); err != nil {
+ return err
+ }
+ done = true
+ return nil
+}
+
+// moveFile moves src to dst. It tries os.Rename first, which is atomic when
+// src and dst are on the same filesystem. Only when that fails with EXDEV
+// (a different filesystem) does it fall back to copying src to dst through
+// copyFile — itself leaving no temporary file and touching neither src nor
+// dst on failure — and, only once that copy has landed at dst, removing src.
+// A failure at any point before the copy has landed leaves src exactly
+// where it was; a failure to remove src afterward leaves both a full copy
+// at dst and the original at src rather than risk deleting the only good
+// copy.
+//
+// The filesystem check is done by unwrapping the error for syscall.EXDEV,
+// never by comparing a Stat_t's device field: that field's type differs
+// across the platforms `make ci` vets (freebsd, openbsd), while syscall.EXDEV
+// itself is defined identically on all three.
+func moveFile(src, dst string) error {
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return err
+ }
+ err := os.Rename(src, dst)
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, syscall.EXDEV) {
+ return err
+ }
+ if err := copyFile(src, dst); err != nil {
+ return err
+ }
+ return os.Remove(src)
+}
+
+// maxSuffixAttempts bounds nextFreeName. internal/plan/conflict.go and
+// internal/trash/trash.go each have their own cap of the same size, for the
+// same reason given below: nextFreeName solves yet another, independent
+// collision problem and is not sharing code with either.
+const maxSuffixAttempts = 10000
+
+// nextFreeName finds the first stem_N.ext, N starting at 1, that does not
+// currently exist on disk. It is the executor's own conflict re-check (spec
+// §7.4, last paragraph): planning already resolved every conflict once
+// against the filesystem as it was then, but something else can claim the
+// planned name before the executor gets to it, so the executor looks again,
+// right before acting, using only the real filesystem — it has no run-wide
+// claim set to consult, unlike planning's.
+//
+// internal/plan's own suffixed() and splitExt are unexported, so they are
+// not reachable from here; nextFreeName and splitExt below are a second,
+// small implementation, the same shape as internal/plan's and
+// internal/trash's for the same reason those two do not share code with
+// each other either — each resolves a distinct, independently changing set
+// of collisions (planned destinations; entries already in the Trash; names
+// that appeared on disk since this plan was made).
+func nextFreeName(dst string) (string, error) {
+ dir, base := filepath.Split(dst)
+ stem, ext := splitExt(base)
+ for n := 1; n <= maxSuffixAttempts; n++ {
+ candidate := filepath.Join(dir, fmt.Sprintf("%s_%d%s", stem, n, ext))
+ if _, err := os.Lstat(candidate); os.IsNotExist(err) {
+ return candidate, nil
+ }
+ }
+ return "", errors.New("too many conflicting names")
+}
+
+// splitExt splits name on its last dot, which does not count when it is the
+// first character: "a.tar.gz" -> "a.tar", ".gz"; ".bashrc" -> ".bashrc", "".
+// Duplicated from internal/plan/conflict.go and internal/trash/trash.go
+// (unexported in both) rather than shared; see nextFreeName's comment.
+func splitExt(name string) (stem, ext string) {
+ i := strings.LastIndexByte(name, '.')
+ if i <= 0 {
+ return name, ""
+ }
+ return name[:i], name[i:]
+}
+
+// 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) {
+ dir = filepath.Clean(dir)
+ if fi, err := os.Stat(dir); err == nil {
+ if !fi.IsDir() {
+ return nil, fmt.Errorf("%s exists and is not a directory", dir)
+ }
+ return nil, nil
+ } else if !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ parent := filepath.Dir(dir)
+ var made []string
+ if parent != dir {
+ parentMade, err := mkdirAllTracked(parent)
+ if err != nil {
+ return nil, err
+ }
+ made = parentMade
+ }
+ if err := os.Mkdir(dir, 0o755); err != nil && !os.IsExist(err) {
+ return made, err
+ }
+ return append(made, dir), nil
+}
diff --git a/internal/apply/fs_test.go b/internal/apply/fs_test.go
new file mode 100644
index 0000000..87c5824
--- /dev/null
+++ b/internal/apply/fs_test.go
@@ -0,0 +1,92 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package apply
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func write(t *testing.T, path, content string, mode os.FileMode) string {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), mode); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestCopyPreservesModeAndModTime(t *testing.T) {
+ dir := t.TempDir()
+ src := write(t, filepath.Join(dir, "a", "x.pdf"), "content", 0o640)
+ old := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
+ if err := os.Chtimes(src, old, old); err != nil {
+ t.Fatal(err)
+ }
+ dst := filepath.Join(dir, "b", "x.pdf")
+
+ if err := copyFile(src, dst); err != nil {
+ t.Fatal(err)
+ }
+ fi, err := os.Stat(dst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b, _ := os.ReadFile(dst); string(b) != "content" {
+ t.Errorf("content = %q", b)
+ }
+ if fi.Mode().Perm() != 0o640 {
+ t.Errorf("mode = %v, want 0640", fi.Mode().Perm())
+ }
+ if !fi.ModTime().Equal(old) {
+ t.Errorf("mtime = %v, want %v", fi.ModTime(), old)
+ }
+ if si, _ := os.Stat(src); si == nil {
+ t.Error("copy removed its source")
+ }
+}
+
+func TestCopyLeavesNoTempOnFailure(t *testing.T) {
+ dir := t.TempDir()
+ src := write(t, filepath.Join(dir, "x.pdf"), "content", 0o644)
+ // A destination directory that is really a file: the rename must fail.
+ blocked := write(t, filepath.Join(dir, "blocked"), "not a directory", 0o644)
+ if err := copyFile(src, filepath.Join(blocked, "x.pdf")); err == nil {
+ t.Fatal("copy into a non-directory succeeded")
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if len(e.Name()) > 6 && e.Name()[:7] == ".krino-" {
+ t.Errorf("a temporary file was left behind: %s", e.Name())
+ }
+ }
+}
+
+// TestMoveAcrossFilesystems exercises the EXDEV fallback. /dev/shm is a
+// second filesystem on Linux; the test skips where there is none.
+func TestMoveAcrossFilesystems(t *testing.T) {
+ other, err := os.MkdirTemp("/dev/shm", "krino-apply-")
+ if err != nil {
+ t.Skip("no second filesystem available:", err)
+ }
+ defer os.RemoveAll(other)
+ src := write(t, filepath.Join(other, "x.pdf"), "across", 0o644)
+ dst := filepath.Join(t.TempDir(), "x.pdf")
+
+ if err := moveFile(src, dst); err != nil {
+ t.Fatal(err)
+ }
+ if b, err := os.ReadFile(dst); err != nil || string(b) != "across" {
+ t.Errorf("destination = %q, %v", b, err)
+ }
+ if _, err := os.Stat(src); !os.IsNotExist(err) {
+ t.Error("the source survived a cross-filesystem move")
+ }
+}