// SPDX-License-Identifier: GPL-3.0-or-later package plan import ( "path" "path/filepath" "strings" "testing" "time" "git.labunix.xyz/krino/internal/config" "git.labunix.xyz/krino/internal/scan" "git.labunix.xyz/krino/internal/xdg" ) // 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), checked against textDir, not the // planner's own staticDir. 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") } // Seeds that try to leave the destination, so plain go test checks // containment too: a ".." capture, a "~" name, one below a literal. f.Add("{2}", "a.pdf") f.Add("Out/{2}/{1}", "a.pdf") f.Add("{name}/x", "~") f.Fuzz(func(t *testing.T, tmpl, name string) { facts := Facts{ Name: name, Captures: []string{name, "x", ".."}, ModTime: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC), Now: time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC), } a, errA := Expand(tmpl, facts) b, errB := Expand(tmpl, facts) if a != b || (errA == nil) != (errB == nil) { t.Fatalf("Expand(%q) differs between two calls: %q/%v, %q/%v", tmpl, a, errA, b, errB) } 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) } if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\x00") { return // not a name a directory entry can have } for _, dest := range []string{tmpl, "Out/" + tmpl, "~/docs/" + tmpl} { if !strings.ContainsRune(dest, '{') { continue } got := plannedDir(dest, name, facts.Captures, facts) if got == "" { continue } 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) } } }) } // plannedDir builds a move of a file named name to dest with captures and // returns the directory the step would put it in, or "" when the step is // skipped. func plannedDir(dest, name string, captures []string, facts Facts) string { in := []Input{{ File: scan.File{Path: "/r/" + name, Rel: name, Name: name, ModTime: facts.ModTime}, Rules: []RuleMatch{{Name: "a", Captures: captures, Actions: []config.Action{{Kind: config.Move, Arg: dest}}}}, }} s := Build("/r", in, facts.Now, NoDisk{}, NewClaims())[0].Steps[0] if s.Skip != "" { return "" } return filepath.Dir(s.Dst) } // textDir is the directory a destination's text names before its first // 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 "/", true default: prefix = prefix[:cut] } switch { case prefix == "~": return home, true case strings.HasPrefix(prefix, "~/"): return path.Join(home, prefix[2:]), true case strings.HasPrefix(prefix, "/"): return path.Clean(prefix), true } return path.Join(root, prefix), true } // underDir reports whether path is dir or lies under it, by whole segments. func underDir(path, dir string) bool { return dir == "/" || path == dir || strings.HasPrefix(path, dir+"/") }