1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "syscall"
11 "testing"
12 "time"
13)
14
15func writeTree(t *testing.T, root string, files map[string]string) {
16 t.Helper()
17 for name, body := range files {
18 p := filepath.Join(root, filepath.FromSlash(name))
19 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
20 t.Fatal(err)
21 }
22 if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
23 t.Fatal(err)
24 }
25 }
26}
27
28// Values recorded on github.com (expr-hashfiles), for the files the scenario
29// creates: alpha, beta and gamma.
30func TestHashFilesRecordedValues(t *testing.T) {
31 ws := t.TempDir()
32 writeTree(t, ws, map[string]string{"hf/a.txt": "alpha\n", "hf/b.txt": "beta\n", "hf/sub/c.md": "gamma\n"})
33 for _, tc := range []struct {
34 patterns []string
35 want string
36 }{
37 {[]string{"hf/a.txt"}, "4bb706b95c7ea23f44bc5d035ad8841af479871295d2ae0c685d07174705c880"},
38 {[]string{"hf/a.txt", "hf/b.txt"}, "24d116e0411b3a4a8d3d5c9c88c150bc4d4603a490294bd4b23d3ef549e1f1a0"},
39 {[]string{"hf/b.txt", "hf/a.txt"}, "a3013a35661fd0340c6651bfd9b89fd7c86e916011c7e9cfecfb835d90ec7505"},
40 {[]string{"hf/*.txt"}, "24d116e0411b3a4a8d3d5c9c88c150bc4d4603a490294bd4b23d3ef549e1f1a0"},
41 {[]string{"hf/**"}, "58d680606dbb1f9fab754d2d071e528858b6ca169b64be479ae9557d72c18d77"},
42 {[]string{"hf/**/*.md"}, "b9f1217dc10c76d1ab32754b4df630a29025a4a878aeddd113fda7045f9faf46"},
43 {[]string{"hf/**", "!hf/sub/**"}, "24d116e0411b3a4a8d3d5c9c88c150bc4d4603a490294bd4b23d3ef549e1f1a0"},
44 {[]string{"hf/nothing-*"}, ""},
45 } {
46 got, err := hashFilesIn(t.Context(), ws, "/home/x", tc.patterns, false)
47 if err != nil || got != tc.want {
48 t.Errorf("%q = %q (%v), want %q", tc.patterns, got, err, tc.want)
49 }
50 }
51 // "before files exist": the directory is not there yet.
52 got, err := hashFilesIn(t.Context(), t.TempDir(), "/home/x", []string{"hf/**"}, false)
53 if err != nil || got != "" {
54 t.Errorf("empty workspace: %q %v", got, err)
55 }
56}
57
58// The scenario end to end, in expressions, against the recording.
59func TestCorpusHashFiles(t *testing.T) {
60 compareWithRecording(t, "expr-hashfiles")
61}
62
63// Differential test: the official script (Misc/expressionFunc/hashFiles, run by
64// node) and this port must agree. Opt in with ERMITE_HASHFILES_JS=<path of the
65// script, Misc/layoutbin/hashFiles/index.js of actions/runner>.
66func TestHashFilesMatchesOfficialScript(t *testing.T) {
67 script := os.Getenv("ERMITE_HASHFILES_JS")
68 if script == "" {
69 t.Skip("set ERMITE_HASHFILES_JS to Misc/layoutbin/hashFiles/index.js of actions/runner")
70 }
71 needTools(t, "node")
72 ws := t.TempDir()
73 writeTree(t, ws, map[string]string{
74 "a.txt": "a", "b.go": "b", ".hidden/x": "h", ".dotfile": "d", "src/main.go": "m", "src/util/u.go": "u",
75 "src/util/u_test.go": "t", "src/Zeta.go": "z", "src/alpha.GO": "A", "docs/readme.md": "r", "docs/img/p.png": "p",
76 "node_modules/pkg/index.js": "n", "weird [name]/f.txt": "w", "a b/c d.txt": "s", "go.mod": "g", "go.sum": "s2",
77 "deep/1/2/3/4/f": "deep", "ünï/çode.txt": "u",
78 })
79 if err := os.Symlink("src", filepath.Join(ws, "link")); err != nil {
80 t.Fatal(err)
81 }
82 if err := os.Symlink("missing", filepath.Join(ws, "broken")); err != nil {
83 t.Fatal(err)
84 }
85 for _, patterns := range [][]string{
86 {"**"}, {"*"}, {"**/*.go"}, {"**/*.go", "!**/*_test.go"}, {"src/**", "!src/util/**"}, {"**/go.sum", "**/go.mod"},
87 {"go.mod", "go.sum"}, {"src/*.go"}, {"src/?lpha.GO"}, {"src/[Zz]eta.go"}, {"**/*.{go,md}"}, {"docs"}, {"docs/"}, {"docs/*"},
88 {"./src/main.go"}, {"**/.hidden/**"}, {".*"}, {"**/node_modules/**", "src/**"}, {"!src/**", "**/*.go"}, {"nothing"}, {"**/*.md", "# a comment", ""},
89 {"weird [[]name]/f.txt"}, {"weird*/*"}, {"a b/*"}, {"src/**/", "docs/**"}, {"ünï/*"}, {"deep/**/f"}, {"**/f"}, {"link/**"}, {"link/main.go"},
90 {"broken"}, {"**/*.GO"}, {"src/util/../main.go"}, {"/etc/hostname"}, {"src\\main.go"}, {"**/u*"}, {"*/*/*"}, {"src/**/*", "!src/util"},
91 } {
92 for _, follow := range []bool{false, true} {
93 want := officialHash(t, script, ws, patterns, follow)
94 got, err := hashFilesIn(t.Context(), ws, "/home/x", patterns, follow)
95 if want.failed {
96 if err == nil {
97 t.Errorf("%q follow=%v: the script fails (%s), got %q", patterns, follow, want.msg, got)
98 }
99 continue
100 }
101 if err != nil || got != want.sum {
102 t.Errorf("%q follow=%v: got %q (%v), script %q", patterns, follow, got, err, want.sum)
103 }
104 }
105 }
106}
107
108// A symbolic link that leaves the workspace is not followed, whatever the
109// script does: the executor may run as root and the workspace is the user's.
110func TestHashFilesDoesNotLeaveTheWorkspace(t *testing.T) {
111 outside := t.TempDir()
112 writeTree(t, outside, map[string]string{"secret": "s"})
113 ws := t.TempDir()
114 writeTree(t, ws, map[string]string{"a.txt": "alpha\n"})
115 if err := os.Symlink(filepath.Join(outside, "secret"), filepath.Join(ws, "leak")); err != nil {
116 t.Fatal(err)
117 }
118 if err := os.Symlink(outside, filepath.Join(ws, "leakdir")); err != nil {
119 t.Fatal(err)
120 }
121 only, _ := hashFilesIn(t.Context(), ws, "/h", []string{"a.txt"}, false)
122 for _, follow := range []bool{false, true} {
123 got, err := hashFilesIn(t.Context(), ws, "/h", []string{"**"}, follow)
124 if err != nil || got != only {
125 t.Errorf("follow=%v: %q (%v), want the hash of a.txt alone %q", follow, got, err, only)
126 }
127 }
128}
129
130// A matched symbolic link without a target fails the call, like the script.
131func TestHashFilesBrokenLinkFails(t *testing.T) {
132 ws := t.TempDir()
133 if err := os.Symlink("missing", filepath.Join(ws, "broken")); err != nil {
134 t.Fatal(err)
135 }
136 if _, err := hashFilesIn(t.Context(), ws, "/h", []string{"**"}, false); err == nil {
137 t.Error("want an error")
138 }
139 if got, err := hashFilesIn(t.Context(), ws, "/h", []string{"**"}, true); err != nil || got != "" {
140 t.Errorf("followed, the link is omitted: %q %v", got, err)
141 }
142}
143
144// A FIFO in the workspace must not hang the function: opening it for reading
145// blocks until a writer comes. It is skipped, directly or through a symbolic
146// link, with or without --follow-symbolic-links.
147func TestHashFilesSkipsFIFOs(t *testing.T) {
148 ws := t.TempDir()
149 writeTree(t, ws, map[string]string{"a.txt": "alpha\n"})
150 if err := syscall.Mkfifo(filepath.Join(ws, "pipe"), 0o644); err != nil {
151 t.Skipf("no FIFO here: %v", err)
152 }
153 if err := os.Symlink("pipe", filepath.Join(ws, "pipelink")); err != nil {
154 t.Fatal(err)
155 }
156 only, err := hashFilesIn(t.Context(), ws, "/h", []string{"a.txt"}, false)
157 if err != nil || only == "" {
158 t.Fatalf("%q %v", only, err)
159 }
160 for _, tc := range []struct {
161 patterns []string
162 follow bool
163 }{
164 {[]string{"**"}, false}, {[]string{"**"}, true}, {[]string{"pipe"}, false}, {[]string{"pipe"}, true},
165 {[]string{"pipelink"}, false}, {[]string{"pipelink"}, true},
166 } {
167 done := make(chan struct{})
168 var got string
169 var err error
170 go func() {
171 defer close(done)
172 got, err = hashFilesIn(t.Context(), ws, "/h", tc.patterns, tc.follow)
173 }()
174 select {
175 case <-done:
176 case <-time.After(5 * time.Second):
177 t.Fatalf("%q follow=%v: hangs on a FIFO", tc.patterns, tc.follow)
178 }
179 if err != nil {
180 t.Errorf("%q follow=%v: %v", tc.patterns, tc.follow, err)
181 }
182 if tc.patterns[0] == "**" && got != only {
183 t.Errorf("%q follow=%v: %q, want a.txt alone %q", tc.patterns, tc.follow, got, only)
184 }
185 if tc.patterns[0] != "**" && got != "" {
186 t.Errorf("%q follow=%v: %q, want no file", tc.patterns, tc.follow, got)
187 }
188 }
189}
190
191type officialResult struct {
192 sum string
193 failed bool
194 msg string
195}
196
197func officialHash(t *testing.T, script, ws string, patterns []string, follow bool) officialResult {
198 t.Helper()
199 cmd := exec.Command("node", script)
200 cmd.Dir = ws
201 cmd.Env = append(os.Environ(), "patterns="+strings.Join(patterns, "\n"))
202 if follow {
203 cmd.Env = append(cmd.Env, "followSymbolicLinks=true")
204 }
205 out, err := cmd.CombinedOutput()
206 if err != nil {
207 return officialResult{failed: true, msg: string(out)}
208 }
209 s := string(out)
210 i := strings.Index(s, "__OUTPUT__")
211 j := strings.LastIndex(s, "__OUTPUT__")
212 if i < 0 || j <= i {
213 t.Fatalf("no output: %s", s)
214 }
215 return officialResult{sum: s[i+10 : j]}
216}