1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12
13 "github.com/bnema/zut/packages/provider"
14)
15
16func realASTTool(t *testing.T, root string) *ASTTool {
17 t.Helper()
18 binary, err := exec.LookPath("ast-grep")
19 if err != nil {
20 t.Skip("ast-grep not installed")
21 }
22 return &ASTTool{CWD: root, LookPath: func(string) (string, error) { return binary, nil }}
23}
24
25func astText(t *testing.T, tool *ASTTool, raw string) string {
26 t.Helper()
27 result, err := tool.Execute(context.Background(), json.RawMessage(raw), nil)
28 if err != nil {
29 t.Fatalf("Execute(%s): %v", raw, err)
30 }
31 return result.Content[0].(provider.TextBlock).Text
32}
33
34func TestASTLanguageIsOptional(t *testing.T) {
35 root := t.TempDir()
36 if err := os.WriteFile(filepath.Join(root, "a.py"), []byte("print(1)\n"), 0o644); err != nil {
37 t.Fatal(err)
38 }
39 if err := os.WriteFile(filepath.Join(root, "b.ts"), []byte("console.log(2)\n"), 0o644); err != nil {
40 t.Fatal(err)
41 }
42 tool := realASTTool(t, root)
43 if got := astText(t, tool, `{"pattern":"print($A)","path":"."}`); !strings.Contains(got, "a.py:1") {
44 t.Fatalf("python search = %q", got)
45 }
46 if got := astText(t, tool, `{"pattern":"console.log($$$)","path":"."}`); !strings.Contains(got, "b.ts:1") {
47 t.Fatalf("typescript search = %q", got)
48 }
49}
50
51// tree-sitter-go parses a bare `pkg.Func(args)` pattern as a type
52// conversion, so without the retry these calls never match.
53func TestASTGoQualifiedCallMatchesCalls(t *testing.T) {
54 root := t.TempDir()
55 path := filepath.Join(root, "main.go")
56 source := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\t_ = fmt.Errorf(\"a\")\n\t_ = fmt.Errorf(\"b %d\",\n\t\t1)\n}\n"
57 if err := os.WriteFile(path, []byte(source), 0o644); err != nil {
58 t.Fatal(err)
59 }
60 tool := realASTTool(t, root)
61 for _, raw := range []string{
62 `{"pattern":"fmt.Errorf($$$ARGS)","language":"go","path":"."}`,
63 `{"pattern":"fmt.Errorf($$$ARGS)","path":"."}`,
64 `{"pattern":"fmt.Errorf($$$ARGS)","path":"main.go"}`,
65 } {
66 got := astText(t, tool, raw)
67 if !strings.Contains(got, "main.go:6") || !strings.Contains(got, "main.go:7") {
68 t.Fatalf("search %s = %q", raw, got)
69 }
70 }
71 astText(t, tool, `{"pattern":"fmt.Errorf($$$ARGS)","path":".","rewrite":"errors.New($$$ARGS)"}`)
72 got, _ := os.ReadFile(path)
73 if strings.Contains(string(got), "fmt.Errorf") || strings.Count(string(got), "errors.New(") != 2 {
74 t.Fatalf("rewrite = %q", got)
75 }
76}
77
78func TestASTRewriteWritesThroughSymlink(t *testing.T) {
79 root := t.TempDir()
80 target := filepath.Join(root, "real.go")
81 link := filepath.Join(root, "link.go")
82 if err := os.WriteFile(target, []byte("old"), 0o644); err != nil {
83 t.Fatal(err)
84 }
85 if err := os.Symlink(target, link); err != nil {
86 t.Skip("symlinks unavailable:", err)
87 }
88 if err := (&ASTTool{}).applyRewrite([]astChange{{path: link, original: []byte("old"), content: []byte("new"), mode: 0o644}}); err != nil {
89 t.Fatal(err)
90 }
91 if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
92 t.Fatalf("link replaced: %v, %v", info, err)
93 }
94 if got, _ := os.ReadFile(target); string(got) != "new" {
95 t.Fatalf("target = %q", got)
96 }
97}
98
99func TestASTApplyRewriteRejectsSymlinkRetargetedOutsideJail(t *testing.T) {
100 root := t.TempDir()
101 outside := filepath.Join(t.TempDir(), "outside.go")
102 link := filepath.Join(root, "link.go")
103 if err := os.WriteFile(outside, []byte("old"), 0o644); err != nil {
104 t.Fatal(err)
105 }
106 // The plan was made for an in-jail file, but the link now escapes.
107 if err := os.Symlink(outside, link); err != nil {
108 t.Skip("symlinks unavailable:", err)
109 }
110 sandbox := NewSandbox(root)
111 sandbox.Lock()
112 err := (&ASTTool{Sandbox: sandbox}).applyRewrite([]astChange{{path: link, original: []byte("old"), content: []byte("new"), mode: 0o644}})
113 if err == nil {
114 t.Fatal("rewrite through escaping symlink succeeded")
115 }
116 if got, _ := os.ReadFile(outside); string(got) != "old" {
117 t.Fatalf("outside file = %q", got)
118 }
119}
120
121func TestASTGoCallRetryFailureIsReported(t *testing.T) {
122 if runtime.GOOS == "windows" {
123 t.Skip("test fixture uses a POSIX script")
124 }
125 root := t.TempDir()
126 // `run` finds nothing (exit 1); the `scan` retry fails with an error.
127 script := filepath.Join(root, "ast-grep")
128 body := "#!/bin/sh\nif [ \"$1\" = scan ]; then echo 'Error: invalid rule' >&2; exit 8; fi\nexit 1\n"
129 if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
130 t.Fatal(err)
131 }
132 tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return script, nil }}
133 _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"fmt.Errorf($$$)","language":"go","path":"."}`), nil)
134 if err == nil || !strings.Contains(err.Error(), "invalid rule") {
135 t.Fatalf("Execute error = %v", err)
136 }
137}
138
139func TestASTNoMatchIncludesPatternTip(t *testing.T) {
140 root := t.TempDir()
141 if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil {
142 t.Fatal(err)
143 }
144 got := astText(t, realASTTool(t, root), `{"pattern":"missing($A)","language":"go","path":"."}`)
145 if !strings.Contains(got, "No structural matches.") || !strings.Contains(got, "$$$A matches zero or more") {
146 t.Fatalf("no-match text = %q", got)
147 }
148}
149
150func TestASTGoCallPatternDetection(t *testing.T) {
151 for _, tc := range []struct {
152 args astArgs
153 root string
154 want bool
155 }{
156 {astArgs{Pattern: "fmt.Errorf($$$)", Language: "go"}, "/src", true},
157 {astArgs{Pattern: "fmt.Errorf($$$)"}, "/src", true},
158 {astArgs{Pattern: "fmt.Errorf($$$)"}, "/src/main.go", true},
159 {astArgs{Pattern: "fmt.Errorf($$$)"}, "/src/app.ts", false},
160 {astArgs{Pattern: "console.log($$$)", Language: "typescript"}, "/src", false},
161 {astArgs{Pattern: "foo($A)", Language: "go"}, "/src", false},
162 {astArgs{Pattern: "if err != nil { $$$ }", Language: "go"}, "/src", false},
163 } {
164 if got := astGoCallPattern(tc.args, tc.root); got != tc.want {
165 t.Errorf("astGoCallPattern(%+v, %q) = %v, want %v", tc.args, tc.root, got, tc.want)
166 }
167 }
168}
169
170func TestASTSchemaAndDescriptionTeachUsage(t *testing.T) {
171 tool := &ASTTool{}
172 var schema struct {
173 Required []string `json:"required"`
174 }
175 if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
176 t.Fatal(err)
177 }
178 if strings.Join(schema.Required, ",") != "pattern,path" {
179 t.Fatalf("required = %v", schema.Required)
180 }
181 for _, want := range []string{"$$$", "rewrite", "grep"} {
182 if !strings.Contains(tool.Description(), want) {
183 t.Fatalf("description missing %q", want)
184 }
185 }
186}