aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/plan/chain.go50
-rw-r--r--internal/plan/fuzz_test.go18
-rw-r--r--internal/plan/hostile_test.go38
3 files changed, 91 insertions, 15 deletions
diff --git a/internal/plan/chain.go b/internal/plan/chain.go
index fda8730..d4d07da 100644
--- a/internal/plan/chain.go
+++ b/internal/plan/chain.go
@@ -212,29 +212,51 @@ func stepKind(k config.ActionKind) Kind {
// the same way the engine resolves an extra directory: ~ expands, a
// relative path joins root, and the result is cleaned.
//
-// A placeholder may not add a ".." segment (spec §15.1): a capture is part
-// of a file name, which can be "..", and must not move the destination out
-// of the directory the rule names. A ".." written in the rule itself stands.
+// A placeholder may not take the destination out of the directory the
+// rule's own text names before it (spec §15.1): a capture or {ext} is part
+// of a file name, which can be "..", "~" or empty, and ResolveDir decides
+// from the expanded text whether a destination is relative, in the home
+// directory or absolute. So the resolved destination must lie at or under
+// staticDir of the raw text. A destination with no placeholder is written
+// entirely by the rule, and stands as written.
func expandDir(raw string, facts Facts, root string) (string, error) {
expanded, err := Expand(raw, facts)
if err != nil {
return "", err
}
- if dotDots(expanded) > dotDots(raw) {
- return "", fmt.Errorf("destination %q leaves its directory through a placeholder", expanded)
+ resolved := ResolveDir(expanded, root)
+ if strings.ContainsRune(raw, '{') {
+ if base := staticDir(raw, root); !within(resolved, base) {
+ return "", fmt.Errorf("destination %q leaves %s through a placeholder", expanded, xdg.Abbrev(base))
+ }
}
- return ResolveDir(expanded, root), nil
+ return resolved, nil
}
-// dotDots counts the ".." segments of a slash-separated path.
-func dotDots(p string) int {
- n := 0
- for _, seg := range strings.Split(p, "/") {
- if seg == ".." {
- n++
- }
+// staticDir is the directory a destination names before its first
+// placeholder: its last complete path segment, resolved like any
+// destination. "Work/Acme/{mtime:%Y}" is root/Work/Acme, "Work/Ac{1}" is
+// root/Work, "{1}/x" is root itself, "~/{1}" is the home directory and
+// "/{1}" is "/".
+func staticDir(raw, root string) string {
+ prefix := raw
+ if i := strings.IndexByte(raw, '{'); i >= 0 {
+ prefix = raw[:i]
+ }
+ switch i := strings.LastIndexByte(prefix, '/'); {
+ case i < 0:
+ prefix = ""
+ case i == 0:
+ prefix = "/"
+ default:
+ prefix = prefix[:i]
}
- return n
+ return ResolveDir(prefix, root)
+}
+
+// within reports whether path is dir itself or lies under it.
+func within(path, dir string) bool {
+ return path == dir || dir == string(filepath.Separator) || strings.HasPrefix(path, dir+string(filepath.Separator))
}
// ResolveDir expands a leading ~ and joins a relative directory to root,
diff --git a/internal/plan/fuzz_test.go b/internal/plan/fuzz_test.go
index fb68f94..6deb225 100644
--- a/internal/plan/fuzz_test.go
+++ b/internal/plan/fuzz_test.go
@@ -3,13 +3,21 @@
package plan
import (
+ "path/filepath"
+ "strings"
"testing"
"time"
+
+ "krino/internal/config"
+ "krino/internal/scan"
)
// FuzzExpand: expanding any template against any file name never panics,
// gives the same answer twice, and never succeeds with a {N} beyond the
// capture groups there are - MaxIndex and Expand read placeholders alike.
+// Used as a destination - relative, in the home directory, or bare - a
+// template never plans a move outside the directory its text names before
+// the first placeholder (review M1).
func FuzzExpand(f *testing.F) {
for _, s := range []string{"{name}", "{stem}{ext}", "{mtime:%Y/%m}", "{1}_{2}", "{{literal}}", "{", "}", "{now:%", "{0}", "{9}", "{99999999999999999999}"} {
f.Add(s, "a.b.pdf")
@@ -29,5 +37,15 @@ func FuzzExpand(f *testing.F) {
if n, err := MaxIndex(tmpl); err == nil && n > 2 && errA == nil {
t.Fatalf("Expand(%q) = %q, though it uses {%d} and only 2 groups exist", tmpl, a, n)
}
+ for _, dest := range []string{tmpl, "Out/" + tmpl, "~/docs/" + tmpl} {
+ in := []Input{{
+ File: scan.File{Path: "/r/x.pdf", Rel: "x.pdf", Name: "x.pdf", ModTime: facts.ModTime},
+ Rules: []RuleMatch{{Name: "a", Captures: facts.Captures, Actions: []config.Action{{Kind: config.Move, Arg: dest}}}},
+ }}
+ s := Build("/r", in, facts.Now, NoDisk{}, NewClaims())[0].Steps[0]
+ if strings.ContainsRune(dest, '{') && s.Skip == "" && !within(filepath.Dir(s.Dst), staticDir(dest, "/r")) {
+ t.Fatalf("destination %q planned %q, outside %q", dest, s.Dst, staticDir(dest, "/r"))
+ }
+ }
})
}
diff --git a/internal/plan/hostile_test.go b/internal/plan/hostile_test.go
index c22fb24..a2f2bfe 100644
--- a/internal/plan/hostile_test.go
+++ b/internal/plan/hostile_test.go
@@ -39,7 +39,7 @@ func TestBuildRefusesDotDotFromPlaceholder(t *testing.T) {
}}
return Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0].Steps[0]
}
- if s := step("/w/{1}/in"); s.Skip != `destination "/w/../in" leaves its directory through a placeholder` {
+ if s := step("/w/{1}/in"); s.Skip != `destination "/w/../in" leaves /w through a placeholder` {
t.Errorf("placeholder ..: Skip = %q, Dst = %q", s.Skip, s.Dst)
}
if s := step("/w/../in"); s.Skip != "" || s.Dst != "/in/x.pdf" {
@@ -49,3 +49,39 @@ func TestBuildRefusesDotDotFromPlaceholder(t *testing.T) {
t.Errorf("..x: Skip = %q, Dst = %q", s.Skip, s.Dst)
}
}
+
+// TestBuildKeepsPlaceholdersInsideTheirDirectory: a capture of "~", an
+// empty capture, or an extension-less {ext} at the start of a destination
+// used to make it $HOME or absolute (review M1). The resolved destination
+// must stay under the directory the rule's text names before its first
+// placeholder; a destination with no placeholder is the rule's own
+// business.
+func TestBuildKeepsPlaceholdersInsideTheirDirectory(t *testing.T) {
+ t.Setenv("HOME", "/home/x")
+ step := func(name, dest string, caps ...string) Step {
+ in := []Input{{File: file("/r", name), Rules: []RuleMatch{{Name: "a", Captures: caps, Actions: []config.Action{act(config.Move, dest)}}}}}
+ return Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0].Steps[0]
+ }
+ for _, tc := range []struct {
+ name, dest string
+ caps []string
+ skip bool
+ dst string
+ }{
+ {"~_i.pdf", "{1}/Filed", []string{"~_", "~"}, true, ""},
+ {"_i.pdf", "{1}/Filed", []string{"_", ""}, true, ""},
+ {"README", "{ext}/tmp", nil, true, ""},
+ {"a.pdf", "Out/{1}", []string{"a", "~"}, false, "/r/Out/~/a.pdf"},
+ {"a.pdf", "~/docs/{1}", []string{"a", "x"}, false, "/home/x/docs/x/a.pdf"},
+ {"a.pdf", "~/docs/{1}", []string{"a", ".."}, true, ""},
+ {"a.pdf", "Work/Ac{1}", []string{"a", "me"}, false, "/r/Work/Acme/a.pdf"},
+ {"a.pdf", "/w/{1}/in", []string{"a", ".."}, true, ""},
+ {"a.pdf", "/w/../in", nil, false, "/in/a.pdf"},
+ {"a.pdf", "{mtime:%Y}", nil, false, "/r/2026/a.pdf"},
+ } {
+ s := step(tc.name, tc.dest, tc.caps...)
+ if tc.skip != (s.Skip != "") || (!tc.skip && s.Dst != tc.dst) {
+ t.Errorf("%s -> %s %q: Skip %q Dst %q; want skip=%v dst=%q", tc.name, tc.dest, tc.caps, s.Skip, s.Dst, tc.skip, tc.dst)
+ }
+ }
+}