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