diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-13 02:31:32 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-13 02:31:32 +0200 |
| commit | 26c94eb3db62ec6eebbf8d22c11afe691d9520c4 (patch) | |
| tree | 165e5bf69234b4f96c9b74deb4898d7143ddf120 /internal/apply | |
| parent | a6e442a645902011b2081c216daaec052cdc6ce6 (diff) | |
| download | krino-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.go | 2 | ||||
| -rw-r--r-- | internal/apply/apply_test.go | 42 | ||||
| -rw-r--r-- | internal/apply/fs.go | 35 |
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 |
