summaryrefslogtreecommitdiff
path: root/internal/plan/fuzz_test.go
blob: b17d3c4c3aa099a92f00b542852d8a714816c05f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// SPDX-License-Identifier: GPL-3.0-or-later

package plan

import (
	"path"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"krino/internal/config"
	"krino/internal/scan"
	"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")
	}
	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 := textDir(dest, "/r", xdg.Home()); !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
// '{' (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, '{')]
	switch cut := strings.LastIndexByte(prefix, '/'); {
	case cut < 0:
		prefix = ""
	case cut == 0:
		return "/"
	default:
		prefix = prefix[:cut]
	}
	switch {
	case prefix == "~":
		return home
	case strings.HasPrefix(prefix, "~/"):
		return path.Join(home, prefix[2:])
	case strings.HasPrefix(prefix, "/"):
		return path.Clean(prefix)
	}
	return path.Join(root, prefix)
}

// 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+"/")
}