aboutsummaryrefslogtreecommitdiff
path: root/internal/plan
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 21:27:22 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 21:27:22 +0200
commitaca389f0e63713b890214cad950ac3aee7e6aaf4 (patch)
treebb4dab94ca594edb339ab8fd1743d583efe3d59c /internal/plan
parent3bfafbc8664a2a1ba8efc3f64376ff63c3dc11b9 (diff)
downloadkrino-aca389f0e63713b890214cad950ac3aee7e6aaf4.tar.gz
krino-aca389f0e63713b890214cad950ac3aee7e6aaf4.zip
plan 9: overwrite never trashes a directory or another scanned file
Diffstat (limited to 'internal/plan')
-rw-r--r--internal/plan/chain.go8
-rw-r--r--internal/plan/conflict.go15
-rw-r--r--internal/plan/conflict_test.go32
3 files changed, 54 insertions, 1 deletions
diff --git a/internal/plan/chain.go b/internal/plan/chain.go
index d4d07da..793e0e1 100644
--- a/internal/plan/chain.go
+++ b/internal/plan/chain.go
@@ -68,6 +68,14 @@ func Build(root string, in []Input, now time.Time, d Disk, claims *Claims) []Cha
return in[order[i]].File.Rel < in[order[j]].File.Rel
})
+ // Every scanned file's own path counts as spoken for (review M4): no step
+ // of this plan may trash another scanned file to make room - it may be
+ // about to move away, or be acted on by its own chain - so overwrite
+ // takes a free name there, exactly as suffix already did for a path that
+ // exists on disk.
+ for _, x := range in {
+ claims.taken[x.File.Path] = true
+ }
chains := make([]Chain, len(in))
for _, i := range order {
chains[i] = buildOne(root, in[i], now, d, claims.taken)
diff --git a/internal/plan/conflict.go b/internal/plan/conflict.go
index 8747669..c4c6b64 100644
--- a/internal/plan/conflict.go
+++ b/internal/plan/conflict.go
@@ -15,6 +15,7 @@ import (
// tests can supply a stub and Build stays pure otherwise.
type Disk interface {
Exists(path string) bool
+ Regular(path string) bool
SameContent(a, b string) (bool, error)
}
@@ -29,6 +30,13 @@ func (OS) Exists(path string) bool {
return err == nil
}
+// Regular reports whether path is a regular file, not following a symlink
+// at path itself.
+func (OS) Regular(path string) bool {
+ fi, err := os.Lstat(path)
+ return err == nil && fi.Mode().IsRegular()
+}
+
// SameContent delegates to internal/dup, the one place content identity is
// decided.
func (OS) SameContent(a, b string) (bool, error) {
@@ -40,6 +48,7 @@ func (OS) SameContent(a, b string) (bool, error) {
type NoDisk struct{}
func (NoDisk) Exists(string) bool { return false }
+func (NoDisk) Regular(string) bool { return false }
func (NoDisk) SameContent(string, string) (bool, error) { return false, nil }
// claimed is the set of destination paths already spoken for by an earlier
@@ -89,6 +98,12 @@ func resolveConflict(kind Kind, policy config.Conflict, src, dst string, d Disk,
return dst, "target exists", ""
case config.ConflictOverwrite:
if onDisk && !c[dst] {
+ // Only a regular file is ever trashed to make room (review M4):
+ // a directory or link of the same name stays, and so does the
+ // step - skipped, saying why.
+ if !d.Regular(dst) {
+ return dst, "target is not a regular file", ""
+ }
// The existing file is trashed first (plan 4). Only the first
// step to reach this path may displace it: once another step
// in this same plan has already claimed dst, that path will
diff --git a/internal/plan/conflict_test.go b/internal/plan/conflict_test.go
index bdc37af..289cd14 100644
--- a/internal/plan/conflict_test.go
+++ b/internal/plan/conflict_test.go
@@ -16,9 +16,11 @@ import (
type fakeDisk struct {
exists map[string]bool
same map[[2]string]bool
+ dirs map[string]bool // existing paths that are directories
}
-func (f fakeDisk) Exists(p string) bool { return f.exists[p] }
+func (f fakeDisk) Exists(p string) bool { return f.exists[p] }
+func (f fakeDisk) Regular(p string) bool { return f.exists[p] && !f.dirs[p] }
func (f fakeDisk) SameContent(a, b string) (bool, error) {
return f.same[[2]string{a, b}], nil
}
@@ -208,6 +210,7 @@ func TestSameContentReal(t *testing.T) {
type stubAlwaysExists struct{}
func (stubAlwaysExists) Exists(string) bool { return true }
+func (stubAlwaysExists) Regular(string) bool { return true }
func (stubAlwaysExists) SameContent(string, string) (bool, error) { return false, nil }
// TestSuffixedCapsAttempts is C2: suffixed() gives up after
@@ -222,3 +225,30 @@ func TestSuffixedCapsAttempts(t *testing.T) {
t.Errorf("step = %+v; want Skip \"too many conflicting names\" and empty Dst", s)
}
}
+
+// TestOverwriteNeverDisplacesADirectory: a hostile file named like an
+// existing directory must not have that directory trashed in its place
+// (review M4).
+func TestOverwriteNeverDisplacesADirectory(t *testing.T) {
+ dir := "/home/x/Documents/Invoices"
+ d := fakeDisk{exists: map[string]bool{dir: true}, dirs: map[string]bool{dir: true}}
+ _, skip, displaces := resolveConflict(Move, config.ConflictOverwrite, "/r/Invoices", dir, d, claimed{})
+ if displaces != "" || skip != "target is not a regular file" {
+ t.Errorf("skip %q displaces %q; want skipped, nothing displaced", skip, displaces)
+ }
+}
+
+// TestOverwriteNeverDisplacesAnotherScannedFile: a file of the same plan is
+// not trashed to make room - it may be about to move away or be acted on -
+// so the step takes a free name instead (review M4).
+func TestOverwriteNeverDisplacesAnotherScannedFile(t *testing.T) {
+ d := fakeDisk{exists: map[string]bool{"/r/a.pdf": true, "/r/b.pdf": true}}
+ in := []Input{
+ {File: file("/r", "a.pdf"), Rules: []RuleMatch{{Name: "r", Settings: config.Resolved{OnConflict: config.ConflictOverwrite}, Actions: []config.Action{act(config.Rename, "b.pdf")}}}},
+ {File: file("/r", "b.pdf"), Rules: []RuleMatch{{Name: "m", Actions: []config.Action{act(config.Move, "Out")}}}},
+ }
+ s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
+ if s.Displaces != "" || s.Dst != "/r/b_1.pdf" || s.Skip != "" {
+ t.Errorf("Displaces %q Dst %q Skip %q; want no displacement and /r/b_1.pdf", s.Displaces, s.Dst, s.Skip)
+ }
+}