detached 33a243c

containers_spec_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"path"
7	"path/filepath"
8	"strings"
9	"testing"
10
11	"bnema.dev/ermite-actions/expr"
12)
13
14func TestParseVolume(t *testing.T) {
15	for in, want := range map[string]mount{
16		"/data":          {target: "/data"},
17		"vol:/data":      {source: "vol", target: "/data"},
18		"/data:ro":       {target: "/data", readOnly: true},
19		"/h:/c:ro":       {source: "/h", target: "/c", readOnly: true},
20		"/h:/c:rw":       {source: "/h", target: "/c"},
21		"/h:/c:ro:extra": {target: "/h:/c:ro:extra"},
22		"/data:RO":       {target: "/data", readOnly: true},
23	} {
24		if got := parseVolume(in); got != want {
25			t.Errorf("%q: %+v, want %+v", in, got, want)
26		}
27	}
28}
29
30func TestContainerPathTranslation(t *testing.T) {
31	c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
32	c.mounts = []mount{{source: "/h/work", target: "/__w"}}
33	for in, want := range map[string]string{
34		"/h/work": "/__w", "/h/work/_temp/x": "/__w/_temp/x", "/h/workx": "/h/workx", "": "", "/etc": "/etc",
35	} {
36		if got := c.translateToContainer(in); got != want {
37			t.Errorf("%q -> %q, want %q", in, got, want)
38		}
39	}
40	if got := c.translateToHost("/__w/a/b"); got != "/h/work/a/b" {
41		t.Error(got)
42	}
43	// ResolvePathForStepHost translates quoted paths under a mount only.
44	if got := c.resolvePath(`"/h/work/a"`); got != "/__w/a" {
45		t.Error(got)
46	}
47	if got := c.resolvePath("/opt/tc/x"); got != "/opt/tc/x" {
48		t.Errorf("no mount source covers it: %s", got)
49	}
50}
51
52func TestRegistryHost(t *testing.T) {
53	for in, want := range map[string]string{
54		"ubuntu:24.04": "", "owner/img": "", "ghcr.io/owner/img:1": "ghcr.io",
55		"localhost:5000/img": "localhost:5000", "a/b/c": "a",
56	} {
57		if got := parseRegistryHost(in); got != want {
58			t.Errorf("%q: %q want %q", in, got, want)
59		}
60	}
61}
62
63func mustValue(t *testing.T, js string) expr.Value {
64	t.Helper()
65	v, err := expr.FromJSON([]byte(js))
66	if err != nil {
67		t.Fatal(err)
68	}
69	return v
70}
71
72func TestConvertContainerForms(t *testing.T) {
73	j := &job{}
74	v := mustValue(t, `{"image":"docker://ubuntu:24.04","env":{"A":"1","B":2},"ports":[8080,"9000:9001"],"volumes":["v:/d"],"options":"--cpus 1","credentials":{"username":"u","password":"p"}}`)
75	spec, err := j.convertContainer(v, false)
76	if err != nil || spec.image != "ubuntu:24.04" || len(spec.env) != 2 || spec.env[1].Value != "2" ||
77		strings.Join(spec.ports, ",") != "8080,9000:9001" || spec.credPass != "p" || spec.options != "--cpus 1" {
78		t.Fatalf("%+v %v", spec, err)
79	}
80	if s, err := j.convertContainer(mustValue(t, `""`), false); s != nil || err != nil {
81		t.Errorf("empty string: %v %v", s, err)
82	}
83	if s, err := j.convertContainer(mustValue(t, `{"image":""}`), true); s != nil || err != nil {
84		t.Errorf("empty image: %v %v", s, err)
85	}
86	for _, bad := range []string{`{"entrypoint":"x","image":"i"}`, `{"nope":1,"image":"i"}`, `{"image":["a"]}`} {
87		if _, err := j.convertContainer(mustValue(t, bad), false); err == nil {
88			t.Errorf("%s accepted", bad)
89		}
90	}
91	// A service may only set entrypoint and command when the server allows it.
92	if _, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x"}`), true); err == nil {
93		t.Error("command without the feature")
94	}
95	j.baseEnv = []string{"ACTIONS_SERVICE_CONTAINER_COMMAND=true"}
96	if s, err := j.convertContainer(mustValue(t, `{"image":"i","command":"x","entrypoint":"e"}`), true); err != nil || s.command != "x" || s.entrypoint != "e" {
97		t.Errorf("%+v %v", s, err)
98	}
99}
100
101func TestImageReferenceGrammar(t *testing.T) {
102	good := []string{"ubuntu", "ubuntu:24.04", "ghcr.io/o/img:1", "localhost:5000/a/b:tag", "a/b_c.d-e", "redis@sha256:" + strings.Repeat("a", 64),
103		"r.io:443/x/y:1@sha256:" + strings.Repeat("0", 64), "Ubuntu:24.04",
104		"[::1]:5000/x", "[::1]/x/y:tag", "[2001:db8::1]:443/a/b@sha256:" + strings.Repeat("a", 64)}
105	bad := []string{"", "--help", "-x", "-", " a", "a b", "a\nb", "a;b", "a$(x)", "a:", "a:-x", "a@sha256:zz", "/a", "a//b", "a:b:c", "[::1/x", "[]/x", "[zz]/x", "[::1]x", "[::1]:/x", strings.Repeat("a", 1100)}
106	for _, g := range good {
107		if !validImageRef(g) {
108			t.Errorf("rejected %q", g)
109		}
110	}
111	for _, b := range bad {
112		if validImageRef(b) {
113			t.Errorf("accepted %q", b)
114		}
115	}
116	j := &job{}
117	if _, err := j.convertContainer(mustValue(t, `"--help"`), false); err == nil || !strings.Contains(err.Error(), "Invalid container image '--help'") {
118		t.Errorf("container: --help: %v", err)
119	}
120}
121
122func TestServiceNamesAndContainerIDs(t *testing.T) {
123	f := newFakeCtr(t)
124	for _, name := range []string{"-x", ".y", "a b", "a/b"} {
125		rec, res := ctrRun(t, f, "    services:\n      \""+name+"\":\n        image: redis:7\n", "      - run: echo never\n", nil)
126		if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Set up job")), "Invalid service name") {
127			t.Errorf("%q: %+v", name, res)
128		}
129	}
130	for id, ok := range map[string]bool{strings.Repeat("a", 64): true, strings.Repeat("0", 64): true, "Usage: docker create": false, strings.Repeat("a", 63): false, strings.Repeat("A", 64): false, "--help": false} {
131		if containerIDRe.MatchString(id) != ok {
132			t.Errorf("id %q", id)
133		}
134	}
135	// A help text printed by create is not taken for an id: nothing is started.
136	f.set(t, "create_out", "Usage:  docker create [OPTIONS] IMAGE\n")
137	rec, res := ctrRun(t, f, "    container: ubuntu:24.04\n", "      - run: echo never\n", nil)
138	if res.Conclusion != ConclusionFailure || !strings.Contains(logOf(rec.step(t, "Initialize containers")), "##[error]Docker create did not return a container id: 'Usage:  docker create [OPTIONS] IMAGE'") {
139		t.Errorf("%+v\n%s", res, logOf(rec.step(t, "Initialize containers")))
140	}
141	for _, c := range f.calls(t) {
142		if c[0] == "start" {
143			t.Errorf("started %v", c)
144		}
145	}
146}
147
148func TestTranslateToHostDoesNotEscapeTheMapping(t *testing.T) {
149	c := &container{maps: []pathMap{{"/h/work", "/__w"}, {"/opt/tc", "/__t"}}}
150	for in, want := range map[string]string{
151		"/__w/a/b":            "/h/work/a/b",
152		"/__w/a/../b":         "/h/work/b",
153		"/__w/../outside/x":   "/outside/x",
154		"/__w/a/../../../etc": "/etc",
155		"/__w//a///b/":        "/h/work/a/b",
156		"/__w":                "/h/work",
157		"/__t/../__w/x":       "/h/work/x",
158		"/__wx/y":             "/__wx/y",
159		"rel/../x":            "rel/../x",
160	} {
161		got := c.translateToHost(in)
162		if got != want {
163			t.Errorf("%q -> %q, want %q", in, got, want)
164		}
165		if strings.HasPrefix(in, "/__w") && !strings.HasPrefix(got, "/h/work") && got != path.Clean(in) {
166			t.Errorf("%q escaped to %q", in, got)
167		}
168	}
169}
170
171// A matcher file named by a command of the container is read only when it is
172// in a directory shared with the host: ".." cannot reach another host file.
173func TestMatcherPathFromTheContainerStaysInside(t *testing.T) {
174	f := newFakeCtr(t)
175	var outside string
176	rec, res := ctrRun(t, f, ctrJob, "      - name: s\n        run: echo\n", func(c *Config) {
177		outside = filepath.Join(filepath.Dir(c.Layout.WorkRoot), "outside")
178		writeFiles(t, filepath.Dir(c.Layout.WorkRoot), files{"outside/m.json": `{"problemMatcher":[{"owner":"leak","pattern":[{"regexp":"x"}]}]}`})
179		f.set(t, "exec_out", "::add-matcher::/__w/../outside/m.json\n::add-matcher::"+outside+"/m.json\n::add-matcher::/etc/hostname\n")
180	})
181	if res.Conclusion != ConclusionFailure {
182		t.Fatalf("%+v", res)
183	}
184	log := logOf(rec.step(t, "s"))
185	if strings.Contains(log, "leak") || strings.Count(log, "##[error]") < 2 || !strings.Contains(log, "Could not find file") && !strings.Contains(log, "ould not find") {
186		t.Errorf("log:\n%s", log)
187	}
188}

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