fix/step-umask a16908b

resolve_test.go RawHistoryFolder

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}

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