diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 20:14:47 +0200 |
| commit | 3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch) | |
| tree | ec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /internal/apply | |
| parent | 24a84671ace373ae331fa83a1ff484990f4dff0e (diff) | |
| download | krino-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.go | 209 | ||||
| -rw-r--r-- | internal/apply/apply_test.go | 252 | ||||
| -rw-r--r-- | internal/apply/fs.go | 197 | ||||
| -rw-r--r-- | internal/apply/fs_test.go | 92 |
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") + } +} |
