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}