diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 23:45:23 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-14 23:45:23 +0200 |
| commit | 97c8164bc81e0a438dab92d7244485005dac4ff2 (patch) | |
| tree | 08d657f8b92a6539259b6aa8f08b0e9ef8cc6710 /internal | |
| parent | 166565025832c8a8faf7397766aeaa878980dd2d (diff) | |
| download | krino-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.go | 5 | ||||
| -rw-r--r-- | internal/engine/match_test.go | 2 | ||||
| -rw-r--r-- | internal/plan/chain.go | 18 | ||||
| -rw-r--r-- | internal/plan/fuzz_test.go | 37 | ||||
| -rw-r--r-- | internal/plan/hostile_test.go | 5 | ||||
| -rw-r--r-- | internal/plan/placeholder.go | 24 | ||||
| -rw-r--r-- | internal/plan/testdata/fuzz/FuzzExpand/8d1df2cb4b20cf0b | 3 |
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") |
