summaryrefslogtreecommitdiff
path: root/internal/apply
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-13 02:31:32 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-13 02:31:32 +0200
commit26c94eb3db62ec6eebbf8d22c11afe691d9520c4 (patch)
tree165e5bf69234b4f96c9b74deb4898d7143ddf120 /internal/apply
parenta6e442a645902011b2081c216daaec052cdc6ce6 (diff)
downloadkrino-0.0.1.tar.gz
krino-0.0.1.zip
krino: release 0.0.1 — man pages, install, examples, cross and release, README, changelogv0.0.1
Also: undo removes the directories its run created; a hardlink is never a duplicate of its own other name; a flag written before "undo" is honoured; --version prints no leading v. Duplicate conditions with different scopes not sharing an original is documented as a known limitation.
Diffstat (limited to 'internal/apply')
-rw-r--r--internal/apply/apply.go2
-rw-r--r--internal/apply/apply_test.go42
-rw-r--r--internal/apply/fs.go35
3 files changed, 78 insertions, 1 deletions
diff --git a/internal/apply/apply.go b/internal/apply/apply.go
index 2cd52e4..f177a17 100644
--- a/internal/apply/apply.go
+++ b/internal/apply/apply.go
@@ -153,7 +153,7 @@ func runFileStep(step plan.Step) StepResult {
case plan.Move:
err = moveFile(step.Src, dst)
case plan.Rename:
- err = os.Rename(step.Src, dst)
+ err = renameFile(step.Src, dst)
}
if err != nil {
return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry}
diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go
index 64b0176..4bf6c30 100644
--- a/internal/apply/apply_test.go
+++ b/internal/apply/apply_test.go
@@ -250,3 +250,45 @@ func TestChainMadeIsOutermostFirstForNestedDirectories(t *testing.T) {
t.Errorf("Made = %v, want %v (outermost first)", got[0].Made, want)
}
}
+
+// TestMoveFileRefusesOccupiedDestination is item 16 (fix round 2026-09-12,
+// plan 5 Task 2): moveFile must refuse an occupied destination on its own,
+// not merely rely on runFileStep having already checked - the exact
+// arrangement that produced plan 4's Task 5 Critical, where a helper that
+// replaced silently was trusted because some caller had checked. Called
+// directly, bypassing runFileStep's own pre-check entirely.
+func TestMoveFileRefusesOccupiedDestination(t *testing.T) {
+ dir := t.TempDir()
+ src := write(t, filepath.Join(dir, "x.pdf"), "source", 0o644)
+ dst := write(t, filepath.Join(dir, "y.pdf"), "already there", 0o644)
+
+ if err := moveFile(src, dst); err == nil {
+ t.Fatal("moveFile overwrote an existing destination")
+ }
+ if b, err := os.ReadFile(src); err != nil || string(b) != "source" {
+ t.Errorf("moveFile touched its source: %q, %v", b, err)
+ }
+ if b, err := os.ReadFile(dst); err != nil || string(b) != "already there" {
+ t.Errorf("moveFile touched its destination: %q, %v", b, err)
+ }
+}
+
+// TestRenameFileRefusesOccupiedDestination is item 16's other half:
+// runFileStep's bare os.Rename call for the Rename kind was just as
+// unguarded in itself as moveFile was. renameFile is the helper that now
+// carries the same independent guard, called directly here.
+func TestRenameFileRefusesOccupiedDestination(t *testing.T) {
+ dir := t.TempDir()
+ src := write(t, filepath.Join(dir, "x.pdf"), "source", 0o644)
+ dst := write(t, filepath.Join(dir, "y.pdf"), "already there", 0o644)
+
+ if err := renameFile(src, dst); err == nil {
+ t.Fatal("renameFile overwrote an existing destination")
+ }
+ if b, err := os.ReadFile(src); err != nil || string(b) != "source" {
+ t.Errorf("renameFile touched its source: %q, %v", b, err)
+ }
+ if b, err := os.ReadFile(dst); err != nil || string(b) != "already there" {
+ t.Errorf("renameFile touched its destination: %q, %v", b, err)
+ }
+}
diff --git a/internal/apply/fs.go b/internal/apply/fs.go
index 205089f..823d5c8 100644
--- a/internal/apply/fs.go
+++ b/internal/apply/fs.go
@@ -108,6 +108,16 @@ func moveFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
+ // Item 16 (fix round 2026-09-12, plan 5 Task 2): this guard must hold
+ // independently of runFileStep's own pre-check, layered rather than
+ // moved - the exact arrangement that produced plan 4's Task 5 Critical,
+ // where a helper that replaced silently was trusted because some caller
+ // had checked. Placed immediately before the operation that would
+ // otherwise clobber dst, the same way copyFile's own guard sits right
+ // before its rename into place.
+ if err := refuseIfExists(dst); err != nil {
+ return err
+ }
err := os.Rename(src, dst)
if err == nil {
return nil
@@ -121,6 +131,31 @@ func moveFile(src, dst string) error {
return os.Remove(src)
}
+// renameFile renames src to dst, refusing on its own when dst already
+// exists rather than trusting that a caller checked first (item 16, same
+// reasoning as moveFile's guard above): a bare os.Rename silently replaces
+// an occupied destination, and runFileStep's own pre-check must not be the
+// only thing standing between a rename step and that.
+func renameFile(src, dst string) error {
+ if err := refuseIfExists(dst); err != nil {
+ return err
+ }
+ return os.Rename(src, dst)
+}
+
+// refuseIfExists reports an error naming dst if something is already there
+// (os.Lstat succeeds, following no symlink), and propagates any other stat
+// failure. A nil return means dst was confirmed absent at the moment of the
+// check.
+func refuseIfExists(dst string) error {
+ if _, err := os.Lstat(dst); err == nil {
+ return fmt.Errorf("destination already exists: %s", dst)
+ } else if !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
// 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