1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "strings"
10 "testing"
11 "time"
12
13 "connectrpc.com/connect"
14 "github.com/bnema/zerowrap"
15
16 ermite "bnema.dev/ermite-api/gen/ermite"
17
18 "bnema.dev/ermite-runner/internal/execstream/execpb"
19)
20
21type resolveStub struct {
22 stubClient
23 got *ermite.ResolveActionsRequest
24 resp *ermite.ResolveActionsResponse
25 err error
26 fn func(*ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse
27 onCall func(*ermite.ResolveActionsRequest)
28 calls int
29}
30
31func (c *resolveStub) ResolveActions(_ context.Context, r *connect.Request[ermite.ResolveActionsRequest]) (*connect.Response[ermite.ResolveActionsResponse], error) {
32 c.got = r.Msg
33 c.calls++
34 if c.onCall != nil {
35 c.onCall(r.Msg)
36 }
37 if c.err != nil {
38 return nil, c.err
39 }
40 if c.fn != nil {
41 return connect.NewResponse(c.fn(r.Msg)), nil
42 }
43 return connect.NewResponse(c.resp), nil
44}
45
46func resolveJob(c jobClient) *jobRun {
47 j := &jobRun{client: c, ctx: context.Background(), job: &ermite.Job{JobId: "j1"}, log: zerowrap.Logger{},
48 t: Timing{CallTimeout: time.Second, BackoffMin: time.Millisecond, BackoffMax: time.Millisecond}}
49 j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://ermite.example", "")
50 return j
51}
52
53func TestResolveActions(t *testing.T) {
54 c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{
55 {Ref: "a/b@v1", NameWithOwner: "a/b", ResolvedSha: strings.Repeat("a", 40), TarballUrl: "https://ermite.example/t", Token: "tok", Source: ermite.ActionSource_ACTION_SOURCE_ERMITE},
56 {Ref: "a/c@v1", Error: "boom"},
57 }}}
58 resp := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Id: 7, Uses: []string{"a/b@v1", "a/c@v1"}})
59 if resp.Error != "" || len(resp.Actions) != 2 {
60 t.Fatalf("%+v", resp)
61 }
62 if c.got.JobId != "j1" || len(c.got.Refs) != 2 || c.got.Refs[1].Uses != "a/c@v1" {
63 t.Errorf("request %+v", c.got)
64 }
65 if a := resp.Actions[0]; a.Token != "tok" || a.AuthOrigin != "https://ermite.example" || a.TarballUrl != "https://ermite.example/t" || a.NameWithOwner != "a/b" {
66 t.Errorf("%+v", a)
67 }
68 if resp.Actions[1].Error != "boom" {
69 t.Errorf("%+v", resp.Actions[1])
70 }
71}
72
73func TestResolveActionsFailures(t *testing.T) {
74 c := &resolveStub{err: connect.NewError(connect.CodeInvalidArgument, nil)}
75 if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); !strings.Contains(r.Error, "invalid_argument") {
76 t.Errorf("%+v", r)
77 }
78 c = &resolveStub{resp: &ermite.ResolveActionsResponse{}}
79 if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1"}}); r.Error == "" {
80 t.Errorf("a short answer must fail: %+v", r)
81 }
82 many := make([]string, maxResolveRefs+1)
83 if r := resolveJob(c).resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many}); r.Error == "" {
84 t.Error("more than 100 refs must fail")
85 }
86}
87
88func TestSetGitHubToken(t *testing.T) {
89 cases := map[string]string{
90 `{"github":{"job":"j","token":""},"needs":{}}`: `{"github":{"job":"j","token":"T"},"needs":{}}`,
91 `{"github":{"job":"j"}}`: `{"github":{"token":"T","job":"j"}}`,
92 `{"needs":{}}`: `{"needs":{},"github":{"token":"T"}}`,
93 }
94 for in, want := range cases {
95 got, err := SetGitHubToken(in, "T")
96 if err != nil || got != want {
97 t.Errorf("%s: %s %v, want %s", in, got, err, want)
98 }
99 }
100 if got, _ := SetGitHubToken(`{"a":1}`, ""); got != `{"a":1}` {
101 t.Errorf("empty token changed the contexts: %s", got)
102 }
103 if _, err := SetGitHubToken(`[]`, "T"); err == nil {
104 t.Error("not an object")
105 }
106 var m map[string]any
107 if got, _ := SetGitHubToken(`{}`, `a"b\`); json.Unmarshal([]byte(got), &m) != nil {
108 t.Errorf("invalid JSON: %s", got)
109 }
110}
111
112func TestCheckUses(t *testing.T) {
113 long := func(n int) string { return strings.Repeat("a", n) }
114 for u, ok := range map[string]bool{
115 "actions/checkout@v4": true, "o/r/sub/dir@refs/heads/feat/x": true, "o/r@" + strings.Repeat("a", 40): true, "o/r.go_x-y@v1.2.3": true,
116 // GO-011: the path of the action is a path, not a repository name.
117 "o/r/action+tools@v1": true, "o/r/déploiement/日本語@v1": true, "o/r/with space/x@v1": true, "o/r/a(b)[c]{d}~e#f%g@v1": true,
118 "o/r/a\ufffdb@v1": true, "o/r/\ufffd@v1": true, // a literal U+FFFD is a valid character
119 "o/r/" + long(255) + "@v1": true, "o/r/.github/actions/x@v1": true, "o/r/.hidden@v1": true, "o/r/a..b@v1": true,
120 "": false, "o/r": false, "o@v1": false, "o/r@": false, "/r@v1": false, "o//r@v1": false, "o/../r@v1": false, "o/r/..@v1": false,
121 "o/r/a/../b@v1": false, "o/r/./a@v1": false, "o/r/a//b@v1": false, "o/r/a/@v1": false,
122 "o/r/a\\b@v1": false, "o/r/a\x00b@v1": false, "o/r/a\nb@v1": false, "o/r/a\x7fb@v1": false, "o/r/\xff@v1": false, "o/r/a\xc3(b@v1": false, "o/r/\xed\xa0\x80@v1": false,
123 "o/r/" + long(256) + "@v1": false, long(101) + "/r@v1": false, "o/" + long(101) + "@v1": false,
124 "o/r@" + long(256): false, "o/r/" + strings.Repeat("a/", 600) + "x@v1": false,
125 "o/r@v1 x": false, "o/r@v1\n": false, "o/r@v~1": false, "o/r@v:1": false, "o /r@v1": false, "o/r@v1@v2": false,
126 "o/r\x00@v1": false, "ö/r@v1": false, "o/r+x@v1": false, "o/r:x@v1": false,
127 } {
128 if err := checkUses(u); (err == nil) != ok {
129 t.Errorf("%q: %v, want ok=%v", cut(u), err, ok)
130 }
131 }
132}
133
134// GO-003: whatever the sandbox sends, the server is called with valid
135// references only, and with a bounded number of calls and references.
136func TestResolveActionsRejectsAndBudgets(t *testing.T) {
137 c := &resolveStub{resp: &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: "a/b@v1"}}}}
138 j := resolveJob(c)
139 for _, uses := range [][]string{nil, {}, {""}, {"nonsense"}, {"a/b@v1", "bad ref"}} {
140 c.got = nil
141 if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: uses}); r.Error == "" || c.got != nil {
142 t.Errorf("%q: error %q, called=%v", uses, r.Error, c.got != nil)
143 }
144 }
145 // Distinct references, one per request: the rounds are the limit.
146 c.resp = nil
147 j = resolveJob(c)
148 c.fn = func(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse { return echo(r) }
149 var calls int
150 for i := 0; i < maxResolveRounds+20; i++ {
151 c.got = nil
152 r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{fmt.Sprintf("a/b%d@v1", i)}})
153 if c.got != nil {
154 calls++
155 }
156 if i >= maxResolveRounds && r.Error == "" {
157 t.Fatalf("round %d is over the budget and was served", i)
158 }
159 }
160 if calls != maxResolveRounds {
161 t.Errorf("%d calls, the budget is %d", calls, maxResolveRounds)
162 }
163 // Distinct references in big requests: the references are the limit.
164 j = resolveJob(c)
165 served := 0
166 for i := 0; i < 40; i++ {
167 big := make([]string, maxResolveRefs)
168 for k := range big {
169 big[k] = fmt.Sprintf("a/b%d_%d@v1", i, k)
170 }
171 if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: big}); r.Error == "" {
172 served++
173 }
174 }
175 if served != maxResolveRefsTotal/maxResolveRefs {
176 t.Errorf("served %d requests of %d refs, the budget is %d refs", served, maxResolveRefs, maxResolveRefsTotal)
177 }
178}
179
180func echo(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse {
181 out := &ermite.ResolveActionsResponse{}
182 for _, ref := range r.Refs {
183 out.Actions = append(out.Actions, &ermite.ResolvedAction{Ref: ref.Uses, NameWithOwner: "a/b"})
184 }
185 return out
186}
187
188// GO-010: a resolution is cached for the job, so repeated invocations of a local
189// composite action cost nothing, and a big request is split into calls.
190func TestResolveActionsCacheAndBatches(t *testing.T) {
191 c := &resolveStub{}
192 c.fn = echo
193 j := resolveJob(c)
194 for i := 0; i < 500; i++ {
195 r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1", "a/c/sub@v2"}})
196 if r.Error != "" || len(r.Actions) != 2 || r.Actions[1].Ref != "a/c/sub@v2" {
197 t.Fatalf("invocation %d: %+v", i, r)
198 }
199 }
200 if c.calls != 1 {
201 t.Errorf("%d calls for 500 invocations of the same actions", c.calls)
202 }
203 // A new action goes through; only it is asked.
204 c.got = nil
205 if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/b@v1", "a/new@v1"}}); r.Error != "" || len(c.got.Refs) != 1 || c.got.Refs[0].Uses != "a/new@v1" {
206 t.Errorf("%+v %v", r, c.got)
207 }
208 // Duplicates in one request are asked once, answered in order.
209 j = resolveJob(c)
210 c.calls = 0
211 r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/x@v1", "a/y@v1", "a/x@v1"}})
212 if r.Error != "" || len(r.Actions) != 3 || r.Actions[2].Ref != "a/x@v1" || r.Actions[1].Ref != "a/y@v1" || c.calls != 1 || len(c.got.Refs) != 2 {
213 t.Errorf("%+v calls %d", r, c.calls)
214 }
215 // 250 distinct references: three calls of at most 100, in order.
216 j = resolveJob(c)
217 c.calls = 0
218 var sizes []int
219 c.onCall = func(r *ermite.ResolveActionsRequest) { sizes = append(sizes, len(r.Refs)) }
220 many := make([]string, 250)
221 for i := range many {
222 many[i] = fmt.Sprintf("a/b%d@v1", i)
223 }
224 r = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: many})
225 if r.Error != "" || len(r.Actions) != 250 || r.Actions[249].Ref != many[249] || fmt.Sprint(sizes) != "[100 100 50]" {
226 t.Errorf("%q calls %v", r.Error, sizes)
227 }
228 // An error of one action is not cached.
229 c.fn = func(r *ermite.ResolveActionsRequest) *ermite.ResolveActionsResponse {
230 return &ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Ref: r.Refs[0].Uses, Error: "not found"}}}
231 }
232 j = resolveJob(c)
233 c.calls = 0
234 _ = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/gone@v1"}})
235 _ = j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: []string{"a/gone@v1"}})
236 if c.calls != 2 {
237 t.Errorf("an error was cached: %d calls", c.calls)
238 }
239 // More than the request limit.
240 if r := j.resolveActions(context.Background(), &execpb.ResolveRequest{Uses: make([]string, maxRequestRefs+1)}); r.Error == "" {
241 t.Error("a request over the limit was served")
242 }
243}
244
245// GO-002 on the host side: the token only goes with an ermite archive whose URL
246// is on an origin of ermite; with guest forge access the URL is rewritten.
247func TestResolveResponseTokenOrigin(t *testing.T) {
248 j := resolveJob(&resolveStub{})
249 ermiteSrc, ghSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE, ermite.ActionSource_ACTION_SOURCE_GITHUB
250 conv := func(j *jobRun, src ermite.ActionSource, url, tok string) *execpb.ResolvedAction {
251 r := j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: src, TarballUrl: url, Token: tok}}}, 1)
252 return r.Actions[0]
253 }
254 if a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t"); a.Token != "t" || a.AuthOrigin != "https://ermite.example" {
255 t.Errorf("%+v", a)
256 }
257 for name, a := range map[string]*execpb.ResolvedAction{
258 "github source": conv(j, ghSrc, "https://ermite.example/x", "t"),
259 "foreign origin": conv(j, ermiteSrc, "https://evil.example/x", "t"),
260 "subdomain": conv(j, ermiteSrc, "https://x.ermite.example/x", "t"),
261 "other port": conv(j, ermiteSrc, "https://ermite.example:8443/x", "t"),
262 "other scheme": conv(j, ermiteSrc, "http://ermite.example/x", "t"),
263 "not a url": conv(j, ermiteSrc, "ermite.example/x", "t"),
264 "credentials in it": conv(j, ermiteSrc, "https://u:p@ermite.example/x", "t"),
265 } {
266 if a.Token != "" || a.AuthOrigin != "" {
267 t.Errorf("%s: token kept: %+v", name, a)
268 }
269 }
270 // No token, no origin: github.com archives are anonymous.
271 if a := conv(j, ghSrc, "https://codeload.github.com/o/r/tar.gz/abc", ""); a.Token != "" || a.AuthOrigin != "" {
272 t.Errorf("%+v", a)
273 }
274 // Guest forge access: the sandbox reaches ermite at the runner's origin.
275 j.setOrigins(`{"github":{"server_url":"https://ermite.example"}}`, "https://runner.internal:8088", "http://10.0.2.2:8088")
276 a := conv(j, ermiteSrc, "https://ermite.example/-/github/api/repos/o/r/tarball/abc", "t")
277 if a.TarballUrl != "http://10.0.2.2:8088/-/github/api/repos/o/r/tarball/abc" || a.AuthOrigin != "http://10.0.2.2:8088" || a.Token != "t" {
278 t.Errorf("%+v", a)
279 }
280}
281
282// GO-012 on the archive tokens: with IPv6 hosts the origin named for the token is
283// exact, and a look-alike is not accepted.
284func TestResolveResponseTokenOriginIPv6(t *testing.T) {
285 j := resolveJob(&resolveStub{})
286 j.setOrigins(`{"github":{"server_url":"https://[::1]:8443"}}`, "https://[fd00::9]", "http://[fd00::2]:8088")
287 ermiteSrc := ermite.ActionSource_ACTION_SOURCE_ERMITE
288 conv := func(url string) *execpb.ResolvedAction {
289 return j.resolveResponse(&ermite.ResolveActionsResponse{Actions: []*ermite.ResolvedAction{{Source: ermiteSrc, TarballUrl: url, Token: "t"}}}, 1).Actions[0]
290 }
291 // The public origin is rewritten to the guest one.
292 if a := conv("https://[::1]:8443/-/github/api/repos/o/r/tarball/abc"); a.Token != "t" || a.AuthOrigin != "http://[fd00::2]:8088" || a.TarballUrl != "http://[fd00::2]:8088/-/github/api/repos/o/r/tarball/abc" {
293 t.Errorf("%+v", a)
294 }
295 // The runner's own origin, default port.
296 if a := conv("https://[fd00::9]:443/x"); a.Token != "t" || a.AuthOrigin != "https://[fd00::9]" {
297 t.Errorf("%+v", a)
298 }
299 // The look-alike host "::1:8443" and other ports are not ermite.
300 for _, u := range []string{"https://[::1:8443]/x", "https://[::1]:8444/x", "http://[::1]:8443/x", "https://[::1]/x"} {
301 if a := conv(u); a.Token != "" || a.AuthOrigin != "" {
302 t.Errorf("%s: token kept: %+v", u, a)
303 }
304 }
305}