v0.6.0 aba298d

hashfiles_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close