summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:45:23 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-14 23:45:23 +0200
commit97c8164bc81e0a438dab92d7244485005dac4ff2 (patch)
tree08d657f8b92a6539259b6aa8f08b0e9ef8cc6710 /internal
parent166565025832c8a8faf7397766aeaa878980dd2d (diff)
downloadkrino-97c8164bc81e0a438dab92d7244485005dac4ff2.tar.gz
krino-97c8164bc81e0a438dab92d7244485005dac4ff2.zip
literal braces are text, not placeholders, for containment and walk exclusion
Diffstat (limited to 'internal')
-rw-r--r--internal/engine/match.go5
-rw-r--r--internal/engine/match_test.go2
-rw-r--r--internal/plan/chain.go18
-rw-r--r--internal/plan/fuzz_test.go37
-rw-r--r--internal/plan/hostile_test.go5
-rw-r--r--internal/plan/placeholder.go24
-rw-r--r--internal/plan/testdata/fuzz/FuzzExpand/8d1df2cb4b20cf0b3
7 files changed, 69 insertions, 25 deletions
diff --git a/internal/engine/match.go b/internal/engine/match.go
index 0e8b3d5..9f5ff12 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -501,9 +501,8 @@ func (e *Engine) excludeDirs(d *Dir) []string {
if a.Kind != config.Copy && a.Kind != config.Move {
continue
}
- prefix := a.Arg
- if idx := strings.IndexByte(prefix, '{'); idx >= 0 {
- prefix = prefix[:idx]
+ prefix, templated := plan.StaticPrefix(a.Arg)
+ if templated {
if idx2 := strings.LastIndexByte(prefix, '/'); idx2 >= 0 {
prefix = prefix[:idx2]
} else {
diff --git a/internal/engine/match_test.go b/internal/engine/match_test.go
index 477e06f..f2a01bc 100644
--- a/internal/engine/match_test.go
+++ b/internal/engine/match_test.go
@@ -267,6 +267,7 @@ func TestExcludeDirs(t *testing.T) {
(rule "r7" (move "` + absDest + `"))
(rule "r8" (copy "Backup"))
(rule "r9" (rename "x-{name}"))
+(rule "r10" (move "Lit/{{a}}/{mtime:%Y}"))
`
main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": conf})
e, errs := Load(main)
@@ -285,6 +286,7 @@ func TestExcludeDirs(t *testing.T) {
absDest, // r7: absolute, already inside root
filepath.Join(root, "Backup"), // r8: copy counts like move
// r9 rename "x-{name}": rename is never a destination.
+ filepath.Join(root, "Lit", "{a}"), // r10: "{{a}}" is literal text
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("excludeDirs =\n%v\nwant\n%v", got, want)
diff --git a/internal/plan/chain.go b/internal/plan/chain.go
index 793e0e1..b32cc7d 100644
--- a/internal/plan/chain.go
+++ b/internal/plan/chain.go
@@ -233,8 +233,8 @@ func expandDir(raw string, facts Facts, root string) (string, error) {
return "", err
}
resolved := ResolveDir(expanded, root)
- if strings.ContainsRune(raw, '{') {
- if base := staticDir(raw, root); !within(resolved, base) {
+ if prefix, templated := StaticPrefix(raw); templated {
+ if base := staticDir(prefix, root); !within(resolved, base) {
return "", fmt.Errorf("destination %q leaves %s through a placeholder", expanded, xdg.Abbrev(base))
}
}
@@ -242,15 +242,11 @@ func expandDir(raw string, facts Facts, root string) (string, error) {
}
// 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]
- }
+// placeholder, given that text (StaticPrefix): 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(prefix, root string) string {
switch i := strings.LastIndexByte(prefix, '/'); {
case i < 0:
prefix = ""
diff --git a/internal/plan/fuzz_test.go b/internal/plan/fuzz_test.go
index c7261ec..91a8421 100644
--- a/internal/plan/fuzz_test.go
+++ b/internal/plan/fuzz_test.go
@@ -56,7 +56,7 @@ func FuzzExpand(f *testing.F) {
if got == "" {
continue
}
- if base := textDir(dest, "/r", xdg.Home()); !underDir(got, base) {
+ if base, templated := textDir(dest, "/r", xdg.Home()); templated && !underDir(got, base) {
t.Fatalf("destination %q planned %q for %q, outside %q, the directory its text names", dest, got, name, base)
}
}
@@ -79,28 +79,43 @@ func plannedDir(dest, name string, captures []string, facts Facts) string {
}
// textDir is the directory a destination's text names before its first
-// '{' (spec 15.1), worked out here by hand rather than by staticDir: the
-// text up to the last '/' before the brace, "~" as the home directory, a
-// relative path under root.
-func textDir(dest, root, home string) string {
- prefix := dest[:strings.IndexByte(dest, '{')]
+// placeholder (spec 15.1), worked out here by hand rather than by staticDir:
+// the text up to the last '/' before the first '{' that is not "{{", with
+// "{{" and "}}" read as braces, "~" as the home directory, a relative path
+// under root. templated is false when dest has no placeholder, only literal
+// braces: then a ".." in it is the rule's own.
+func textDir(dest, root, home string) (dir string, templated bool) {
+ open := 0
+ for {
+ j := strings.IndexByte(dest[open:], '{')
+ if j < 0 {
+ return "", false
+ }
+ if open+j+1 < len(dest) && dest[open+j+1] == '{' {
+ open += j + 2
+ continue
+ }
+ open += j
+ break
+ }
+ prefix := strings.NewReplacer("{{", "{", "}}", "}").Replace(dest[:open])
switch cut := strings.LastIndexByte(prefix, '/'); {
case cut < 0:
prefix = ""
case cut == 0:
- return "/"
+ return "/", true
default:
prefix = prefix[:cut]
}
switch {
case prefix == "~":
- return home
+ return home, true
case strings.HasPrefix(prefix, "~/"):
- return path.Join(home, prefix[2:])
+ return path.Join(home, prefix[2:]), true
case strings.HasPrefix(prefix, "/"):
- return path.Clean(prefix)
+ return path.Clean(prefix), true
}
- return path.Join(root, prefix)
+ return path.Join(root, prefix), true
}
// underDir reports whether path is dir or lies under it, by whole segments.
diff --git a/internal/plan/hostile_test.go b/internal/plan/hostile_test.go
index a2f2bfe..13c2afd 100644
--- a/internal/plan/hostile_test.go
+++ b/internal/plan/hostile_test.go
@@ -48,6 +48,11 @@ func TestBuildRefusesDotDotFromPlaceholder(t *testing.T) {
if s := step("/w/{1}x"); s.Skip != "" || s.Dst != "/w/..x/x.pdf" {
t.Errorf("..x: Skip = %q, Dst = %q", s.Skip, s.Dst)
}
+ // "{{" is a literal brace, not a placeholder: the text names /w/{x}, and
+ // a capture may not climb out of it (triage 28b).
+ if s := step("/w/{{x}}/{1}/in"); s.Skip != `destination "/w/{x}/../in" leaves /w/{x} through a placeholder` {
+ t.Errorf("literal braces: Skip = %q, Dst = %q", s.Skip, s.Dst)
+ }
}
// TestBuildKeepsPlaceholdersInsideTheirDirectory: a capture of "~", an
diff --git a/internal/plan/placeholder.go b/internal/plan/placeholder.go
index b947236..5922b07 100644
--- a/internal/plan/placeholder.go
+++ b/internal/plan/placeholder.go
@@ -30,6 +30,30 @@ func splitExt(name string) (stem, ext string) {
return name[:i], name[i:]
}
+// StaticPrefix returns the literal text of s before its first placeholder,
+// with "{{" and "}}" read as the braces they stand for, and whether s has a
+// placeholder at all. It scans like Expand, so a literal brace never counts
+// as a placeholder (triage 28b).
+func StaticPrefix(s string) (prefix string, templated bool) {
+ var b strings.Builder
+ for i := 0; i < len(s); {
+ switch {
+ case s[i] == '{' && i+1 < len(s) && s[i+1] == '{':
+ b.WriteByte('{')
+ i += 2
+ case s[i] == '}' && i+1 < len(s) && s[i+1] == '}':
+ b.WriteByte('}')
+ i += 2
+ case s[i] == '{':
+ return b.String(), true
+ default:
+ b.WriteByte(s[i])
+ i++
+ }
+ }
+ return b.String(), false
+}
+
// Expand replaces every placeholder in s. It returns an error naming the
// first placeholder it could not expand. It scans s once: "{{" and "}}"
// each emit one literal brace, and any other "{" opens a placeholder that
diff --git a/internal/plan/testdata/fuzz/FuzzExpand/8d1df2cb4b20cf0b b/internal/plan/testdata/fuzz/FuzzExpand/8d1df2cb4b20cf0b
new file mode 100644
index 0000000..1d02849
--- /dev/null
+++ b/internal/plan/testdata/fuzz/FuzzExpand/8d1df2cb4b20cf0b
@@ -0,0 +1,3 @@
+go test fuzz v1
+string("{{/..")
+string("0")