detached 5b66afc

feat(ci): serve the owner's git URLs and the REST subset the actions call

Git over HTTP also serves /<owner>/<repo>[.git]/... for ERMITE_OWNER, so
actions/checkout can clone github.repository without the web redirect; other
owners are 404. Authorization accepts the "token" scheme next to Basic (any
user, x-access-token) and Bearer.

/-/github/api serves GET /repos/{owner}/{repo}, /hash-algorithm and
/tarball[/{ref}] (git archive, gzip, one top-level directory, streamed with
200 instead of GitHub's 302 to codeload) with the forge's own access rules;
everything else is GitHub's 404 JSON. The job repository token and the archive
tokens are wired into Auth.
commit
5b66afc2db2d0b78021c13b448e92e5f7ba321fe
author
brice · · 2026-10-13 10:41 UTC
parent
8d1000a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +1549 -21
M .hexcheck.yaml +3 -0
@@ -73,6 +73,9 @@ allow:
7373 - rule: no-adapter-to-adapter-imports
7474 path: internal/adapters/githttp/**
7575 reason: rate-limits Git clients with internal/clientlimit
76+ - rule: no-adapter-to-adapter-imports
77+ path: internal/adapters/githubapi/**
78+ reason: rate-limits the clients of the REST subset with internal/clientlimit
7679 - rule: no-adapter-to-adapter-imports
7780 path: internal/adapters/rpc/**
7881 reason: rate-limits API clients with internal/clientlimit
@@ -73,6 +73,9 @@ allow:
73 - rule: no-adapter-to-adapter-imports73 - rule: no-adapter-to-adapter-imports
74 path: internal/adapters/githttp/**74 path: internal/adapters/githttp/**
75 reason: rate-limits Git clients with internal/clientlimit75 reason: rate-limits Git clients with internal/clientlimit
76+ - rule: no-adapter-to-adapter-imports
77+ path: internal/adapters/githubapi/**
78+ reason: rate-limits the clients of the REST subset with internal/clientlimit
76 - rule: no-adapter-to-adapter-imports79 - rule: no-adapter-to-adapter-imports
77 path: internal/adapters/rpc/**80 path: internal/adapters/rpc/**
78 reason: rate-limits API clients with internal/clientlimit81 reason: rate-limits API clients with internal/clientlimit
A internal/adapters/gitcli/archive.go +93 -0
@@ -0,0 +1,93 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "context"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "os/exec"
13+ "strings"
14+ "time"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// archiveTimeout bounds one archive when ctx has no deadline: a large
21+// repository takes minutes, a stalled client must not hold a process forever.
22+const archiveTimeout = 15 * time.Minute
23+
24+var _ ports.GitArchive = (*Git)(nil)
25+
26+// Archive streams the tree of the commit as a gzip-compressed tar, every path
27+// below prefix ("<prefix>/..."), with `git archive`. The tar is compressed here,
28+// not by a gzip binary. A client that goes away ends ctx, which kills git.
29+// Nothing is written to w when the commit does not exist
30+// (domain.ErrNotFound).
31+//
32+//hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's
33+func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {
34+ dir, err := g.repoDir(repo)
35+ if err != nil {
36+ return err
37+ }
38+ if _, ok := ctx.Deadline(); !ok {
39+ var cancel context.CancelFunc
40+ ctx, cancel = context.WithTimeout(ctx, archiveTimeout)
41+ defer cancel()
42+ }
43+ release, err := g.acquireExec(ctx)
44+ if err != nil {
45+ return fmt.Errorf("git archive: %w", err)
46+ }
47+ defer release()
48+
49+ cmd := exec.CommandContext(ctx, g.bin, readArgs(dir, "archive", "--format=tar", "--prefix="+prefix+"/", commit.String(), "--")...)
50+ cmd.Env = g.env
51+ cmd.Dir = g.dataDir
52+ cmd.WaitDelay = 5 * time.Second
53+ stderr := &tailBuffer{max: 4096}
54+ cmd.Stderr = stderr
55+ stdout, err := cmd.StdoutPipe()
56+ if err != nil {
57+ return fmt.Errorf("git archive: %w", err)
58+ }
59+ if err := cmd.Start(); err != nil {
60+ return fmt.Errorf("git archive: %w", err)
61+ }
62+ // The first byte decides whether git found the commit: wait for it before
63+ // writing to w, so that a missing commit can still be answered.
64+ first := make([]byte, 1)
65+ if n, _ := io.ReadFull(stdout, first); n == 0 {
66+ werr := cmd.Wait()
67+ switch {
68+ case ctx.Err() != nil:
69+ return ctx.Err()
70+ case werr != nil:
71+ return fmt.Errorf("git archive %s %s: %w: %s", repo, commit, domain.ErrNotFound, strings.TrimSpace(stderr.String()))
72+ }
73+ return errors.New("git archive: empty output")
74+ }
75+ zw := gzip.NewWriter(w)
76+ _, cerr := io.Copy(zw, io.MultiReader(bytes.NewReader(first), stdout))
77+ if cerr == nil {
78+ cerr = zw.Close()
79+ }
80+ if cerr != nil {
81+ _ = cmd.Process.Kill()
82+ }
83+ werr := cmd.Wait()
84+ switch {
85+ case ctx.Err() != nil:
86+ return ctx.Err()
87+ case cerr != nil:
88+ return fmt.Errorf("git archive %s: %w", repo, cerr)
89+ case werr != nil:
90+ return fmt.Errorf("git archive %s: %w: %s", repo, werr, strings.TrimSpace(stderr.String()))
91+ }
92+ return nil
93+}
@@ -0,0 +1,93 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "context"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "os/exec"
13+ "strings"
14+ "time"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// archiveTimeout bounds one archive when ctx has no deadline: a large
21+// repository takes minutes, a stalled client must not hold a process forever.
22+const archiveTimeout = 15 * time.Minute
23+
24+var _ ports.GitArchive = (*Git)(nil)
25+
26+// Archive streams the tree of the commit as a gzip-compressed tar, every path
27+// below prefix ("<prefix>/..."), with `git archive`. The tar is compressed here,
28+// not by a gzip binary. A client that goes away ends ctx, which kills git.
29+// Nothing is written to w when the commit does not exist
30+// (domain.ErrNotFound).
31+//
32+//hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's
33+func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {
34+ dir, err := g.repoDir(repo)
35+ if err != nil {
36+ return err
37+ }
38+ if _, ok := ctx.Deadline(); !ok {
39+ var cancel context.CancelFunc
40+ ctx, cancel = context.WithTimeout(ctx, archiveTimeout)
41+ defer cancel()
42+ }
43+ release, err := g.acquireExec(ctx)
44+ if err != nil {
45+ return fmt.Errorf("git archive: %w", err)
46+ }
47+ defer release()
48+
49+ cmd := exec.CommandContext(ctx, g.bin, readArgs(dir, "archive", "--format=tar", "--prefix="+prefix+"/", commit.String(), "--")...)
50+ cmd.Env = g.env
51+ cmd.Dir = g.dataDir
52+ cmd.WaitDelay = 5 * time.Second
53+ stderr := &tailBuffer{max: 4096}
54+ cmd.Stderr = stderr
55+ stdout, err := cmd.StdoutPipe()
56+ if err != nil {
57+ return fmt.Errorf("git archive: %w", err)
58+ }
59+ if err := cmd.Start(); err != nil {
60+ return fmt.Errorf("git archive: %w", err)
61+ }
62+ // The first byte decides whether git found the commit: wait for it before
63+ // writing to w, so that a missing commit can still be answered.
64+ first := make([]byte, 1)
65+ if n, _ := io.ReadFull(stdout, first); n == 0 {
66+ werr := cmd.Wait()
67+ switch {
68+ case ctx.Err() != nil:
69+ return ctx.Err()
70+ case werr != nil:
71+ return fmt.Errorf("git archive %s %s: %w: %s", repo, commit, domain.ErrNotFound, strings.TrimSpace(stderr.String()))
72+ }
73+ return errors.New("git archive: empty output")
74+ }
75+ zw := gzip.NewWriter(w)
76+ _, cerr := io.Copy(zw, io.MultiReader(bytes.NewReader(first), stdout))
77+ if cerr == nil {
78+ cerr = zw.Close()
79+ }
80+ if cerr != nil {
81+ _ = cmd.Process.Kill()
82+ }
83+ werr := cmd.Wait()
84+ switch {
85+ case ctx.Err() != nil:
86+ return ctx.Err()
87+ case cerr != nil:
88+ return fmt.Errorf("git archive %s: %w", repo, cerr)
89+ case werr != nil:
90+ return fmt.Errorf("git archive %s: %w: %s", repo, werr, strings.TrimSpace(stderr.String()))
91+ }
92+ return nil
93+}
A internal/adapters/gitcli/archive_test.go +80 -0
@@ -0,0 +1,80 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "io"
11+ "strings"
12+ "testing"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+func TestArchiveWritesAGzipTarUnderThePrefix(t *testing.T) {
21+ g, data := newGit(t)
22+ ctx := context.Background()
23+ m := newMergeFixture(t, data)
24+ t.Cleanup(func() { _ = g.Close(ctx) })
25+
26+ var buf bytes.Buffer
27+ require.NoError(t, g.Archive(ctx, m.f.Name, m.d, "owner-proj-abcdef0", &buf))
28+ zr, err := gzip.NewReader(&buf)
29+ require.NoError(t, err)
30+ tr := tar.NewReader(zr)
31+ files := map[string]string{}
32+ for {
33+ h, err := tr.Next()
34+ if err == io.EOF {
35+ break
36+ }
37+ require.NoError(t, err)
38+ if h.Typeflag == tar.TypeXGlobalHeader {
39+ // git archive records the commit id here, as GitHub's tarballs do.
40+ assert.Equal(t, m.d.String(), h.PAXRecords["comment"])
41+ continue
42+ }
43+ require.True(t, strings.HasPrefix(h.Name, "owner-proj-abcdef0/"), h.Name)
44+ if h.Typeflag == tar.TypeReg {
45+ b, _ := io.ReadAll(tr)
46+ files[h.Name] = string(b)
47+ }
48+ }
49+ assert.Equal(t, "one\n2\nthree\n", files["owner-proj-abcdef0/a.txt"], "the tree of the commit asked, not of HEAD")
50+ assert.Equal(t, "see\n", files["owner-proj-abcdef0/c.txt"])
51+ assert.NotContains(t, files, "owner-proj-abcdef0/.git")
52+}
53+
54+func TestArchiveOfAMissingCommitWritesNothing(t *testing.T) {
55+ g, data := newGit(t)
56+ ctx := context.Background()
57+ m := newMergeFixture(t, data)
58+ t.Cleanup(func() { _ = g.Close(ctx) })
59+
60+ var buf bytes.Buffer
61+ missing, err := domain.ParseObjectID(strings.Repeat("1", 40))
62+ require.NoError(t, err)
63+ err = g.Archive(ctx, m.f.Name, missing, "p", &buf)
64+ assert.ErrorIs(t, err, domain.ErrNotFound)
65+ assert.Zero(t, buf.Len(), "nothing was written, so the caller can still answer 404")
66+
67+ err = g.Archive(ctx, name(t, "nope"), m.d, "p", &buf)
68+ assert.ErrorIs(t, err, domain.ErrNotFound)
69+}
70+
71+func TestArchiveStopsWithItsContext(t *testing.T) {
72+ g, data := newGit(t)
73+ m := newMergeFixture(t, data)
74+ t.Cleanup(func() { _ = g.Close(context.Background()) })
75+
76+ ctx, cancel := context.WithCancel(context.Background())
77+ cancel()
78+ err := g.Archive(ctx, m.f.Name, m.d, "p", io.Discard)
79+ assert.ErrorIs(t, err, context.Canceled)
80+}
@@ -0,0 +1,80 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "io"
11+ "strings"
12+ "testing"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+func TestArchiveWritesAGzipTarUnderThePrefix(t *testing.T) {
21+ g, data := newGit(t)
22+ ctx := context.Background()
23+ m := newMergeFixture(t, data)
24+ t.Cleanup(func() { _ = g.Close(ctx) })
25+
26+ var buf bytes.Buffer
27+ require.NoError(t, g.Archive(ctx, m.f.Name, m.d, "owner-proj-abcdef0", &buf))
28+ zr, err := gzip.NewReader(&buf)
29+ require.NoError(t, err)
30+ tr := tar.NewReader(zr)
31+ files := map[string]string{}
32+ for {
33+ h, err := tr.Next()
34+ if err == io.EOF {
35+ break
36+ }
37+ require.NoError(t, err)
38+ if h.Typeflag == tar.TypeXGlobalHeader {
39+ // git archive records the commit id here, as GitHub's tarballs do.
40+ assert.Equal(t, m.d.String(), h.PAXRecords["comment"])
41+ continue
42+ }
43+ require.True(t, strings.HasPrefix(h.Name, "owner-proj-abcdef0/"), h.Name)
44+ if h.Typeflag == tar.TypeReg {
45+ b, _ := io.ReadAll(tr)
46+ files[h.Name] = string(b)
47+ }
48+ }
49+ assert.Equal(t, "one\n2\nthree\n", files["owner-proj-abcdef0/a.txt"], "the tree of the commit asked, not of HEAD")
50+ assert.Equal(t, "see\n", files["owner-proj-abcdef0/c.txt"])
51+ assert.NotContains(t, files, "owner-proj-abcdef0/.git")
52+}
53+
54+func TestArchiveOfAMissingCommitWritesNothing(t *testing.T) {
55+ g, data := newGit(t)
56+ ctx := context.Background()
57+ m := newMergeFixture(t, data)
58+ t.Cleanup(func() { _ = g.Close(ctx) })
59+
60+ var buf bytes.Buffer
61+ missing, err := domain.ParseObjectID(strings.Repeat("1", 40))
62+ require.NoError(t, err)
63+ err = g.Archive(ctx, m.f.Name, missing, "p", &buf)
64+ assert.ErrorIs(t, err, domain.ErrNotFound)
65+ assert.Zero(t, buf.Len(), "nothing was written, so the caller can still answer 404")
66+
67+ err = g.Archive(ctx, name(t, "nope"), m.d, "p", &buf)
68+ assert.ErrorIs(t, err, domain.ErrNotFound)
69+}
70+
71+func TestArchiveStopsWithItsContext(t *testing.T) {
72+ g, data := newGit(t)
73+ m := newMergeFixture(t, data)
74+ t.Cleanup(func() { _ = g.Close(context.Background()) })
75+
76+ ctx, cancel := context.WithCancel(context.Background())
77+ cancel()
78+ err := g.Archive(ctx, m.f.Name, m.d, "p", io.Discard)
79+ assert.ErrorIs(t, err, context.Canceled)
80+}
M internal/adapters/githttp/handler.go +49 -10
@@ -38,6 +38,21 @@ var Patterns = []string{
3838 "POST /{repo}/git-receive-pack",
3939 }
4040
41+// OwnerPatterns are the patterns of Patterns under /<owner>/, where owner is
42+// ERMITE_OWNER: the URL github.repository gives (actions/checkout clones
43+// <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal
44+// segment so that the patterns never overlap the /-/ routes, and other owners
45+// fall through to the web, which answers 404. Register them only for a valid
46+// owner; Config.Owner must be the same.
47+func OwnerPatterns(owner string) []string {
48+ out := make([]string, len(Patterns))
49+ for i, p := range Patterns {
50+ method, path, _ := strings.Cut(p, " ")
51+ out[i] = method + " /" + owner + path
52+ }
53+ return out
54+}
55+
4156 // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes
4257 // is zero.
4358 const DefaultMaxBodyBytes int64 = 4 << 30
@@ -67,6 +82,9 @@ type Config struct {
6782 Repos ports.RepoLookup
6883 Pushes ports.PushService
6984 Backend ports.GitBackend
85+ // Owner is ERMITE_OWNER: /<owner>/<repo>/... is served like /<repo>/...
86+ // (see OwnerPatterns). Empty serves the second form nowhere.
87+ Owner string
7088 // Hooks provides hook tokens for pushes. It is required.
7189 Hooks ports.HookTokens
7290 // TrustedProxies are the networks allowed to set X-Forwarded-For.
@@ -146,12 +164,34 @@ type route struct {
146164
147165 func (rt route) push() bool { return rt.service == serviceReceive }
148166
167+// gitSuffixes are the ends of the paths the handler serves, after the
168+// repository.
169+var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceReceive}
170+
149171 // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack
150-// and /<repo>/git-receive-pack. info/refs without a service is the dumb
151-// protocol, which ermite does not serve.
152-func parseRoute(r *http.Request) (route, int) {
153- seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
154- if !ok || seg == "" {
172+// and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of
173+// the forge. info/refs without a service is the dumb protocol, which ermite
174+// does not serve.
175+func (h *Handler) parseRoute(r *http.Request) (route, int) {
176+ p := strings.TrimPrefix(r.URL.Path, "/")
177+ var head, rest string
178+ for _, suffix := range gitSuffixes {
179+ if h, ok := strings.CutSuffix(p, suffix); ok {
180+ head, rest = h, suffix[1:]
181+ break
182+ }
183+ }
184+ if rest == "" {
185+ return route{}, http.StatusNotFound
186+ }
187+ seg := head
188+ if owner, repo, ok := strings.Cut(head, "/"); ok {
189+ if h.cfg.Owner == "" || owner != h.cfg.Owner {
190+ return route{}, http.StatusNotFound
191+ }
192+ seg = repo
193+ }
194+ if seg == "" || strings.Contains(seg, "/") {
155195 return route{}, http.StatusNotFound
156196 }
157197 rt := route{repo: seg, suffix: rest}
@@ -175,8 +215,6 @@ func parseRoute(r *http.Request) (route, int) {
175215 return route{}, http.StatusMethodNotAllowed
176216 }
177217 rt.service = rest
178- default:
179- return route{}, http.StatusNotFound
180218 }
181219 return rt, 0
182220 }
@@ -198,7 +236,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
198236 Msg("git request")
199237 }()
200238
201- rt, status := parseRoute(r)
239+ rt, status := h.parseRoute(r)
202240 if status != 0 {
203241 http.Error(w, http.StatusText(status), status)
204242 return
@@ -265,7 +303,8 @@ func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal,
265303 }
266304
267305 // credentials extracts the token from an Authorization header: the password of
268-// Basic (the username is ignored) or a Bearer token. present is true when the
306+// Basic (the username is ignored, actions/checkout sends x-access-token), or
307+// the value of "Bearer" or "token". present is true when the
269308 // header exists, even if it carries nothing usable, which then fails
270309 // authentication.
271310 func credentials(r *http.Request) (secret string, present bool) {
@@ -276,7 +315,7 @@ func credentials(r *http.Request) (secret string, present bool) {
276315 if _, pass, ok := r.BasicAuth(); ok {
277316 return pass, true
278317 }
279- if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") {
318+ if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) {
280319 return strings.TrimSpace(tok), true
281320 }
282321 return "", true
@@ -38,6 +38,21 @@ var Patterns = []string{
38 "POST /{repo}/git-receive-pack",38 "POST /{repo}/git-receive-pack",
39 }39 }
40 40
41+// OwnerPatterns are the patterns of Patterns under /<owner>/, where owner is
42+// ERMITE_OWNER: the URL github.repository gives (actions/checkout clones
43+// <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal
44+// segment so that the patterns never overlap the /-/ routes, and other owners
45+// fall through to the web, which answers 404. Register them only for a valid
46+// owner; Config.Owner must be the same.
47+func OwnerPatterns(owner string) []string {
48+ out := make([]string, len(Patterns))
49+ for i, p := range Patterns {
50+ method, path, _ := strings.Cut(p, " ")
51+ out[i] = method + " /" + owner + path
52+ }
53+ return out
54+}
55+
41 // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes56 // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes
42 // is zero.57 // is zero.
43 const DefaultMaxBodyBytes int64 = 4 << 3058 const DefaultMaxBodyBytes int64 = 4 << 30
@@ -67,6 +82,9 @@ type Config struct {
67 Repos ports.RepoLookup82 Repos ports.RepoLookup
68 Pushes ports.PushService83 Pushes ports.PushService
69 Backend ports.GitBackend84 Backend ports.GitBackend
85+ // Owner is ERMITE_OWNER: /<owner>/<repo>/... is served like /<repo>/...
86+ // (see OwnerPatterns). Empty serves the second form nowhere.
87+ Owner string
70 // Hooks provides hook tokens for pushes. It is required.88 // Hooks provides hook tokens for pushes. It is required.
71 Hooks ports.HookTokens89 Hooks ports.HookTokens
72 // TrustedProxies are the networks allowed to set X-Forwarded-For.90 // TrustedProxies are the networks allowed to set X-Forwarded-For.
@@ -146,12 +164,34 @@ type route struct {
146 164
147 func (rt route) push() bool { return rt.service == serviceReceive }165 func (rt route) push() bool { return rt.service == serviceReceive }
148 166
167+// gitSuffixes are the ends of the paths the handler serves, after the
168+// repository.
169+var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceReceive}
170+
149 // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack171 // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack
150-// and /<repo>/git-receive-pack. info/refs without a service is the dumb172+// and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of
151-// protocol, which ermite does not serve.173+// the forge. info/refs without a service is the dumb protocol, which ermite
152-func parseRoute(r *http.Request) (route, int) {174+// does not serve.
153- seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")175+func (h *Handler) parseRoute(r *http.Request) (route, int) {
154- if !ok || seg == "" {176+ p := strings.TrimPrefix(r.URL.Path, "/")
177+ var head, rest string
178+ for _, suffix := range gitSuffixes {
179+ if h, ok := strings.CutSuffix(p, suffix); ok {
180+ head, rest = h, suffix[1:]
181+ break
182+ }
183+ }
184+ if rest == "" {
185+ return route{}, http.StatusNotFound
186+ }
187+ seg := head
188+ if owner, repo, ok := strings.Cut(head, "/"); ok {
189+ if h.cfg.Owner == "" || owner != h.cfg.Owner {
190+ return route{}, http.StatusNotFound
191+ }
192+ seg = repo
193+ }
194+ if seg == "" || strings.Contains(seg, "/") {
155 return route{}, http.StatusNotFound195 return route{}, http.StatusNotFound
156 }196 }
157 rt := route{repo: seg, suffix: rest}197 rt := route{repo: seg, suffix: rest}
@@ -175,8 +215,6 @@ func parseRoute(r *http.Request) (route, int) {
175 return route{}, http.StatusMethodNotAllowed215 return route{}, http.StatusMethodNotAllowed
176 }216 }
177 rt.service = rest217 rt.service = rest
178- default:
179- return route{}, http.StatusNotFound
180 }218 }
181 return rt, 0219 return rt, 0
182 }220 }
@@ -198,7 +236,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
198 Msg("git request")236 Msg("git request")
199 }()237 }()
200 238
201- rt, status := parseRoute(r)239+ rt, status := h.parseRoute(r)
202 if status != 0 {240 if status != 0 {
203 http.Error(w, http.StatusText(status), status)241 http.Error(w, http.StatusText(status), status)
204 return242 return
@@ -265,7 +303,8 @@ func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal,
265 }303 }
266 304
267 // credentials extracts the token from an Authorization header: the password of305 // credentials extracts the token from an Authorization header: the password of
268-// Basic (the username is ignored) or a Bearer token. present is true when the306+// Basic (the username is ignored, actions/checkout sends x-access-token), or
307+// the value of "Bearer" or "token". present is true when the
269 // header exists, even if it carries nothing usable, which then fails308 // header exists, even if it carries nothing usable, which then fails
270 // authentication.309 // authentication.
271 func credentials(r *http.Request) (secret string, present bool) {310 func credentials(r *http.Request) (secret string, present bool) {
@@ -276,7 +315,7 @@ func credentials(r *http.Request) (secret string, present bool) {
276 if _, pass, ok := r.BasicAuth(); ok {315 if _, pass, ok := r.BasicAuth(); ok {
277 return pass, true316 return pass, true
278 }317 }
279- if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") {318+ if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) {
280 return strings.TrimSpace(tok), true319 return strings.TrimSpace(tok), true
281 }320 }
282 return "", true321 return "", true
M internal/adapters/githttp/handler_test.go +52 -2
@@ -172,7 +172,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
172172 repos := mocks.NewRepoLookup(t)
173173 r.stubUseCases(repos)
174174 cfg := githttp.Config{
175- Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,
175+ Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, Owner: "acme",
176176 }
177177 script := cgiScript(t)
178178 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
@@ -188,7 +188,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
188188 }
189189 r.h = githttp.New(cfg)
190190 mux := http.NewServeMux()
191- for _, p := range githttp.Patterns {
191+ for _, p := range append(githttp.Patterns, githttp.OwnerPatterns("acme")...) {
192192 mux.Handle(p, r.h)
193193 }
194194 r.srv = httptest.NewServer(mux)
@@ -506,6 +506,54 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
506506 assert.Equal(t, 200, code)
507507 }
508508
509+// The clone URL of GitHub-shaped clients, /<owner>/<repo>, with or without .git:
510+// served for the owner of the forge, 404 for anyone else and for what is not a
511+// Git path.
512+func TestHandlerServesTheOwnerForm(t *testing.T) {
513+ t.Parallel()
514+ tests := []struct {
515+ name, method, path, pass string
516+ want int
517+ }{
518+ {"owner, no suffix", "GET", "/acme/pub/info/refs?service=git-upload-pack", "", 200},
519+ {"owner, .git", "GET", "/acme/pub.git/info/refs?service=git-upload-pack", "", 200},
520+ {"owner, private, reader", "GET", "/acme/priv/info/refs?service=git-upload-pack", "reader-secret", 200},
521+ {"owner, private, anonymous", "GET", "/acme/priv/info/refs?service=git-upload-pack", "", 401},
522+ {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200},
523+ {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200},
524+ {"other owner", "GET", "/other/pub/info/refs?service=git-upload-pack", "root-secret", 404},
525+ {"owner case is exact", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 404},
526+ {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404},
527+ {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404},
528+ {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404},
529+ {"invalid repository", "GET", "/acme/Bad/info/refs?service=git-upload-pack", "root-secret", 404},
530+ {"owner, wrong method", "POST", "/acme/pub/info/refs?service=git-upload-pack", "", 405},
531+ {"owner, not a git path", "GET", "/acme/pub/HEAD", "", 404},
532+ }
533+ for _, tt := range tests {
534+ t.Run(tt.name, func(t *testing.T) {
535+ t.Parallel()
536+ r := newRig(t, nil)
537+ user := ""
538+ if tt.pass != "" {
539+ user = "x-access-token"
540+ }
541+ code, _, _ := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil)
542+ assert.Equal(t, tt.want, code)
543+ })
544+ }
545+}
546+
547+// Without an owner configured only the plain form is served.
548+func TestHandlerWithoutOwnerServesThePlainFormOnly(t *testing.T) {
549+ t.Parallel()
550+ r := newRig(t, func(c *githttp.Config) { c.Owner = "" })
551+ code, _, _ := r.do(t, "GET", "/acme/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
552+ assert.Equal(t, 404, code)
553+ code, _, _ = r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
554+ assert.Equal(t, 200, code)
555+}
556+
509557 func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
510558 t.Parallel()
511559 r := newRig(t, nil)
@@ -514,6 +562,8 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
514562 assert.Equal(t, 200, code)
515563 code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})
516564 assert.Equal(t, 200, code, "the scheme is case-insensitive")
565+ code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "token reader-secret"})
566+ assert.Equal(t, 200, code, "GitHub's own scheme")
517567 for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {
518568 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})
519569 assert.Equal(t, 401, code, h)
@@ -172,7 +172,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
172 repos := mocks.NewRepoLookup(t)172 repos := mocks.NewRepoLookup(t)
173 r.stubUseCases(repos)173 r.stubUseCases(repos)
174 cfg := githttp.Config{174 cfg := githttp.Config{
175- Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,175+ Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, Owner: "acme",
176 }176 }
177 script := cgiScript(t)177 script := cgiScript(t)
178 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(178 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
@@ -188,7 +188,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
188 }188 }
189 r.h = githttp.New(cfg)189 r.h = githttp.New(cfg)
190 mux := http.NewServeMux()190 mux := http.NewServeMux()
191- for _, p := range githttp.Patterns {191+ for _, p := range append(githttp.Patterns, githttp.OwnerPatterns("acme")...) {
192 mux.Handle(p, r.h)192 mux.Handle(p, r.h)
193 }193 }
194 r.srv = httptest.NewServer(mux)194 r.srv = httptest.NewServer(mux)
@@ -506,6 +506,54 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
506 assert.Equal(t, 200, code)506 assert.Equal(t, 200, code)
507 }507 }
508 508
509+// The clone URL of GitHub-shaped clients, /<owner>/<repo>, with or without .git:
510+// served for the owner of the forge, 404 for anyone else and for what is not a
511+// Git path.
512+func TestHandlerServesTheOwnerForm(t *testing.T) {
513+ t.Parallel()
514+ tests := []struct {
515+ name, method, path, pass string
516+ want int
517+ }{
518+ {"owner, no suffix", "GET", "/acme/pub/info/refs?service=git-upload-pack", "", 200},
519+ {"owner, .git", "GET", "/acme/pub.git/info/refs?service=git-upload-pack", "", 200},
520+ {"owner, private, reader", "GET", "/acme/priv/info/refs?service=git-upload-pack", "reader-secret", 200},
521+ {"owner, private, anonymous", "GET", "/acme/priv/info/refs?service=git-upload-pack", "", 401},
522+ {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200},
523+ {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200},
524+ {"other owner", "GET", "/other/pub/info/refs?service=git-upload-pack", "root-secret", 404},
525+ {"owner case is exact", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 404},
526+ {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404},
527+ {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404},
528+ {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404},
529+ {"invalid repository", "GET", "/acme/Bad/info/refs?service=git-upload-pack", "root-secret", 404},
530+ {"owner, wrong method", "POST", "/acme/pub/info/refs?service=git-upload-pack", "", 405},
531+ {"owner, not a git path", "GET", "/acme/pub/HEAD", "", 404},
532+ }
533+ for _, tt := range tests {
534+ t.Run(tt.name, func(t *testing.T) {
535+ t.Parallel()
536+ r := newRig(t, nil)
537+ user := ""
538+ if tt.pass != "" {
539+ user = "x-access-token"
540+ }
541+ code, _, _ := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil)
542+ assert.Equal(t, tt.want, code)
543+ })
544+ }
545+}
546+
547+// Without an owner configured only the plain form is served.
548+func TestHandlerWithoutOwnerServesThePlainFormOnly(t *testing.T) {
549+ t.Parallel()
550+ r := newRig(t, func(c *githttp.Config) { c.Owner = "" })
551+ code, _, _ := r.do(t, "GET", "/acme/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
552+ assert.Equal(t, 404, code)
553+ code, _, _ = r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
554+ assert.Equal(t, 200, code)
555+}
556+
509 func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {557 func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
510 t.Parallel()558 t.Parallel()
511 r := newRig(t, nil)559 r := newRig(t, nil)
@@ -514,6 +562,8 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
514 assert.Equal(t, 200, code)562 assert.Equal(t, 200, code)
515 code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})563 code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})
516 assert.Equal(t, 200, code, "the scheme is case-insensitive")564 assert.Equal(t, 200, code, "the scheme is case-insensitive")
565+ code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "token reader-secret"})
566+ assert.Equal(t, 200, code, "GitHub's own scheme")
517 for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {567 for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {
518 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})568 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})
519 assert.Equal(t, 401, code, h)569 assert.Equal(t, 401, code, h)
A internal/adapters/githubapi/handler.go +270 -0
@@ -0,0 +1,270 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package githubapi serves the subset of GitHub's REST API that the actions
4+// running on ermite call under github.api_url (/-/github/api, ADR 0025 CI-2):
5+//
6+// GET /repos/{owner}/{repo}
7+// GET /repos/{owner}/{repo}/hash-algorithm
8+// GET /repos/{owner}/{repo}/tarball[/{ref}]
9+//
10+// Everything else under the prefix is GitHub's 404 JSON, so that an action
11+// calling an endpoint of a feature ermite lacks fails as it does on a GitHub
12+// instance without it, never silently succeeds. The auth is the forge's: a
13+// token (stored, or the repository token of a job) as the password of Basic with
14+// any user, or as "Bearer" or "token" credentials; no credentials is anonymous,
15+// which reads public repositories.
16+package githubapi
17+
18+import (
19+ "context"
20+ "errors"
21+ "io"
22+ "net/http"
23+ "net/netip"
24+ "strconv"
25+ "strings"
26+
27+ "github.com/bnema/zerowrap"
28+
29+ "bnema.dev/ermite/internal/clientlimit"
30+ "bnema.dev/ermite/internal/domain"
31+ "bnema.dev/ermite/internal/ports"
32+)
33+
34+// Prefix is where the API is mounted.
35+const Prefix = "/-/github/api"
36+
37+// Patterns are the ServeMux patterns the handler serves: its whole prefix.
38+var Patterns = []string{Prefix + "/"}
39+
40+// notFoundBody is GitHub's body for an unknown endpoint or repository.
41+const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
42+
43+// DefaultMaxArchives bounds the archives written at once.
44+const DefaultMaxArchives = 8
45+
46+// Config configures a Handler.
47+type Config struct {
48+ Auth ports.AuthService
49+ API ports.GitHubAPIService
50+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
51+ TrustedProxies []netip.Prefix
52+ // Limiter counts failed authentications; nil builds a default one.
53+ Limiter ports.FailureLimiter
54+ // MaxArchives bounds the archives written at once (each runs a git
55+ // process); zero means DefaultMaxArchives.
56+ MaxArchives int
57+}
58+
59+// Handler serves the REST subset.
60+type Handler struct {
61+ cfg Config
62+ limiter ports.FailureLimiter
63+ archives chan struct{}
64+}
65+
66+// New builds a Handler.
67+func New(cfg Config) *Handler {
68+ h := &Handler{cfg: cfg, limiter: cfg.Limiter}
69+ if h.limiter == nil {
70+ h.limiter = clientlimit.NewLimiter(nil)
71+ }
72+ n := cfg.MaxArchives
73+ if n <= 0 {
74+ n = DefaultMaxArchives
75+ }
76+ h.archives = make(chan struct{}, n)
77+ return h
78+}
79+
80+// ServeHTTP implements http.Handler.
81+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
82+ w.Header().Set("Cache-Control", "no-store")
83+ w.Header().Set("X-Content-Type-Options", "nosniff")
84+ rest := strings.TrimPrefix(r.URL.Path, Prefix)
85+ kind, owner, repo, ref, ok := route(rest)
86+ if !ok {
87+ notFound(w)
88+ return
89+ }
90+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
91+ w.Header().Set("Allow", "GET, HEAD")
92+ jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
93+ return
94+ }
95+
96+ ctx := r.Context()
97+ addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
98+ if blocked, _ := h.limiter.Blocked(ctx, addr); blocked {
99+ jsonError(w, http.StatusTooManyRequests, "API rate limit exceeded")
100+ return
101+ }
102+ p, err := h.authenticate(r, addr)
103+ if err != nil {
104+ if errors.Is(err, domain.ErrUnauthenticated) {
105+ jsonError(w, http.StatusUnauthorized, "Bad credentials")
106+ return
107+ }
108+ log := zerowrap.FromCtx(ctx)
109+ log.Error().Err(err).Msg("authenticate github api request")
110+ jsonError(w, http.StatusInternalServerError, "Server Error")
111+ return
112+ }
113+
114+ switch kind {
115+ case "repo":
116+ body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)
117+ if err != nil {
118+ h.fail(ctx, w, err)
119+ return
120+ }
121+ writeJSON(w, r, body)
122+ case "hash":
123+ alg, err := h.cfg.API.HashAlgorithm(ctx, p, owner, repo)
124+ if err != nil {
125+ h.fail(ctx, w, err)
126+ return
127+ }
128+ writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`))
129+ case "tarball":
130+ h.tarball(w, r, p, owner, repo, ref)
131+ }
132+}
133+
134+// route recognises the three endpoints. ref is "" for /tarball.
135+func route(rest string) (kind, owner, repo, ref string, ok bool) {
136+ parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
137+ if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {
138+ return "", "", "", "", false
139+ }
140+ owner, repo = parts[1], parts[2]
141+ switch {
142+ case len(parts) == 3:
143+ return "repo", owner, repo, "", true
144+ case len(parts) == 4 && parts[3] == "hash-algorithm":
145+ return "hash", owner, repo, "", true
146+ case len(parts) >= 4 && parts[3] == "tarball":
147+ // The ref may hold slashes (feature/x): everything after tarball/.
148+ return "tarball", owner, repo, strings.Join(parts[4:], "/"), true
149+ }
150+ return "", "", "", "", false
151+}
152+
153+// tarball streams the archive. It is not a 302 to a codeload: ermite serves the
154+// bytes itself, which the runner follows without a second credential.
155+func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref string) {
156+ ctx := r.Context()
157+ select {
158+ case h.archives <- struct{}{}:
159+ defer func() { <-h.archives }()
160+ default:
161+ w.Header().Set("Retry-After", "5")
162+ jsonError(w, http.StatusServiceUnavailable, "Server busy")
163+ return
164+ }
165+ if r.Method == http.MethodHead {
166+ // Resolve without writing: a HEAD is answered with the headers only.
167+ err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer {
168+ setArchiveHeaders(w, name)
169+ w.WriteHeader(http.StatusOK)
170+ return io.Discard
171+ })
172+ if err != nil && !errors.Is(err, context.Canceled) {
173+ h.fail(ctx, w, err)
174+ }
175+ return
176+ }
177+ started := false
178+ err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer {
179+ started = true
180+ setArchiveHeaders(w, name)
181+ return w
182+ })
183+ switch {
184+ case err == nil:
185+ case started:
186+ // The status is out: the client sees a truncated archive. Log it.
187+ log := zerowrap.FromCtx(ctx)
188+ log.Warn().Err(err).Msg("github api archive cut short")
189+ default:
190+ h.fail(ctx, w, err)
191+ }
192+}
193+
194+func setArchiveHeaders(w http.ResponseWriter, name string) {
195+ w.Header().Set("Content-Type", "application/x-gzip")
196+ w.Header().Set("Content-Disposition", "attachment; filename="+name)
197+}
198+
199+// authenticate reads the credentials of the request. None is the anonymous
200+// principal; credentials that are not valid count as a failure of the client
201+// address.
202+func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) {
203+ secret, present := credentials(r)
204+ if !present {
205+ return domain.Anonymous(), nil
206+ }
207+ p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
208+ if err != nil {
209+ if errors.Is(err, domain.ErrUnauthenticated) {
210+ h.limiter.Fail(r.Context(), addr)
211+ }
212+ return domain.Anonymous(), err
213+ }
214+ return p, nil
215+}
216+
217+// credentials extracts the token of an Authorization header: the password of
218+// Basic (the user is ignored, checkout sends x-access-token), or the value of
219+// "Bearer" or "token".
220+func credentials(r *http.Request) (secret string, present bool) {
221+ auth := r.Header.Get("Authorization")
222+ if auth == "" {
223+ return "", false
224+ }
225+ if _, pass, ok := r.BasicAuth(); ok {
226+ return pass, true
227+ }
228+ if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) {
229+ return strings.TrimSpace(tok), true
230+ }
231+ return "", true
232+}
233+
234+// fail answers an error of a use case. A repository the caller cannot read is
235+// as not found as one that does not exist.
236+func (h *Handler) fail(ctx context.Context, w http.ResponseWriter, err error) {
237+ switch {
238+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
239+ notFound(w)
240+ case errors.Is(err, domain.ErrResourceExhausted):
241+ w.Header().Set("Retry-After", "5")
242+ jsonError(w, http.StatusServiceUnavailable, "Server busy")
243+ case errors.Is(err, context.Canceled):
244+ default:
245+ log := zerowrap.FromCtx(ctx)
246+ log.Error().Err(err).Msg("github api request failed")
247+ jsonError(w, http.StatusInternalServerError, "Server Error")
248+ }
249+}
250+
251+func notFound(w http.ResponseWriter) {
252+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
253+ w.WriteHeader(http.StatusNotFound)
254+ _, _ = io.WriteString(w, notFoundBody)
255+}
256+
257+func jsonError(w http.ResponseWriter, status int, msg string) {
258+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
259+ w.WriteHeader(status)
260+ _, _ = io.WriteString(w, `{"message":`+strconv.Quote(msg)+`,"documentation_url":"https://docs.github.com/rest"}`)
261+}
262+
263+func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) {
264+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
265+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
266+ w.WriteHeader(http.StatusOK)
267+ if r.Method != http.MethodHead {
268+ _, _ = w.Write(body)
269+ }
270+}
@@ -0,0 +1,270 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package githubapi serves the subset of GitHub's REST API that the actions
4+// running on ermite call under github.api_url (/-/github/api, ADR 0025 CI-2):
5+//
6+// GET /repos/{owner}/{repo}
7+// GET /repos/{owner}/{repo}/hash-algorithm
8+// GET /repos/{owner}/{repo}/tarball[/{ref}]
9+//
10+// Everything else under the prefix is GitHub's 404 JSON, so that an action
11+// calling an endpoint of a feature ermite lacks fails as it does on a GitHub
12+// instance without it, never silently succeeds. The auth is the forge's: a
13+// token (stored, or the repository token of a job) as the password of Basic with
14+// any user, or as "Bearer" or "token" credentials; no credentials is anonymous,
15+// which reads public repositories.
16+package githubapi
17+
18+import (
19+ "context"
20+ "errors"
21+ "io"
22+ "net/http"
23+ "net/netip"
24+ "strconv"
25+ "strings"
26+
27+ "github.com/bnema/zerowrap"
28+
29+ "bnema.dev/ermite/internal/clientlimit"
30+ "bnema.dev/ermite/internal/domain"
31+ "bnema.dev/ermite/internal/ports"
32+)
33+
34+// Prefix is where the API is mounted.
35+const Prefix = "/-/github/api"
36+
37+// Patterns are the ServeMux patterns the handler serves: its whole prefix.
38+var Patterns = []string{Prefix + "/"}
39+
40+// notFoundBody is GitHub's body for an unknown endpoint or repository.
41+const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
42+
43+// DefaultMaxArchives bounds the archives written at once.
44+const DefaultMaxArchives = 8
45+
46+// Config configures a Handler.
47+type Config struct {
48+ Auth ports.AuthService
49+ API ports.GitHubAPIService
50+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
51+ TrustedProxies []netip.Prefix
52+ // Limiter counts failed authentications; nil builds a default one.
53+ Limiter ports.FailureLimiter
54+ // MaxArchives bounds the archives written at once (each runs a git
55+ // process); zero means DefaultMaxArchives.
56+ MaxArchives int
57+}
58+
59+// Handler serves the REST subset.
60+type Handler struct {
61+ cfg Config
62+ limiter ports.FailureLimiter
63+ archives chan struct{}
64+}
65+
66+// New builds a Handler.
67+func New(cfg Config) *Handler {
68+ h := &Handler{cfg: cfg, limiter: cfg.Limiter}
69+ if h.limiter == nil {
70+ h.limiter = clientlimit.NewLimiter(nil)
71+ }
72+ n := cfg.MaxArchives
73+ if n <= 0 {
74+ n = DefaultMaxArchives
75+ }
76+ h.archives = make(chan struct{}, n)
77+ return h
78+}
79+
80+// ServeHTTP implements http.Handler.
81+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
82+ w.Header().Set("Cache-Control", "no-store")
83+ w.Header().Set("X-Content-Type-Options", "nosniff")
84+ rest := strings.TrimPrefix(r.URL.Path, Prefix)
85+ kind, owner, repo, ref, ok := route(rest)
86+ if !ok {
87+ notFound(w)
88+ return
89+ }
90+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
91+ w.Header().Set("Allow", "GET, HEAD")
92+ jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
93+ return
94+ }
95+
96+ ctx := r.Context()
97+ addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
98+ if blocked, _ := h.limiter.Blocked(ctx, addr); blocked {
99+ jsonError(w, http.StatusTooManyRequests, "API rate limit exceeded")
100+ return
101+ }
102+ p, err := h.authenticate(r, addr)
103+ if err != nil {
104+ if errors.Is(err, domain.ErrUnauthenticated) {
105+ jsonError(w, http.StatusUnauthorized, "Bad credentials")
106+ return
107+ }
108+ log := zerowrap.FromCtx(ctx)
109+ log.Error().Err(err).Msg("authenticate github api request")
110+ jsonError(w, http.StatusInternalServerError, "Server Error")
111+ return
112+ }
113+
114+ switch kind {
115+ case "repo":
116+ body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)
117+ if err != nil {
118+ h.fail(ctx, w, err)
119+ return
120+ }
121+ writeJSON(w, r, body)
122+ case "hash":
123+ alg, err := h.cfg.API.HashAlgorithm(ctx, p, owner, repo)
124+ if err != nil {
125+ h.fail(ctx, w, err)
126+ return
127+ }
128+ writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`))
129+ case "tarball":
130+ h.tarball(w, r, p, owner, repo, ref)
131+ }
132+}
133+
134+// route recognises the three endpoints. ref is "" for /tarball.
135+func route(rest string) (kind, owner, repo, ref string, ok bool) {
136+ parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
137+ if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {
138+ return "", "", "", "", false
139+ }
140+ owner, repo = parts[1], parts[2]
141+ switch {
142+ case len(parts) == 3:
143+ return "repo", owner, repo, "", true
144+ case len(parts) == 4 && parts[3] == "hash-algorithm":
145+ return "hash", owner, repo, "", true
146+ case len(parts) >= 4 && parts[3] == "tarball":
147+ // The ref may hold slashes (feature/x): everything after tarball/.
148+ return "tarball", owner, repo, strings.Join(parts[4:], "/"), true
149+ }
150+ return "", "", "", "", false
151+}
152+
153+// tarball streams the archive. It is not a 302 to a codeload: ermite serves the
154+// bytes itself, which the runner follows without a second credential.
155+func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref string) {
156+ ctx := r.Context()
157+ select {
158+ case h.archives <- struct{}{}:
159+ defer func() { <-h.archives }()
160+ default:
161+ w.Header().Set("Retry-After", "5")
162+ jsonError(w, http.StatusServiceUnavailable, "Server busy")
163+ return
164+ }
165+ if r.Method == http.MethodHead {
166+ // Resolve without writing: a HEAD is answered with the headers only.
167+ err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer {
168+ setArchiveHeaders(w, name)
169+ w.WriteHeader(http.StatusOK)
170+ return io.Discard
171+ })
172+ if err != nil && !errors.Is(err, context.Canceled) {
173+ h.fail(ctx, w, err)
174+ }
175+ return
176+ }
177+ started := false
178+ err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer {
179+ started = true
180+ setArchiveHeaders(w, name)
181+ return w
182+ })
183+ switch {
184+ case err == nil:
185+ case started:
186+ // The status is out: the client sees a truncated archive. Log it.
187+ log := zerowrap.FromCtx(ctx)
188+ log.Warn().Err(err).Msg("github api archive cut short")
189+ default:
190+ h.fail(ctx, w, err)
191+ }
192+}
193+
194+func setArchiveHeaders(w http.ResponseWriter, name string) {
195+ w.Header().Set("Content-Type", "application/x-gzip")
196+ w.Header().Set("Content-Disposition", "attachment; filename="+name)
197+}
198+
199+// authenticate reads the credentials of the request. None is the anonymous
200+// principal; credentials that are not valid count as a failure of the client
201+// address.
202+func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) {
203+ secret, present := credentials(r)
204+ if !present {
205+ return domain.Anonymous(), nil
206+ }
207+ p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
208+ if err != nil {
209+ if errors.Is(err, domain.ErrUnauthenticated) {
210+ h.limiter.Fail(r.Context(), addr)
211+ }
212+ return domain.Anonymous(), err
213+ }
214+ return p, nil
215+}
216+
217+// credentials extracts the token of an Authorization header: the password of
218+// Basic (the user is ignored, checkout sends x-access-token), or the value of
219+// "Bearer" or "token".
220+func credentials(r *http.Request) (secret string, present bool) {
221+ auth := r.Header.Get("Authorization")
222+ if auth == "" {
223+ return "", false
224+ }
225+ if _, pass, ok := r.BasicAuth(); ok {
226+ return pass, true
227+ }
228+ if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) {
229+ return strings.TrimSpace(tok), true
230+ }
231+ return "", true
232+}
233+
234+// fail answers an error of a use case. A repository the caller cannot read is
235+// as not found as one that does not exist.
236+func (h *Handler) fail(ctx context.Context, w http.ResponseWriter, err error) {
237+ switch {
238+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
239+ notFound(w)
240+ case errors.Is(err, domain.ErrResourceExhausted):
241+ w.Header().Set("Retry-After", "5")
242+ jsonError(w, http.StatusServiceUnavailable, "Server busy")
243+ case errors.Is(err, context.Canceled):
244+ default:
245+ log := zerowrap.FromCtx(ctx)
246+ log.Error().Err(err).Msg("github api request failed")
247+ jsonError(w, http.StatusInternalServerError, "Server Error")
248+ }
249+}
250+
251+func notFound(w http.ResponseWriter) {
252+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
253+ w.WriteHeader(http.StatusNotFound)
254+ _, _ = io.WriteString(w, notFoundBody)
255+}
256+
257+func jsonError(w http.ResponseWriter, status int, msg string) {
258+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
259+ w.WriteHeader(status)
260+ _, _ = io.WriteString(w, `{"message":`+strconv.Quote(msg)+`,"documentation_url":"https://docs.github.com/rest"}`)
261+}
262+
263+func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) {
264+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
265+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
266+ w.WriteHeader(http.StatusOK)
267+ if r.Method != http.MethodHead {
268+ _, _ = w.Write(body)
269+ }
270+}
A internal/app/ci_execution_test.go +792 -0
@@ -0,0 +1,792 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "encoding/base64"
11+ "encoding/json"
12+ "io"
13+ "net/http"
14+ "path/filepath"
15+ "slices"
16+ "strings"
17+ "sync"
18+ "testing"
19+ "time"
20+
21+ "connectrpc.com/connect"
22+ "github.com/stretchr/testify/assert"
23+ "github.com/stretchr/testify/mock"
24+ "github.com/stretchr/testify/require"
25+
26+ pb "bnema.dev/ermite-api/gen/ermite"
27+ "bnema.dev/ermite/internal/domain"
28+ "bnema.dev/ermite/internal/ports"
29+ "bnema.dev/ermite/internal/ports/mocks"
30+ "bnema.dev/ermite/internal/usecase"
31+)
32+
33+// CI-2 execution (phase E1): the job repository token, the git URLs of the
34+// owner, the REST subset and the resolution of actions.
35+
36+// fakeRemote stands in for github.com: no test reaches the network. It is the
37+// generated mock of ports.GitRemote answering from a table the test edits.
38+type fakeRemote struct {
39+ m *mocks.GitRemote
40+ mu sync.Mutex
41+ refs map[string][]ports.RemoteRef
42+ err map[string]error
43+ calls []string
44+}
45+
46+func newFakeRemote(t *testing.T) *fakeRemote {
47+ t.Helper()
48+ f := &fakeRemote{m: mocks.NewGitRemote(t), refs: map[string][]ports.RemoteRef{}, err: map[string]error{}}
49+ f.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
50+ func(_ context.Context, owner, repo string) ([]ports.RemoteRef, error) {
51+ f.mu.Lock()
52+ defer f.mu.Unlock()
53+ key := strings.ToLower(owner + "/" + repo)
54+ f.calls = append(f.calls, key)
55+ if err := f.err[key]; err != nil {
56+ return nil, err
57+ }
58+ refs, ok := f.refs[key]
59+ if !ok {
60+ return nil, domain.ErrNotFound
61+ }
62+ return refs, nil
63+ }).Maybe()
64+ return f
65+}
66+
67+func (f *fakeRemote) set(repo string, refs ...ports.RemoteRef) {
68+ f.mu.Lock()
69+ defer f.mu.Unlock()
70+ f.refs[repo] = refs
71+}
72+
73+func (f *fakeRemote) asked(key string) bool {
74+ f.mu.Lock()
75+ defer f.mu.Unlock()
76+ return slices.Contains(f.calls, key)
77+}
78+
79+func (f *fakeRemote) count() int {
80+ f.mu.Lock()
81+ defer f.mu.Unlock()
82+ return len(f.calls)
83+}
84+
85+// newExecHarness is newCIHarness with a fake github.com.
86+func newExecHarness(t *testing.T, remote *fakeRemote, tweak func(*usecase.CIConfig)) *ciHarness {
87+ t.Helper()
88+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci, o.remoteLister = tweak, remote.m })
89+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
90+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
91+ require.NoError(t, err)
92+ c.wt = h.workTree("main")
93+ return c
94+}
95+
96+// startJob pushes a workflow and returns the job given to a runner.
97+func (c *ciHarness) startJob(runner *fakeRunner) *pb.Job {
98+ c.t.Helper()
99+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0], "README": "hello\n"}, "")
100+ return runner.await()
101+}
102+
103+func (c *ciHarness) heartbeat(runner *fakeRunner, job *pb.Job) {
104+ c.t.Helper()
105+ _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
106+ require.NoError(c.t, err)
107+}
108+
109+// checkoutHeader is what actions/checkout sets in http.extraheader.
110+func checkoutHeader(token string) string {
111+ return "AUTHORIZATION: basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))
112+}
113+
114+// clone clones url as actions/checkout does: the token in http.extraheader.
115+func (c *ciHarness) clone(token, url string, args ...string) (string, error) {
116+ dest := filepath.Join(c.t.TempDir(), "w")
117+ a := []string{}
118+ if token != "" {
119+ a = append(a, "-c", "http.extraheader="+checkoutHeader(token))
120+ }
121+ a = append(a, "clone", "-q")
122+ a = append(a, args...)
123+ a = append(a, url, dest)
124+ return c.git(c.t.TempDir(), a...)
125+}
126+
127+func (c *ciHarness) get(path, authorization string) (int, string, http.Header) {
128+ c.t.Helper()
129+ req, err := http.NewRequest(http.MethodGet, c.url(path), nil)
130+ require.NoError(c.t, err)
131+ if authorization != "" {
132+ req.Header.Set("Authorization", authorization)
133+ }
134+ resp, err := http.DefaultClient.Do(req)
135+ require.NoError(c.t, err)
136+ defer func() { _ = resp.Body.Close() }()
137+ b, err := io.ReadAll(resp.Body)
138+ require.NoError(c.t, err)
139+ return resp.StatusCode, string(b), resp.Header
140+}
141+
142+func basic(token string) string {
143+ return "Basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))
144+}
145+
146+// AC3: the job gets a repository token at its assignment, in the Job and not in
147+// the contexts; it clones its repository, public or private, over the URL
148+// github.repository gives, with and without .git, the way checkout does.
149+func TestJobTokenClonesTheRepositoryOfTheRun(t *testing.T) {
150+ c := newExecHarness(t, newFakeRemote(t), nil)
151+ runner := c.registerRunner("alpha", "ubuntu-latest")
152+ job := c.startJob(runner)
153+ tok := job.GetGithubToken()
154+ require.True(t, domain.IsRepoToken(tok), "ermitegh_ and 43 base64url characters")
155+ assert.NotEqual(t, job.GetJobToken(), tok)
156+ assert.NotContains(t, job.GetContexts(), tok, "the token does not sit in the contexts the runner stores")
157+ assert.NotContains(t, job.GetWorkflowYaml(), tok)
158+ c.heartbeat(runner, job)
159+
160+ // Private: checkout's clone, shallow, without .git.
161+ out, err := c.clone(tok, c.url("/owner/proj"), "--depth", "1")
162+ require.NoError(t, err, out)
163+ out, err = c.clone(tok, c.url("/owner/proj.git"))
164+ require.NoError(t, err, out)
165+ out, err = c.clone(tok, c.url("/proj.git"))
166+ require.NoError(t, err, "the plain URL works too: "+out)
167+ // Without a token a private repository asks for credentials.
168+ _, err = c.clone("", c.url("/owner/proj"))
169+ require.Error(t, err)
170+ // Another owner has no repositories.
171+ out, err = c.clone(tok, c.url("/someone/proj"))
172+ require.Error(t, err, out)
173+
174+ // The same token and the other schemes, on the wire.
175+ for _, h := range []string{basic(tok), "Bearer " + tok, "token " + tok} {
176+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", h)
177+ assert.Equal(t, http.StatusOK, status, h[:12])
178+ }
179+
180+ // Public: anyone clones, the token too.
181+ vis := domain.VisibilityPublic
182+ _, err = c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis})
183+ require.NoError(t, err)
184+ out, err = c.clone("", c.url("/owner/proj"))
185+ require.NoError(t, err, out)
186+ out, err = c.clone(tok, c.url("/owner/proj"))
187+ require.NoError(t, err, out)
188+}
189+
190+func (c *ciHarness) h(name string) domain.RepoName { return c.name(name) }
191+
192+// AC3: refused on another private repository, for push, and for the REST
193+// subset of another repository; never a stored token for the API.
194+func TestJobTokenReadsItsRepositoryOnly(t *testing.T) {
195+ c := newExecHarness(t, newFakeRemote(t), nil)
196+ c.seed("secret", domain.VisibilityPrivate, "a.txt")
197+ runner := c.registerRunner("alpha", "ubuntu-latest")
198+ job := c.startJob(runner)
199+ tok := job.GetGithubToken()
200+ c.heartbeat(runner, job)
201+
202+ out, err := c.clone(tok, c.url("/owner/secret"))
203+ require.Error(t, err)
204+ assert.Contains(t, out, "not found", "the other private repository does not show itself")
205+ status, _, _ := c.get("/owner/secret/info/refs?service=git-upload-pack", basic(tok))
206+ assert.Equal(t, http.StatusNotFound, status)
207+ status, _, _ = c.get("/-/github/api/repos/owner/secret", basic(tok))
208+ assert.Equal(t, http.StatusNotFound, status)
209+
210+ // Push: the receive-pack advertisement and the push itself are 403.
211+ status, _, _ = c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok))
212+ assert.Equal(t, http.StatusForbidden, status)
213+ c.mustGit(c.wt, "checkout", "-q", "-b", "x")
214+ out, err = c.git(c.wt, "-c", "http.extraheader="+checkoutHeader(tok), "push", c.url("/owner/proj"), "x:refs/heads/x")
215+ require.Error(t, err)
216+ assert.Contains(t, out, "403")
217+ assert.Empty(t, c.bareGit("proj", "branch", "--list", "x"), "nothing was pushed")
218+
219+ // The API takes it as the principal it is (repo:read on one repository): it
220+ // lists that repository and no other, and writes nothing.
221+ list, err := c.api(tok).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{}))
222+ require.NoError(t, err)
223+ require.Len(t, list.Msg.GetRepos(), 1)
224+ assert.Equal(t, "proj", list.Msg.GetRepos()[0].GetName())
225+ _, err = c.api(tok).repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
226+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
227+ _, err = c.api(tok).token.CreateToken(c.ctx, connect.NewRequest(&pb.CreateTokenRequest{Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}}))
228+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
229+ _, err = c.api(tok).token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
230+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
231+ _, err = c.api(tok).run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
232+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no ci:write")
233+
234+ // A made-up token of the right shape is refused, and so are wrong ones.
235+ for _, bad := range []string{"ermitegh_" + strings.Repeat("A", 43), "ermitegh_short", tok + "x"} {
236+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(bad))
237+ assert.Equal(t, http.StatusUnauthorized, status, bad)
238+ }
239+}
240+
241+// The token is not a stored token: ListTokens does not show it, token:admin
242+// does not manage it, and the principal it gives is read-only on one repository.
243+func TestJobTokenIsNotAStoredToken(t *testing.T) {
244+ c := newExecHarness(t, newFakeRemote(t), nil)
245+ runner := c.registerRunner("alpha", "ubuntu-latest")
246+ job := c.startJob(runner)
247+ tok := job.GetGithubToken()
248+
249+ before, err := c.root.token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
250+ require.NoError(t, err)
251+ assert.Empty(t, before.Msg.GetTokens(), "no stored token exists")
252+
253+ p, err := c.forge.Auth.Authenticate(c.ctx, tok)
254+ require.NoError(t, err)
255+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, p.Scopes())
256+ assert.Equal(t, []domain.RepoName{c.h("proj")}, p.Repos())
257+ assert.False(t, p.ExpiresAt().IsZero())
258+ assert.False(t, p.HasScope(domain.ScopeTokenAdmin))
259+ assert.False(t, p.IsRoot())
260+
261+ // Revoking by its id (the "job:" id the principal shows) finds nothing.
262+ _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: strings.TrimPrefix(p.TokenID(), "job:")}))
263+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
264+ _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: p.TokenID()}))
265+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
266+ _, err = c.forge.Auth.Authenticate(c.ctx, tok)
267+ require.NoError(t, err, "still valid")
268+ require.NoError(t, c.forge.Auth.Revalidate(c.ctx, p))
269+}
270+
271+// AC3: refused after the job ends, however it ends.
272+func TestJobTokenEndsWithTheJob(t *testing.T) {
273+ cloneStatus := func(c *ciHarness, tok string) int {
274+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok))
275+ return status
276+ }
277+
278+ t.Run("CompleteJob", func(t *testing.T) {
279+ c := newExecHarness(t, newFakeRemote(t), nil)
280+ runner := c.registerRunner("alpha", "ubuntu-latest")
281+ job := c.startJob(runner)
282+ tok := job.GetGithubToken()
283+ p, err := c.forge.Auth.Authenticate(c.ctx, tok)
284+ require.NoError(t, err)
285+ assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
286+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
287+ assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok))
288+ assert.ErrorIs(t, c.forge.Auth.Revalidate(c.ctx, p), domain.ErrUnauthenticated, "a long operation stops")
289+ out, err := c.clone(tok, c.url("/owner/proj"))
290+ require.Error(t, err, out)
291+ })
292+
293+ t.Run("cancel", func(t *testing.T) {
294+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
295+ cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
296+ })
297+ runner := c.registerRunner("alpha", "ubuntu-latest")
298+ job := c.startJob(runner)
299+ c.heartbeat(runner, job)
300+ tok := job.GetGithubToken()
301+ assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
302+ _, err := c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
303+ require.NoError(t, err)
304+ // The runner is told to stop, and the job concludes when it answers or
305+ // when the grace ends: either way the token goes with it.
306+ c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
307+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, c.runs()[0].GetStatus())
308+ })
309+
310+ t.Run("lost runner", func(t *testing.T) {
311+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
312+ cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
313+ })
314+ runner := c.registerRunner("alpha", "ubuntu-latest")
315+ job := c.startJob(runner)
316+ c.heartbeat(runner, job)
317+ tok := job.GetGithubToken()
318+ assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
319+ c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
320+ assert.Contains(t, c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError(), "lost communication")
321+ })
322+
323+ t.Run("assignment not acknowledged", func(t *testing.T) {
324+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
325+ cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 200*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
326+ })
327+ runner := c.registerRunner("alpha", "ubuntu-latest")
328+ job := c.startJob(runner)
329+ tok := job.GetGithubToken()
330+ c.waitFor("the assignment is taken back", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
331+ again := runner.await()
332+ assert.NotEqual(t, tok, again.GetGithubToken(), "the next assignment has its own token")
333+ assert.Equal(t, http.StatusOK, cloneStatus(c, again.GetGithubToken()))
334+ assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok), "the old one stays revoked")
335+ })
336+
337+ t.Run("repository deleted", func(t *testing.T) {
338+ c := newExecHarness(t, newFakeRemote(t), nil)
339+ runner := c.registerRunner("alpha", "ubuntu-latest")
340+ job := c.startJob(runner)
341+ tok := job.GetGithubToken()
342+ _, err := c.forge.Auth.Authenticate(c.ctx, tok)
343+ require.NoError(t, err)
344+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("proj")))
345+ _, err = c.forge.Auth.Authenticate(c.ctx, tok)
346+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the repository, its runs and their tokens are gone")
347+ assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok))
348+ })
349+
350+ t.Run("expiry", func(t *testing.T) {
351+ // The deadline of the job plus five minutes: a job of one minute.
352+ c := newExecHarness(t, newFakeRemote(t), nil)
353+ runner := c.registerRunner("alpha", "ubuntu-latest")
354+ c.push("main", map[string]string{".github/workflows/t.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n steps:\n - run: echo\n"}, "")
355+ job := runner.await()
356+ p, err := c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken())
357+ require.NoError(t, err)
358+ want := time.Now().Add(time.Minute + domain.CITimeoutSlack + domain.RepoTokenGrace)
359+ assert.WithinDuration(t, want, p.ExpiresAt(), 5*time.Second)
360+ })
361+}
362+
363+// The retry of a poll gets the token of the first call, and a job token of the
364+// protocol never doubles as the repository token.
365+func TestJobTokenSurvivesAPollRetry(t *testing.T) {
366+ c := newExecHarness(t, newFakeRemote(t), nil)
367+ runner := c.registerRunner("alpha", "ubuntu-latest")
368+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
369+ var first *pb.Job
370+ c.waitFor("a job", func() bool {
371+ j, err := runner.fetch("poll-1")
372+ require.NoError(t, err)
373+ first = j
374+ return j != nil
375+ })
376+ again, err := runner.fetch("poll-1")
377+ require.NoError(t, err)
378+ require.NotNil(t, again)
379+ assert.Equal(t, first.GetJobToken(), again.GetJobToken())
380+ assert.Equal(t, first.GetGithubToken(), again.GetGithubToken())
381+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(first.GetJobToken()))
382+ assert.Equal(t, http.StatusUnauthorized, status, "the runner's job token reads nothing")
383+}
384+
385+// The REST subset: GitHub's shapes for what checkout and github-script call,
386+// GitHub's 404 for everything else.
387+func TestGitHubRESTSubset(t *testing.T) {
388+ c := newExecHarness(t, newFakeRemote(t), nil)
389+ runner := c.registerRunner("alpha", "ubuntu-latest")
390+ job := c.startJob(runner)
391+ tok := job.GetGithubToken()
392+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
393+ notFound := `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
394+
395+ // Private: the token reads it, anonymous sees nothing.
396+ status, body, hdr := c.get("/-/github/api/repos/owner/proj", basic(tok))
397+ require.Equal(t, http.StatusOK, status, body)
398+ assert.Contains(t, hdr.Get("Content-Type"), "application/json")
399+ var repo map[string]any
400+ require.NoError(t, json.Unmarshal([]byte(body), &repo))
401+ assert.Equal(t, "proj", repo["name"])
402+ assert.Equal(t, "owner/proj", repo["full_name"])
403+ assert.Equal(t, true, repo["private"])
404+ assert.Equal(t, "private", repo["visibility"])
405+ assert.Equal(t, "main", repo["default_branch"])
406+ assert.Equal(t, "http://example.test/owner/proj", repo["html_url"])
407+ assert.Equal(t, "http://example.test/owner/proj.git", repo["clone_url"])
408+ assert.Equal(t, "http://example.test/-/github/api/repos/owner/proj", repo["url"])
409+ assert.NotZero(t, repo["id"])
410+ assert.NotEmpty(t, repo["node_id"])
411+ owner, _ := repo["owner"].(map[string]any)
412+ assert.Equal(t, "owner", owner["login"])
413+ assert.Equal(t, "User", owner["type"])
414+ assert.Equal(t, false, repo["fork"])
415+ assert.Equal(t, false, repo["archived"])
416+ assert.Equal(t, false, repo["has_issues"] == nil, "present")
417+ assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["created_at"], "REST dates are ISO 8601")
418+ assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["pushed_at"])
419+ perms, _ := repo["permissions"].(map[string]any)
420+ assert.Equal(t, true, perms["pull"])
421+ assert.Equal(t, false, perms["push"], "the job token cannot write")
422+ assert.Equal(t, false, perms["admin"])
423+ assert.NotContains(t, repo, "master_branch", "the push event has it, the REST object does not")
424+
425+ status, body, _ = c.get("/-/github/api/repos/owner/proj", "")
426+ assert.Equal(t, http.StatusNotFound, status, "a private repository does not show itself")
427+ assert.JSONEq(t, notFound, body)
428+ status, _, _ = c.get("/-/github/api/repos/Owner/PROJ", basic(tok))
429+ assert.Equal(t, http.StatusOK, status, "names are case-insensitive as on GitHub")
430+ status, _, _ = c.get("/-/github/api/repos/other/proj", basic(tok))
431+ assert.Equal(t, http.StatusNotFound, status, "another owner")
432+
433+ status, body, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", basic(tok))
434+ assert.Equal(t, http.StatusOK, status)
435+ assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body)
436+ status, _, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", "")
437+ assert.Equal(t, http.StatusNotFound, status)
438+
439+ // The tarball: a gzip tar with one top-level directory, the contents of the
440+ // commit, for a branch, a tag and a full id alike.
441+ c.mustGit(c.wt, "tag", "v1")
442+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
443+ for _, ref := range []string{"main", "v1", sha, ""} {
444+ status, body, hdr := c.get("/-/github/api/repos/owner/proj/tarball/"+ref, basic(tok))
445+ require.Equal(t, http.StatusOK, status, ref)
446+ assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz")
447+ files := untar(t, body)
448+ assert.Equal(t, "hello\n", files["owner-proj-"+sha[:7]+"/README"], ref)
449+ }
450+ status, body, _ = c.get("/-/github/api/repos/owner/proj/tarball/nope", basic(tok))
451+ assert.Equal(t, http.StatusNotFound, status)
452+ assert.JSONEq(t, notFound, body)
453+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "")
454+ assert.Equal(t, http.StatusNotFound, status, "private and anonymous")
455+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
456+ assert.Equal(t, http.StatusOK, status, "any principal that reads the repository")
457+
458+ // A public repository answers anonymous callers.
459+ vis := domain.VisibilityPublic
460+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis})
461+ require.NoError(t, err)
462+ status, body, _ = c.get("/-/github/api/repos/owner/proj", "")
463+ require.Equal(t, http.StatusOK, status)
464+ assert.Contains(t, body, `"visibility":"public"`)
465+ assert.NotContains(t, body, `"permissions"`, "an anonymous caller has none")
466+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "")
467+ assert.Equal(t, http.StatusOK, status)
468+
469+ // Everything else is GitHub's 404, whatever the method.
470+ for _, p := range []string{
471+ "/-/github/api", "/-/github/api/", "/-/github/api/user", "/-/github/api/graphql",
472+ "/-/github/api/repos/owner/proj/issues", "/-/github/api/repos/owner/proj/git/refs/heads/main",
473+ "/-/github/api/repos/owner/proj/zipball/main", "/-/github/api/repos/owner/proj/actions/runs",
474+ "/-/github/api/repos/owner", "/-/github/api/rate_limit", "/-/github/api/repos/owner/proj/contents/README",
475+ } {
476+ status, body, hdr := c.get(p, basic(tok))
477+ assert.Equal(t, http.StatusNotFound, status, p)
478+ assert.JSONEq(t, notFound, body, p)
479+ assert.Contains(t, hdr.Get("Content-Type"), "application/json", p)
480+ }
481+ req, err := http.NewRequest(http.MethodPost, c.url("/-/github/api/repos/owner/proj"), strings.NewReader("{}"))
482+ require.NoError(t, err)
483+ resp, err := http.DefaultClient.Do(req)
484+ require.NoError(t, err)
485+ _ = resp.Body.Close()
486+ assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
487+
488+ // A bad credential is refused as GitHub does.
489+ status, body, _ = c.get("/-/github/api/repos/owner/proj", basic("ermitegh_"+strings.Repeat("A", 43)))
490+ assert.Equal(t, http.StatusUnauthorized, status)
491+ assert.Contains(t, body, "Bad credentials")
492+}
493+
494+func untar(t *testing.T, gz string) map[string]string {
495+ t.Helper()
496+ zr, err := gzip.NewReader(strings.NewReader(gz))
497+ require.NoError(t, err)
498+ tr := tar.NewReader(zr)
499+ files := map[string]string{}
500+ for {
501+ h, err := tr.Next()
502+ if err == io.EOF {
503+ break
504+ }
505+ require.NoError(t, err)
506+ if h.Typeflag != tar.TypeReg {
507+ continue
508+ }
509+ var b bytes.Buffer
510+ _, err = io.Copy(&b, tr)
511+ require.NoError(t, err)
512+ files[h.Name] = b.String()
513+ }
514+ return files
515+}
516+
517+func (c *ciHarness) resolve(runner *fakeRunner, job *pb.Job, uses ...string) []*pb.ResolvedAction {
518+ c.t.Helper()
519+ res, err := c.resolveErr(runner, job, uses...)
520+ require.NoError(c.t, err)
521+ return res
522+}
523+
524+func (c *ciHarness) resolveErr(runner *fakeRunner, job *pb.Job, uses ...string) ([]*pb.ResolvedAction, error) {
525+ refs := make([]*pb.ActionRef, 0, len(uses))
526+ for _, u := range uses {
527+ refs = append(refs, &pb.ActionRef{Uses: u})
528+ }
529+ res, err := runner.jobAPI(job).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: job.GetJobId(), Refs: refs}))
530+ if err != nil {
531+ return nil, err
532+ }
533+ return res.Msg.GetActions(), nil
534+}
535+
536+const (
537+ shaMain = "1111111111111111111111111111111111111111"
538+ shaTag = "2222222222222222222222222222222222222222"
539+ shaTagObj = "3333333333333333333333333333333333333333"
540+ shaPeeled = "4444444444444444444444444444444444444444"
541+ shaOther = "5555555555555555555555555555555555555555"
542+)
543+
544+// AC2, AC4: github.com actions are resolved with ls-remote (tag before branch,
545+// the peeled commit of an annotated tag, a full id as it is), ermite
546+// repositories win, and nothing reaches the network but through the fake.
547+func TestResolveActionsFromGitHub(t *testing.T) {
548+ remote := newFakeRemote(t)
549+ remote.set("actions/checkout",
550+ ports.RemoteRef{Name: "refs/heads/main", ID: shaMain},
551+ ports.RemoteRef{Name: "refs/tags/v4", ID: shaTagObj, Peeled: shaPeeled},
552+ ports.RemoteRef{Name: "refs/tags/v3", ID: shaTag},
553+ ports.RemoteRef{Name: "refs/heads/v3", ID: shaOther}, // a tag and a branch of the same name: the tag
554+ )
555+ c := newExecHarness(t, remote, nil)
556+ runner := c.registerRunner("alpha", "ubuntu-latest")
557+ job := c.startJob(runner)
558+ c.heartbeat(runner, job)
559+
560+ got := c.resolve(runner, job,
561+ "actions/checkout@v4", "actions/checkout@main", "actions/checkout@v3", "Actions/Checkout/sub/dir@v4",
562+ "actions/checkout@"+strings.ToUpper(shaOther), "nobody/unknown-action@"+shaOther, "actions/checkout@v4",
563+ )
564+ require.Len(t, got, 7)
565+ assert.Equal(t, "actions/checkout@v4", got[0].GetRef())
566+ assert.Equal(t, "actions/checkout", got[0].GetNameWithOwner())
567+ assert.Equal(t, shaPeeled, got[0].GetResolvedSha(), "an annotated tag gives its commit")
568+ assert.Equal(t, "https://codeload.github.com/actions/checkout/tar.gz/"+shaPeeled, got[0].GetTarballUrl())
569+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got[0].GetSource())
570+ assert.Empty(t, got[0].GetToken(), "no token goes to github.com")
571+ assert.Empty(t, got[0].GetError())
572+ assert.Equal(t, shaMain, got[1].GetResolvedSha(), "a branch")
573+ assert.Equal(t, shaTag, got[2].GetResolvedSha(), "a tag wins over a branch of the same name")
574+ assert.Equal(t, "Actions/Checkout/sub/dir@v4", got[3].GetRef(), "the reference is returned as written")
575+ assert.Equal(t, "Actions/Checkout", got[3].GetNameWithOwner())
576+ assert.Equal(t, "sub/dir", got[3].GetPath())
577+ assert.Equal(t, shaPeeled, got[3].GetResolvedSha(), "owner and repository are case-insensitive")
578+ assert.Equal(t, strings.ToLower(shaOther), got[4].GetResolvedSha(), "a full commit id is not checked")
579+ assert.Equal(t, strings.ToLower(shaOther), got[5].GetResolvedSha(), "not even for a repository ls-remote does not know")
580+ assert.Equal(t, got[0].GetResolvedSha(), got[6].GetResolvedSha(), "duplicates are answered one by one")
581+ assert.Equal(t, 1, remote.count(), "one ls-remote per repository and call")
582+
583+ // GitHub's words for what cannot be resolved; the call itself succeeds.
584+ bad := c.resolve(runner, job, "actions/checkout@nope", "nobody/missing@v1", "noslash@v1", "a/b", "a/b@", "a/b@c@d")
585+ assert.Equal(t, "Unable to resolve action `actions/checkout@nope`, unable to find version `nope`", bad[0].GetError())
586+ assert.Equal(t, "Unable to resolve action `nobody/missing@v1`, repository not found", bad[1].GetError())
587+ assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'", bad[2].GetError())
588+ assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'a/b'", bad[3].GetError())
589+ assert.NotEmpty(t, bad[4].GetError())
590+ assert.NotEmpty(t, bad[5].GetError())
591+ for _, b := range bad {
592+ assert.Empty(t, b.GetResolvedSha(), b.GetRef())
593+ assert.Empty(t, b.GetTarballUrl(), b.GetRef())
594+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, b.GetSource(), b.GetRef())
595+ }
596+
597+ // github.com unreachable is an error of the call, not of one action.
598+ remote.err["flaky/action"] = domain.ErrUnavailable
599+ _, err := c.resolveErr(runner, job, "flaky/action@v1")
600+ assert.Equal(t, connect.CodeUnavailable, ciErrCode(err))
601+}
602+
603+func TestResolveActionsRejectsWhatTheRunnerHandles(t *testing.T) {
604+ c := newExecHarness(t, newFakeRemote(t), nil)
605+ runner := c.registerRunner("alpha", "ubuntu-latest")
606+ job := c.startJob(runner)
607+ c.heartbeat(runner, job)
608+ for _, u := range []string{"./local", "./.github/actions/x", "docker://alpine:3", "actions/checkout@v4"} {
609+ _, err := c.resolveErr(runner, job, "actions/checkout@"+shaMain, u)
610+ if u == "actions/checkout@v4" {
611+ assert.NoError(t, err)
612+ continue
613+ }
614+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), u)
615+ }
616+ many := make([]string, 101)
617+ for i := range many {
618+ many[i] = "a/b@" + shaMain
619+ }
620+ _, err := c.resolveErr(runner, job, many...)
621+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "at most 100")
622+ many = many[:100]
623+ got, err := c.resolveErr(runner, job, many...)
624+ require.NoError(t, err)
625+ assert.Len(t, got, 100)
626+ got, err = c.resolveErr(runner, job)
627+ require.NoError(t, err)
628+ assert.Empty(t, got)
629+}
630+
631+// AC2: another job's token, a repository token, an ended job and a bad token
632+// are refused.
633+func TestResolveActionsAuthentication(t *testing.T) {
634+ c := newExecHarness(t, newFakeRemote(t), nil)
635+ runner := c.registerRunner("alpha", "ubuntu-latest")
636+ job := c.startJob(runner)
637+ c.heartbeat(runner, job)
638+ call := func(secret, id string) error {
639+ _, err := c.api(secret).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: id, Refs: []*pb.ActionRef{{Uses: "a/b@" + shaMain}}}))
640+ return err
641+ }
642+ assert.NoError(t, call(job.GetJobToken(), job.GetJobId()))
643+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(job.GetGithubToken(), job.GetJobId())), "the repository token is not a job token")
644+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(runner.secret, job.GetJobId())), "nor the runner token")
645+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("ermitejob_"+strings.Repeat("A", 43), job.GetJobId())))
646+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("", job.GetJobId())))
647+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(call(job.GetJobToken(), "7-0-0")), "a token of another job")
648+
649+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
650+ err := call(job.GetJobToken(), job.GetJobId())
651+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "an ended job")
652+ var ce *connect.Error
653+ require.ErrorAs(t, err, &ce)
654+ assert.NotEmpty(t, ce.Details(), "with a JobEnded detail")
655+}
656+
657+// AC4: an action of a repository of ermite wins over github.com, is downloaded
658+// with a per-job read token for that repository only, and the same ref gives the
659+// same commit to every job of the attempt (and a new one to a re-run).
660+func TestResolveActionsFromErmite(t *testing.T) {
661+ remote := newFakeRemote(t)
662+ remote.set("owner/myaction", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite
663+ c := newExecHarness(t, remote, nil)
664+ c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js")
665+ tag := c.bareGit("myaction", "rev-parse", "HEAD")
666+ c.mustGit(c.repoDir("myaction"), "tag", "v1")
667+ c.mustGit(c.repoDir("myaction"), "tag", "-a", "-m", "annotated", "v2")
668+ c.mustGit(c.repoDir("myaction"), "branch", "dev")
669+ c.seed("hidden", domain.VisibilityPrivate, "secret.txt")
670+
671+ runner := c.registerRunner("alpha", "ubuntu-latest")
672+ c.push("main", map[string]string{".github/workflows/m.yml": "on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"}, "")
673+ a, b := runner.await(), runner.await()
674+ c.heartbeat(runner, a)
675+ c.heartbeat(runner, b)
676+
677+ got := c.resolve(runner, a, "owner/myaction@v1", "owner/MyAction/sub@v2", "owner/myaction@dev", "owner/myaction@"+tag, "owner/myaction@gone", "owner/nothere@v1")
678+ require.Len(t, got, 6)
679+ for i := range 4 {
680+ assert.Empty(t, got[i].GetError(), got[i].GetRef())
681+ assert.Equal(t, tag, got[i].GetResolvedSha(), got[i].GetRef())
682+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[i].GetSource(), got[i].GetRef())
683+ assert.True(t, domain.IsRepoToken(got[i].GetToken()), got[i].GetRef())
684+ assert.Equal(t, "http://example.test/-/github/api/repos/owner/myaction/tarball/"+tag, got[i].GetTarballUrl())
685+ }
686+ assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner())
687+ assert.Equal(t, "sub", got[1].GetPath())
688+ assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per repository and job")
689+ assert.Contains(t, got[4].GetError(), "unable to find version `gone`")
690+ assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all")
691+ assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`")
692+ assert.True(t, remote.asked("owner/nothere"), "an owner repository that ermite lacks falls through to github.com")
693+
694+ // The archive token downloads that repository, whatever the private
695+ // repository of the run's token reaches, and nothing else.
696+ arch := got[0].GetToken()
697+ status, body, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch))
698+ require.Equal(t, http.StatusOK, status)
699+ assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"])
700+ status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch))
701+ assert.Equal(t, http.StatusOK, status, "and clones it")
702+ status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch))
703+ assert.Equal(t, http.StatusForbidden, status, "never writes")
704+ status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch))
705+ assert.Equal(t, http.StatusNotFound, status, "another private repository")
706+ status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch))
707+ assert.Equal(t, http.StatusNotFound, status, "not even the repository of the run")
708+ status, _, _ = c.get("/-/github/api/repos/owner/hidden", basic(arch))
709+ assert.Equal(t, http.StatusNotFound, status)
710+ // The job's own token does not read the action repository: this is why the
711+ // archive has a token of its own.
712+ status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(a.GetGithubToken()))
713+ assert.Equal(t, http.StatusNotFound, status)
714+
715+ // Per job: the same commit, another token.
716+ other := c.resolve(runner, b, "owner/myaction@v1")
717+ assert.Equal(t, tag, other[0].GetResolvedSha())
718+ assert.NotEqual(t, arch, other[0].GetToken(), "tokens are not shared between jobs")
719+
720+ // Stable for the attempt: the branch moves, the answer does not.
721+ c.mustGit(c.wt, "checkout", "-q", "-b", "scratch")
722+ c.writeFile(c.wt, "later.txt", []byte("later"))
723+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/myaction.git"), "scratch:refs/heads/dev", "-f")
724+ moved := c.bareGit("myaction", "rev-parse", "dev")
725+ require.NotEqual(t, tag, moved)
726+ again := c.resolve(runner, b, "owner/myaction@dev")
727+ assert.Equal(t, tag, again[0].GetResolvedSha(), "the first resolution of the attempt stands")
728+
729+ // The token goes with its job.
730+ runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
731+ status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch))
732+ assert.Equal(t, http.StatusUnauthorized, status, "revoked with the job")
733+ status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(other[0].GetToken()))
734+ assert.Equal(t, http.StatusOK, status, "the other job's token lives on")
735+}
736+
737+func ptrRef(name, id string) ports.RemoteRef { return ports.RemoteRef{Name: name, ID: id} }
738+
739+// A re-run resolves again, and github.com is asked once for a repository in
740+// each attempt.
741+func TestResolveActionsAreCachedPerAttempt(t *testing.T) {
742+ remote := newFakeRemote(t)
743+ remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaMain))
744+ c := newExecHarness(t, remote, nil)
745+ runner := c.registerRunner("alpha", "ubuntu-latest")
746+ job := c.startJob(runner)
747+ c.heartbeat(runner, job)
748+
749+ assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha())
750+ remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaOther))
751+ assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha(), "stable within the attempt")
752+ assert.Equal(t, 1, remote.count(), "the second call is answered from the attempt's resolution")
753+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
754+
755+ _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
756+ require.NoError(t, err)
757+ job2 := runner.await()
758+ assert.Equal(t, int32(2), job2.GetAttempt())
759+ c.heartbeat(runner, job2)
760+ assert.Equal(t, shaOther, c.resolve(runner, job2, "actions/setup-go@v5")[0].GetResolvedSha(), "a re-run resolves again")
761+ assert.NotEqual(t, job.GetGithubToken(), job2.GetGithubToken())
762+}
763+
764+// AC2: no secret reaches the logs of the server: the repository token, the
765+// archive tokens and the job token, whatever happened with them.
766+func TestRepositoryTokensAreNeverLogged(t *testing.T) {
767+ remote := newFakeRemote(t)
768+ c := newExecHarness(t, remote, nil)
769+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
770+ runner := c.registerRunner("alpha", "ubuntu-latest")
771+ job := c.startJob(runner)
772+ c.heartbeat(runner, job)
773+ tok := job.GetGithubToken()
774+
775+ arch := c.resolve(runner, job, "owner/myaction@main")[0]
776+ require.NotEmpty(t, arch.GetToken())
777+ _, err := c.clone(tok, c.url("/owner/proj"))
778+ require.NoError(t, err)
779+ c.get("/-/github/api/repos/owner/proj", basic(tok))
780+ c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+tok)
781+ c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok))
782+ c.get("/owner/secret/info/refs?service=git-upload-pack", basic("ermitegh_"+strings.Repeat("Z", 43))) // a wrong one
783+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
784+ c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok))
785+
786+ logs := c.logs.String()
787+ require.NotEmpty(t, logs)
788+ for _, secret := range []string{tok, arch.GetToken(), job.GetJobToken(), base64.StdEncoding.EncodeToString([]byte("x-access-token:" + tok))} {
789+ assert.NotContains(t, logs, secret)
790+ }
791+ assert.NotContains(t, logs, strings.Repeat("Z", 43))
792+}
@@ -0,0 +1,792 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "encoding/base64"
11+ "encoding/json"
12+ "io"
13+ "net/http"
14+ "path/filepath"
15+ "slices"
16+ "strings"
17+ "sync"
18+ "testing"
19+ "time"
20+
21+ "connectrpc.com/connect"
22+ "github.com/stretchr/testify/assert"
23+ "github.com/stretchr/testify/mock"
24+ "github.com/stretchr/testify/require"
25+
26+ pb "bnema.dev/ermite-api/gen/ermite"
27+ "bnema.dev/ermite/internal/domain"
28+ "bnema.dev/ermite/internal/ports"
29+ "bnema.dev/ermite/internal/ports/mocks"
30+ "bnema.dev/ermite/internal/usecase"
31+)
32+
33+// CI-2 execution (phase E1): the job repository token, the git URLs of the
34+// owner, the REST subset and the resolution of actions.
35+
36+// fakeRemote stands in for github.com: no test reaches the network. It is the
37+// generated mock of ports.GitRemote answering from a table the test edits.
38+type fakeRemote struct {
39+ m *mocks.GitRemote
40+ mu sync.Mutex
41+ refs map[string][]ports.RemoteRef
42+ err map[string]error
43+ calls []string
44+}
45+
46+func newFakeRemote(t *testing.T) *fakeRemote {
47+ t.Helper()
48+ f := &fakeRemote{m: mocks.NewGitRemote(t), refs: map[string][]ports.RemoteRef{}, err: map[string]error{}}
49+ f.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
50+ func(_ context.Context, owner, repo string) ([]ports.RemoteRef, error) {
51+ f.mu.Lock()
52+ defer f.mu.Unlock()
53+ key := strings.ToLower(owner + "/" + repo)
54+ f.calls = append(f.calls, key)
55+ if err := f.err[key]; err != nil {
56+ return nil, err
57+ }
58+ refs, ok := f.refs[key]
59+ if !ok {
60+ return nil, domain.ErrNotFound
61+ }
62+ return refs, nil
63+ }).Maybe()
64+ return f
65+}
66+
67+func (f *fakeRemote) set(repo string, refs ...ports.RemoteRef) {
68+ f.mu.Lock()
69+ defer f.mu.Unlock()
70+ f.refs[repo] = refs
71+}
72+
73+func (f *fakeRemote) asked(key string) bool {
74+ f.mu.Lock()
75+ defer f.mu.Unlock()
76+ return slices.Contains(f.calls, key)
77+}
78+
79+func (f *fakeRemote) count() int {
80+ f.mu.Lock()
81+ defer f.mu.Unlock()
82+ return len(f.calls)
83+}
84+
85+// newExecHarness is newCIHarness with a fake github.com.
86+func newExecHarness(t *testing.T, remote *fakeRemote, tweak func(*usecase.CIConfig)) *ciHarness {
87+ t.Helper()
88+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci, o.remoteLister = tweak, remote.m })
89+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
90+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
91+ require.NoError(t, err)
92+ c.wt = h.workTree("main")
93+ return c
94+}
95+
96+// startJob pushes a workflow and returns the job given to a runner.
97+func (c *ciHarness) startJob(runner *fakeRunner) *pb.Job {
98+ c.t.Helper()
99+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0], "README": "hello\n"}, "")
100+ return runner.await()
101+}
102+
103+func (c *ciHarness) heartbeat(runner *fakeRunner, job *pb.Job) {
104+ c.t.Helper()
105+ _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
106+ require.NoError(c.t, err)
107+}
108+
109+// checkoutHeader is what actions/checkout sets in http.extraheader.
110+func checkoutHeader(token string) string {
111+ return "AUTHORIZATION: basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))
112+}
113+
114+// clone clones url as actions/checkout does: the token in http.extraheader.
115+func (c *ciHarness) clone(token, url string, args ...string) (string, error) {
116+ dest := filepath.Join(c.t.TempDir(), "w")
117+ a := []string{}
118+ if token != "" {
119+ a = append(a, "-c", "http.extraheader="+checkoutHeader(token))
120+ }
121+ a = append(a, "clone", "-q")
122+ a = append(a, args...)
123+ a = append(a, url, dest)
124+ return c.git(c.t.TempDir(), a...)
125+}
126+
127+func (c *ciHarness) get(path, authorization string) (int, string, http.Header) {
128+ c.t.Helper()
129+ req, err := http.NewRequest(http.MethodGet, c.url(path), nil)
130+ require.NoError(c.t, err)
131+ if authorization != "" {
132+ req.Header.Set("Authorization", authorization)
133+ }
134+ resp, err := http.DefaultClient.Do(req)
135+ require.NoError(c.t, err)
136+ defer func() { _ = resp.Body.Close() }()
137+ b, err := io.ReadAll(resp.Body)
138+ require.NoError(c.t, err)
139+ return resp.StatusCode, string(b), resp.Header
140+}
141+
142+func basic(token string) string {
143+ return "Basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))
144+}
145+
146+// AC3: the job gets a repository token at its assignment, in the Job and not in
147+// the contexts; it clones its repository, public or private, over the URL
148+// github.repository gives, with and without .git, the way checkout does.
149+func TestJobTokenClonesTheRepositoryOfTheRun(t *testing.T) {
150+ c := newExecHarness(t, newFakeRemote(t), nil)
151+ runner := c.registerRunner("alpha", "ubuntu-latest")
152+ job := c.startJob(runner)
153+ tok := job.GetGithubToken()
154+ require.True(t, domain.IsRepoToken(tok), "ermitegh_ and 43 base64url characters")
155+ assert.NotEqual(t, job.GetJobToken(), tok)
156+ assert.NotContains(t, job.GetContexts(), tok, "the token does not sit in the contexts the runner stores")
157+ assert.NotContains(t, job.GetWorkflowYaml(), tok)
158+ c.heartbeat(runner, job)
159+
160+ // Private: checkout's clone, shallow, without .git.
161+ out, err := c.clone(tok, c.url("/owner/proj"), "--depth", "1")
162+ require.NoError(t, err, out)
163+ out, err = c.clone(tok, c.url("/owner/proj.git"))
164+ require.NoError(t, err, out)
165+ out, err = c.clone(tok, c.url("/proj.git"))
166+ require.NoError(t, err, "the plain URL works too: "+out)
167+ // Without a token a private repository asks for credentials.
168+ _, err = c.clone("", c.url("/owner/proj"))
169+ require.Error(t, err)
170+ // Another owner has no repositories.
171+ out, err = c.clone(tok, c.url("/someone/proj"))
172+ require.Error(t, err, out)
173+
174+ // The same token and the other schemes, on the wire.
175+ for _, h := range []string{basic(tok), "Bearer " + tok, "token " + tok} {
176+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", h)
177+ assert.Equal(t, http.StatusOK, status, h[:12])
178+ }
179+
180+ // Public: anyone clones, the token too.
181+ vis := domain.VisibilityPublic
182+ _, err = c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis})
183+ require.NoError(t, err)
184+ out, err = c.clone("", c.url("/owner/proj"))
185+ require.NoError(t, err, out)
186+ out, err = c.clone(tok, c.url("/owner/proj"))
187+ require.NoError(t, err, out)
188+}
189+
190+func (c *ciHarness) h(name string) domain.RepoName { return c.name(name) }
191+
192+// AC3: refused on another private repository, for push, and for the REST
193+// subset of another repository; never a stored token for the API.
194+func TestJobTokenReadsItsRepositoryOnly(t *testing.T) {
195+ c := newExecHarness(t, newFakeRemote(t), nil)
196+ c.seed("secret", domain.VisibilityPrivate, "a.txt")
197+ runner := c.registerRunner("alpha", "ubuntu-latest")
198+ job := c.startJob(runner)
199+ tok := job.GetGithubToken()
200+ c.heartbeat(runner, job)
201+
202+ out, err := c.clone(tok, c.url("/owner/secret"))
203+ require.Error(t, err)
204+ assert.Contains(t, out, "not found", "the other private repository does not show itself")
205+ status, _, _ := c.get("/owner/secret/info/refs?service=git-upload-pack", basic(tok))
206+ assert.Equal(t, http.StatusNotFound, status)
207+ status, _, _ = c.get("/-/github/api/repos/owner/secret", basic(tok))
208+ assert.Equal(t, http.StatusNotFound, status)
209+
210+ // Push: the receive-pack advertisement and the push itself are 403.
211+ status, _, _ = c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok))
212+ assert.Equal(t, http.StatusForbidden, status)
213+ c.mustGit(c.wt, "checkout", "-q", "-b", "x")
214+ out, err = c.git(c.wt, "-c", "http.extraheader="+checkoutHeader(tok), "push", c.url("/owner/proj"), "x:refs/heads/x")
215+ require.Error(t, err)
216+ assert.Contains(t, out, "403")
217+ assert.Empty(t, c.bareGit("proj", "branch", "--list", "x"), "nothing was pushed")
218+
219+ // The API takes it as the principal it is (repo:read on one repository): it
220+ // lists that repository and no other, and writes nothing.
221+ list, err := c.api(tok).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{}))
222+ require.NoError(t, err)
223+ require.Len(t, list.Msg.GetRepos(), 1)
224+ assert.Equal(t, "proj", list.Msg.GetRepos()[0].GetName())
225+ _, err = c.api(tok).repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
226+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
227+ _, err = c.api(tok).token.CreateToken(c.ctx, connect.NewRequest(&pb.CreateTokenRequest{Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}}))
228+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
229+ _, err = c.api(tok).token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
230+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
231+ _, err = c.api(tok).run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
232+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no ci:write")
233+
234+ // A made-up token of the right shape is refused, and so are wrong ones.
235+ for _, bad := range []string{"ermitegh_" + strings.Repeat("A", 43), "ermitegh_short", tok + "x"} {
236+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(bad))
237+ assert.Equal(t, http.StatusUnauthorized, status, bad)
238+ }
239+}
240+
241+// The token is not a stored token: ListTokens does not show it, token:admin
242+// does not manage it, and the principal it gives is read-only on one repository.
243+func TestJobTokenIsNotAStoredToken(t *testing.T) {
244+ c := newExecHarness(t, newFakeRemote(t), nil)
245+ runner := c.registerRunner("alpha", "ubuntu-latest")
246+ job := c.startJob(runner)
247+ tok := job.GetGithubToken()
248+
249+ before, err := c.root.token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
250+ require.NoError(t, err)
251+ assert.Empty(t, before.Msg.GetTokens(), "no stored token exists")
252+
253+ p, err := c.forge.Auth.Authenticate(c.ctx, tok)
254+ require.NoError(t, err)
255+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, p.Scopes())
256+ assert.Equal(t, []domain.RepoName{c.h("proj")}, p.Repos())
257+ assert.False(t, p.ExpiresAt().IsZero())
258+ assert.False(t, p.HasScope(domain.ScopeTokenAdmin))
259+ assert.False(t, p.IsRoot())
260+
261+ // Revoking by its id (the "job:" id the principal shows) finds nothing.
262+ _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: strings.TrimPrefix(p.TokenID(), "job:")}))
263+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
264+ _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: p.TokenID()}))
265+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
266+ _, err = c.forge.Auth.Authenticate(c.ctx, tok)
267+ require.NoError(t, err, "still valid")
268+ require.NoError(t, c.forge.Auth.Revalidate(c.ctx, p))
269+}
270+
271+// AC3: refused after the job ends, however it ends.
272+func TestJobTokenEndsWithTheJob(t *testing.T) {
273+ cloneStatus := func(c *ciHarness, tok string) int {
274+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok))
275+ return status
276+ }
277+
278+ t.Run("CompleteJob", func(t *testing.T) {
279+ c := newExecHarness(t, newFakeRemote(t), nil)
280+ runner := c.registerRunner("alpha", "ubuntu-latest")
281+ job := c.startJob(runner)
282+ tok := job.GetGithubToken()
283+ p, err := c.forge.Auth.Authenticate(c.ctx, tok)
284+ require.NoError(t, err)
285+ assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
286+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
287+ assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok))
288+ assert.ErrorIs(t, c.forge.Auth.Revalidate(c.ctx, p), domain.ErrUnauthenticated, "a long operation stops")
289+ out, err := c.clone(tok, c.url("/owner/proj"))
290+ require.Error(t, err, out)
291+ })
292+
293+ t.Run("cancel", func(t *testing.T) {
294+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
295+ cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
296+ })
297+ runner := c.registerRunner("alpha", "ubuntu-latest")
298+ job := c.startJob(runner)
299+ c.heartbeat(runner, job)
300+ tok := job.GetGithubToken()
301+ assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
302+ _, err := c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
303+ require.NoError(t, err)
304+ // The runner is told to stop, and the job concludes when it answers or
305+ // when the grace ends: either way the token goes with it.
306+ c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
307+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, c.runs()[0].GetStatus())
308+ })
309+
310+ t.Run("lost runner", func(t *testing.T) {
311+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
312+ cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
313+ })
314+ runner := c.registerRunner("alpha", "ubuntu-latest")
315+ job := c.startJob(runner)
316+ c.heartbeat(runner, job)
317+ tok := job.GetGithubToken()
318+ assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
319+ c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
320+ assert.Contains(t, c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError(), "lost communication")
321+ })
322+
323+ t.Run("assignment not acknowledged", func(t *testing.T) {
324+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
325+ cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 200*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
326+ })
327+ runner := c.registerRunner("alpha", "ubuntu-latest")
328+ job := c.startJob(runner)
329+ tok := job.GetGithubToken()
330+ c.waitFor("the assignment is taken back", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
331+ again := runner.await()
332+ assert.NotEqual(t, tok, again.GetGithubToken(), "the next assignment has its own token")
333+ assert.Equal(t, http.StatusOK, cloneStatus(c, again.GetGithubToken()))
334+ assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok), "the old one stays revoked")
335+ })
336+
337+ t.Run("repository deleted", func(t *testing.T) {
338+ c := newExecHarness(t, newFakeRemote(t), nil)
339+ runner := c.registerRunner("alpha", "ubuntu-latest")
340+ job := c.startJob(runner)
341+ tok := job.GetGithubToken()
342+ _, err := c.forge.Auth.Authenticate(c.ctx, tok)
343+ require.NoError(t, err)
344+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("proj")))
345+ _, err = c.forge.Auth.Authenticate(c.ctx, tok)
346+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the repository, its runs and their tokens are gone")
347+ assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok))
348+ })
349+
350+ t.Run("expiry", func(t *testing.T) {
351+ // The deadline of the job plus five minutes: a job of one minute.
352+ c := newExecHarness(t, newFakeRemote(t), nil)
353+ runner := c.registerRunner("alpha", "ubuntu-latest")
354+ c.push("main", map[string]string{".github/workflows/t.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n steps:\n - run: echo\n"}, "")
355+ job := runner.await()
356+ p, err := c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken())
357+ require.NoError(t, err)
358+ want := time.Now().Add(time.Minute + domain.CITimeoutSlack + domain.RepoTokenGrace)
359+ assert.WithinDuration(t, want, p.ExpiresAt(), 5*time.Second)
360+ })
361+}
362+
363+// The retry of a poll gets the token of the first call, and a job token of the
364+// protocol never doubles as the repository token.
365+func TestJobTokenSurvivesAPollRetry(t *testing.T) {
366+ c := newExecHarness(t, newFakeRemote(t), nil)
367+ runner := c.registerRunner("alpha", "ubuntu-latest")
368+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
369+ var first *pb.Job
370+ c.waitFor("a job", func() bool {
371+ j, err := runner.fetch("poll-1")
372+ require.NoError(t, err)
373+ first = j
374+ return j != nil
375+ })
376+ again, err := runner.fetch("poll-1")
377+ require.NoError(t, err)
378+ require.NotNil(t, again)
379+ assert.Equal(t, first.GetJobToken(), again.GetJobToken())
380+ assert.Equal(t, first.GetGithubToken(), again.GetGithubToken())
381+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(first.GetJobToken()))
382+ assert.Equal(t, http.StatusUnauthorized, status, "the runner's job token reads nothing")
383+}
384+
385+// The REST subset: GitHub's shapes for what checkout and github-script call,
386+// GitHub's 404 for everything else.
387+func TestGitHubRESTSubset(t *testing.T) {
388+ c := newExecHarness(t, newFakeRemote(t), nil)
389+ runner := c.registerRunner("alpha", "ubuntu-latest")
390+ job := c.startJob(runner)
391+ tok := job.GetGithubToken()
392+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
393+ notFound := `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
394+
395+ // Private: the token reads it, anonymous sees nothing.
396+ status, body, hdr := c.get("/-/github/api/repos/owner/proj", basic(tok))
397+ require.Equal(t, http.StatusOK, status, body)
398+ assert.Contains(t, hdr.Get("Content-Type"), "application/json")
399+ var repo map[string]any
400+ require.NoError(t, json.Unmarshal([]byte(body), &repo))
401+ assert.Equal(t, "proj", repo["name"])
402+ assert.Equal(t, "owner/proj", repo["full_name"])
403+ assert.Equal(t, true, repo["private"])
404+ assert.Equal(t, "private", repo["visibility"])
405+ assert.Equal(t, "main", repo["default_branch"])
406+ assert.Equal(t, "http://example.test/owner/proj", repo["html_url"])
407+ assert.Equal(t, "http://example.test/owner/proj.git", repo["clone_url"])
408+ assert.Equal(t, "http://example.test/-/github/api/repos/owner/proj", repo["url"])
409+ assert.NotZero(t, repo["id"])
410+ assert.NotEmpty(t, repo["node_id"])
411+ owner, _ := repo["owner"].(map[string]any)
412+ assert.Equal(t, "owner", owner["login"])
413+ assert.Equal(t, "User", owner["type"])
414+ assert.Equal(t, false, repo["fork"])
415+ assert.Equal(t, false, repo["archived"])
416+ assert.Equal(t, false, repo["has_issues"] == nil, "present")
417+ assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["created_at"], "REST dates are ISO 8601")
418+ assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["pushed_at"])
419+ perms, _ := repo["permissions"].(map[string]any)
420+ assert.Equal(t, true, perms["pull"])
421+ assert.Equal(t, false, perms["push"], "the job token cannot write")
422+ assert.Equal(t, false, perms["admin"])
423+ assert.NotContains(t, repo, "master_branch", "the push event has it, the REST object does not")
424+
425+ status, body, _ = c.get("/-/github/api/repos/owner/proj", "")
426+ assert.Equal(t, http.StatusNotFound, status, "a private repository does not show itself")
427+ assert.JSONEq(t, notFound, body)
428+ status, _, _ = c.get("/-/github/api/repos/Owner/PROJ", basic(tok))
429+ assert.Equal(t, http.StatusOK, status, "names are case-insensitive as on GitHub")
430+ status, _, _ = c.get("/-/github/api/repos/other/proj", basic(tok))
431+ assert.Equal(t, http.StatusNotFound, status, "another owner")
432+
433+ status, body, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", basic(tok))
434+ assert.Equal(t, http.StatusOK, status)
435+ assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body)
436+ status, _, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", "")
437+ assert.Equal(t, http.StatusNotFound, status)
438+
439+ // The tarball: a gzip tar with one top-level directory, the contents of the
440+ // commit, for a branch, a tag and a full id alike.
441+ c.mustGit(c.wt, "tag", "v1")
442+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
443+ for _, ref := range []string{"main", "v1", sha, ""} {
444+ status, body, hdr := c.get("/-/github/api/repos/owner/proj/tarball/"+ref, basic(tok))
445+ require.Equal(t, http.StatusOK, status, ref)
446+ assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz")
447+ files := untar(t, body)
448+ assert.Equal(t, "hello\n", files["owner-proj-"+sha[:7]+"/README"], ref)
449+ }
450+ status, body, _ = c.get("/-/github/api/repos/owner/proj/tarball/nope", basic(tok))
451+ assert.Equal(t, http.StatusNotFound, status)
452+ assert.JSONEq(t, notFound, body)
453+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "")
454+ assert.Equal(t, http.StatusNotFound, status, "private and anonymous")
455+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
456+ assert.Equal(t, http.StatusOK, status, "any principal that reads the repository")
457+
458+ // A public repository answers anonymous callers.
459+ vis := domain.VisibilityPublic
460+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis})
461+ require.NoError(t, err)
462+ status, body, _ = c.get("/-/github/api/repos/owner/proj", "")
463+ require.Equal(t, http.StatusOK, status)
464+ assert.Contains(t, body, `"visibility":"public"`)
465+ assert.NotContains(t, body, `"permissions"`, "an anonymous caller has none")
466+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "")
467+ assert.Equal(t, http.StatusOK, status)
468+
469+ // Everything else is GitHub's 404, whatever the method.
470+ for _, p := range []string{
471+ "/-/github/api", "/-/github/api/", "/-/github/api/user", "/-/github/api/graphql",
472+ "/-/github/api/repos/owner/proj/issues", "/-/github/api/repos/owner/proj/git/refs/heads/main",
473+ "/-/github/api/repos/owner/proj/zipball/main", "/-/github/api/repos/owner/proj/actions/runs",
474+ "/-/github/api/repos/owner", "/-/github/api/rate_limit", "/-/github/api/repos/owner/proj/contents/README",
475+ } {
476+ status, body, hdr := c.get(p, basic(tok))
477+ assert.Equal(t, http.StatusNotFound, status, p)
478+ assert.JSONEq(t, notFound, body, p)
479+ assert.Contains(t, hdr.Get("Content-Type"), "application/json", p)
480+ }
481+ req, err := http.NewRequest(http.MethodPost, c.url("/-/github/api/repos/owner/proj"), strings.NewReader("{}"))
482+ require.NoError(t, err)
483+ resp, err := http.DefaultClient.Do(req)
484+ require.NoError(t, err)
485+ _ = resp.Body.Close()
486+ assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
487+
488+ // A bad credential is refused as GitHub does.
489+ status, body, _ = c.get("/-/github/api/repos/owner/proj", basic("ermitegh_"+strings.Repeat("A", 43)))
490+ assert.Equal(t, http.StatusUnauthorized, status)
491+ assert.Contains(t, body, "Bad credentials")
492+}
493+
494+func untar(t *testing.T, gz string) map[string]string {
495+ t.Helper()
496+ zr, err := gzip.NewReader(strings.NewReader(gz))
497+ require.NoError(t, err)
498+ tr := tar.NewReader(zr)
499+ files := map[string]string{}
500+ for {
501+ h, err := tr.Next()
502+ if err == io.EOF {
503+ break
504+ }
505+ require.NoError(t, err)
506+ if h.Typeflag != tar.TypeReg {
507+ continue
508+ }
509+ var b bytes.Buffer
510+ _, err = io.Copy(&b, tr)
511+ require.NoError(t, err)
512+ files[h.Name] = b.String()
513+ }
514+ return files
515+}
516+
517+func (c *ciHarness) resolve(runner *fakeRunner, job *pb.Job, uses ...string) []*pb.ResolvedAction {
518+ c.t.Helper()
519+ res, err := c.resolveErr(runner, job, uses...)
520+ require.NoError(c.t, err)
521+ return res
522+}
523+
524+func (c *ciHarness) resolveErr(runner *fakeRunner, job *pb.Job, uses ...string) ([]*pb.ResolvedAction, error) {
525+ refs := make([]*pb.ActionRef, 0, len(uses))
526+ for _, u := range uses {
527+ refs = append(refs, &pb.ActionRef{Uses: u})
528+ }
529+ res, err := runner.jobAPI(job).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: job.GetJobId(), Refs: refs}))
530+ if err != nil {
531+ return nil, err
532+ }
533+ return res.Msg.GetActions(), nil
534+}
535+
536+const (
537+ shaMain = "1111111111111111111111111111111111111111"
538+ shaTag = "2222222222222222222222222222222222222222"
539+ shaTagObj = "3333333333333333333333333333333333333333"
540+ shaPeeled = "4444444444444444444444444444444444444444"
541+ shaOther = "5555555555555555555555555555555555555555"
542+)
543+
544+// AC2, AC4: github.com actions are resolved with ls-remote (tag before branch,
545+// the peeled commit of an annotated tag, a full id as it is), ermite
546+// repositories win, and nothing reaches the network but through the fake.
547+func TestResolveActionsFromGitHub(t *testing.T) {
548+ remote := newFakeRemote(t)
549+ remote.set("actions/checkout",
550+ ports.RemoteRef{Name: "refs/heads/main", ID: shaMain},
551+ ports.RemoteRef{Name: "refs/tags/v4", ID: shaTagObj, Peeled: shaPeeled},
552+ ports.RemoteRef{Name: "refs/tags/v3", ID: shaTag},
553+ ports.RemoteRef{Name: "refs/heads/v3", ID: shaOther}, // a tag and a branch of the same name: the tag
554+ )
555+ c := newExecHarness(t, remote, nil)
556+ runner := c.registerRunner("alpha", "ubuntu-latest")
557+ job := c.startJob(runner)
558+ c.heartbeat(runner, job)
559+
560+ got := c.resolve(runner, job,
561+ "actions/checkout@v4", "actions/checkout@main", "actions/checkout@v3", "Actions/Checkout/sub/dir@v4",
562+ "actions/checkout@"+strings.ToUpper(shaOther), "nobody/unknown-action@"+shaOther, "actions/checkout@v4",
563+ )
564+ require.Len(t, got, 7)
565+ assert.Equal(t, "actions/checkout@v4", got[0].GetRef())
566+ assert.Equal(t, "actions/checkout", got[0].GetNameWithOwner())
567+ assert.Equal(t, shaPeeled, got[0].GetResolvedSha(), "an annotated tag gives its commit")
568+ assert.Equal(t, "https://codeload.github.com/actions/checkout/tar.gz/"+shaPeeled, got[0].GetTarballUrl())
569+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got[0].GetSource())
570+ assert.Empty(t, got[0].GetToken(), "no token goes to github.com")
571+ assert.Empty(t, got[0].GetError())
572+ assert.Equal(t, shaMain, got[1].GetResolvedSha(), "a branch")
573+ assert.Equal(t, shaTag, got[2].GetResolvedSha(), "a tag wins over a branch of the same name")
574+ assert.Equal(t, "Actions/Checkout/sub/dir@v4", got[3].GetRef(), "the reference is returned as written")
575+ assert.Equal(t, "Actions/Checkout", got[3].GetNameWithOwner())
576+ assert.Equal(t, "sub/dir", got[3].GetPath())
577+ assert.Equal(t, shaPeeled, got[3].GetResolvedSha(), "owner and repository are case-insensitive")
578+ assert.Equal(t, strings.ToLower(shaOther), got[4].GetResolvedSha(), "a full commit id is not checked")
579+ assert.Equal(t, strings.ToLower(shaOther), got[5].GetResolvedSha(), "not even for a repository ls-remote does not know")
580+ assert.Equal(t, got[0].GetResolvedSha(), got[6].GetResolvedSha(), "duplicates are answered one by one")
581+ assert.Equal(t, 1, remote.count(), "one ls-remote per repository and call")
582+
583+ // GitHub's words for what cannot be resolved; the call itself succeeds.
584+ bad := c.resolve(runner, job, "actions/checkout@nope", "nobody/missing@v1", "noslash@v1", "a/b", "a/b@", "a/b@c@d")
585+ assert.Equal(t, "Unable to resolve action `actions/checkout@nope`, unable to find version `nope`", bad[0].GetError())
586+ assert.Equal(t, "Unable to resolve action `nobody/missing@v1`, repository not found", bad[1].GetError())
587+ assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'", bad[2].GetError())
588+ assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'a/b'", bad[3].GetError())
589+ assert.NotEmpty(t, bad[4].GetError())
590+ assert.NotEmpty(t, bad[5].GetError())
591+ for _, b := range bad {
592+ assert.Empty(t, b.GetResolvedSha(), b.GetRef())
593+ assert.Empty(t, b.GetTarballUrl(), b.GetRef())
594+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, b.GetSource(), b.GetRef())
595+ }
596+
597+ // github.com unreachable is an error of the call, not of one action.
598+ remote.err["flaky/action"] = domain.ErrUnavailable
599+ _, err := c.resolveErr(runner, job, "flaky/action@v1")
600+ assert.Equal(t, connect.CodeUnavailable, ciErrCode(err))
601+}
602+
603+func TestResolveActionsRejectsWhatTheRunnerHandles(t *testing.T) {
604+ c := newExecHarness(t, newFakeRemote(t), nil)
605+ runner := c.registerRunner("alpha", "ubuntu-latest")
606+ job := c.startJob(runner)
607+ c.heartbeat(runner, job)
608+ for _, u := range []string{"./local", "./.github/actions/x", "docker://alpine:3", "actions/checkout@v4"} {
609+ _, err := c.resolveErr(runner, job, "actions/checkout@"+shaMain, u)
610+ if u == "actions/checkout@v4" {
611+ assert.NoError(t, err)
612+ continue
613+ }
614+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), u)
615+ }
616+ many := make([]string, 101)
617+ for i := range many {
618+ many[i] = "a/b@" + shaMain
619+ }
620+ _, err := c.resolveErr(runner, job, many...)
621+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "at most 100")
622+ many = many[:100]
623+ got, err := c.resolveErr(runner, job, many...)
624+ require.NoError(t, err)
625+ assert.Len(t, got, 100)
626+ got, err = c.resolveErr(runner, job)
627+ require.NoError(t, err)
628+ assert.Empty(t, got)
629+}
630+
631+// AC2: another job's token, a repository token, an ended job and a bad token
632+// are refused.
633+func TestResolveActionsAuthentication(t *testing.T) {
634+ c := newExecHarness(t, newFakeRemote(t), nil)
635+ runner := c.registerRunner("alpha", "ubuntu-latest")
636+ job := c.startJob(runner)
637+ c.heartbeat(runner, job)
638+ call := func(secret, id string) error {
639+ _, err := c.api(secret).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: id, Refs: []*pb.ActionRef{{Uses: "a/b@" + shaMain}}}))
640+ return err
641+ }
642+ assert.NoError(t, call(job.GetJobToken(), job.GetJobId()))
643+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(job.GetGithubToken(), job.GetJobId())), "the repository token is not a job token")
644+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(runner.secret, job.GetJobId())), "nor the runner token")
645+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("ermitejob_"+strings.Repeat("A", 43), job.GetJobId())))
646+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("", job.GetJobId())))
647+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(call(job.GetJobToken(), "7-0-0")), "a token of another job")
648+
649+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
650+ err := call(job.GetJobToken(), job.GetJobId())
651+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "an ended job")
652+ var ce *connect.Error
653+ require.ErrorAs(t, err, &ce)
654+ assert.NotEmpty(t, ce.Details(), "with a JobEnded detail")
655+}
656+
657+// AC4: an action of a repository of ermite wins over github.com, is downloaded
658+// with a per-job read token for that repository only, and the same ref gives the
659+// same commit to every job of the attempt (and a new one to a re-run).
660+func TestResolveActionsFromErmite(t *testing.T) {
661+ remote := newFakeRemote(t)
662+ remote.set("owner/myaction", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite
663+ c := newExecHarness(t, remote, nil)
664+ c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js")
665+ tag := c.bareGit("myaction", "rev-parse", "HEAD")
666+ c.mustGit(c.repoDir("myaction"), "tag", "v1")
667+ c.mustGit(c.repoDir("myaction"), "tag", "-a", "-m", "annotated", "v2")
668+ c.mustGit(c.repoDir("myaction"), "branch", "dev")
669+ c.seed("hidden", domain.VisibilityPrivate, "secret.txt")
670+
671+ runner := c.registerRunner("alpha", "ubuntu-latest")
672+ c.push("main", map[string]string{".github/workflows/m.yml": "on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"}, "")
673+ a, b := runner.await(), runner.await()
674+ c.heartbeat(runner, a)
675+ c.heartbeat(runner, b)
676+
677+ got := c.resolve(runner, a, "owner/myaction@v1", "owner/MyAction/sub@v2", "owner/myaction@dev", "owner/myaction@"+tag, "owner/myaction@gone", "owner/nothere@v1")
678+ require.Len(t, got, 6)
679+ for i := range 4 {
680+ assert.Empty(t, got[i].GetError(), got[i].GetRef())
681+ assert.Equal(t, tag, got[i].GetResolvedSha(), got[i].GetRef())
682+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[i].GetSource(), got[i].GetRef())
683+ assert.True(t, domain.IsRepoToken(got[i].GetToken()), got[i].GetRef())
684+ assert.Equal(t, "http://example.test/-/github/api/repos/owner/myaction/tarball/"+tag, got[i].GetTarballUrl())
685+ }
686+ assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner())
687+ assert.Equal(t, "sub", got[1].GetPath())
688+ assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per repository and job")
689+ assert.Contains(t, got[4].GetError(), "unable to find version `gone`")
690+ assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all")
691+ assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`")
692+ assert.True(t, remote.asked("owner/nothere"), "an owner repository that ermite lacks falls through to github.com")
693+
694+ // The archive token downloads that repository, whatever the private
695+ // repository of the run's token reaches, and nothing else.
696+ arch := got[0].GetToken()
697+ status, body, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch))
698+ require.Equal(t, http.StatusOK, status)
699+ assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"])
700+ status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch))
701+ assert.Equal(t, http.StatusOK, status, "and clones it")
702+ status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch))
703+ assert.Equal(t, http.StatusForbidden, status, "never writes")
704+ status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch))
705+ assert.Equal(t, http.StatusNotFound, status, "another private repository")
706+ status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch))
707+ assert.Equal(t, http.StatusNotFound, status, "not even the repository of the run")
708+ status, _, _ = c.get("/-/github/api/repos/owner/hidden", basic(arch))
709+ assert.Equal(t, http.StatusNotFound, status)
710+ // The job's own token does not read the action repository: this is why the
711+ // archive has a token of its own.
712+ status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(a.GetGithubToken()))
713+ assert.Equal(t, http.StatusNotFound, status)
714+
715+ // Per job: the same commit, another token.
716+ other := c.resolve(runner, b, "owner/myaction@v1")
717+ assert.Equal(t, tag, other[0].GetResolvedSha())
718+ assert.NotEqual(t, arch, other[0].GetToken(), "tokens are not shared between jobs")
719+
720+ // Stable for the attempt: the branch moves, the answer does not.
721+ c.mustGit(c.wt, "checkout", "-q", "-b", "scratch")
722+ c.writeFile(c.wt, "later.txt", []byte("later"))
723+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/myaction.git"), "scratch:refs/heads/dev", "-f")
724+ moved := c.bareGit("myaction", "rev-parse", "dev")
725+ require.NotEqual(t, tag, moved)
726+ again := c.resolve(runner, b, "owner/myaction@dev")
727+ assert.Equal(t, tag, again[0].GetResolvedSha(), "the first resolution of the attempt stands")
728+
729+ // The token goes with its job.
730+ runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
731+ status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch))
732+ assert.Equal(t, http.StatusUnauthorized, status, "revoked with the job")
733+ status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(other[0].GetToken()))
734+ assert.Equal(t, http.StatusOK, status, "the other job's token lives on")
735+}
736+
737+func ptrRef(name, id string) ports.RemoteRef { return ports.RemoteRef{Name: name, ID: id} }
738+
739+// A re-run resolves again, and github.com is asked once for a repository in
740+// each attempt.
741+func TestResolveActionsAreCachedPerAttempt(t *testing.T) {
742+ remote := newFakeRemote(t)
743+ remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaMain))
744+ c := newExecHarness(t, remote, nil)
745+ runner := c.registerRunner("alpha", "ubuntu-latest")
746+ job := c.startJob(runner)
747+ c.heartbeat(runner, job)
748+
749+ assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha())
750+ remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaOther))
751+ assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha(), "stable within the attempt")
752+ assert.Equal(t, 1, remote.count(), "the second call is answered from the attempt's resolution")
753+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
754+
755+ _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
756+ require.NoError(t, err)
757+ job2 := runner.await()
758+ assert.Equal(t, int32(2), job2.GetAttempt())
759+ c.heartbeat(runner, job2)
760+ assert.Equal(t, shaOther, c.resolve(runner, job2, "actions/setup-go@v5")[0].GetResolvedSha(), "a re-run resolves again")
761+ assert.NotEqual(t, job.GetGithubToken(), job2.GetGithubToken())
762+}
763+
764+// AC2: no secret reaches the logs of the server: the repository token, the
765+// archive tokens and the job token, whatever happened with them.
766+func TestRepositoryTokensAreNeverLogged(t *testing.T) {
767+ remote := newFakeRemote(t)
768+ c := newExecHarness(t, remote, nil)
769+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
770+ runner := c.registerRunner("alpha", "ubuntu-latest")
771+ job := c.startJob(runner)
772+ c.heartbeat(runner, job)
773+ tok := job.GetGithubToken()
774+
775+ arch := c.resolve(runner, job, "owner/myaction@main")[0]
776+ require.NotEmpty(t, arch.GetToken())
777+ _, err := c.clone(tok, c.url("/owner/proj"))
778+ require.NoError(t, err)
779+ c.get("/-/github/api/repos/owner/proj", basic(tok))
780+ c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+tok)
781+ c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok))
782+ c.get("/owner/secret/info/refs?service=git-upload-pack", basic("ermitegh_"+strings.Repeat("Z", 43))) // a wrong one
783+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
784+ c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok))
785+
786+ logs := c.logs.String()
787+ require.NotEmpty(t, logs)
788+ for _, secret := range []string{tok, arch.GetToken(), job.GetJobToken(), base64.StdEncoding.EncodeToString([]byte("x-access-token:" + tok))} {
789+ assert.NotContains(t, logs, secret)
790+ }
791+ assert.NotContains(t, logs, strings.Repeat("Z", 43))
792+}
M internal/app/ci_parity_test.go +46 -6
@@ -307,20 +307,50 @@ jobs:
307307 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
308308 }
309309
310-// A step timeout stays refused as long as the runner has none.
311-func TestCIStepTimeoutIsStillRefused(t *testing.T) {
310+// CI-2: planning runs what CI-1 refused (uses, container, services, a step
311+// timeout) and still refuses job-level uses, environment and concurrency.
312+func TestCIPlanningAcceptsCI2Features(t *testing.T) {
312313 c := newCIHarness(t, nil)
313- c.registerRunner("alpha", "ubuntu-latest")
314+ runner := c.registerRunner("alpha", "ubuntu-latest")
314315 c.push("main", map[string]string{".github/workflows/s.yml": `on: push
315316 jobs:
316317 j:
317318 runs-on: ubuntu-latest
319+ container: alpine:3
320+ services:
321+ db:
322+ image: postgres:16
318323 steps:
324+ - uses: actions/checkout@v4
319325 - timeout-minutes: 1
320326 run: echo
321327 `}, "")
322- c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
323- assert.Equal(t, "not supported yet: timeout-minutes on a step", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError())
328+ job := runner.await()
329+ assert.Equal(t, "j", job.GetJobKey())
330+
331+ for _, tc := range []struct{ name, job, want string }{
332+ {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"},
333+ {"environment", "runs-on: ubuntu-latest\n environment: prod\n steps:\n - run: echo", "not supported yet: environment"},
334+ {"concurrency", "runs-on: ubuntu-latest\n concurrency: g\n steps:\n - run: echo", "not supported yet: concurrency"},
335+ } {
336+ t.Run(tc.name, func(t *testing.T) {
337+ body := "on: push\njobs:\n r:\n " + tc.job + "\n"
338+ c.push("main", map[string]string{".github/workflows/" + tc.name + ".yml": body}, "")
339+ var got string
340+ c.waitFor(tc.name+" fails", func() bool {
341+ for _, r := range c.runs() {
342+ if r.GetWorkflowPath() == ".github/workflows/"+tc.name+".yml" && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED {
343+ if jobs := c.getRun(r.GetId()).GetJobs(); len(jobs) == 1 {
344+ got = jobs[0].GetError()
345+ return true
346+ }
347+ }
348+ }
349+ return false
350+ })
351+ assert.Equal(t, tc.want, got)
352+ })
353+ }
324354 }
325355
326356 // A failed job with continue-on-error does not fail the run, keeps its own
@@ -416,12 +446,22 @@ func TestWebRedirectsOwnerURLs(t *testing.T) {
416446 "/owner/proj": "/proj",
417447 "/owner/proj/commit/" + sha: "/proj/commit/" + sha,
418448 "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1",
419- "/owner/proj.git/info/refs": "/proj.git/info/refs",
420449 } {
421450 res := b.get(path, nil)
422451 assert.Equal(t, http.StatusPermanentRedirect, res.status, path)
423452 assert.Equal(t, to, res.hdr.Get("Location"), path)
424453 }
454+ // The git URLs of the owner are served by git over HTTP, not redirected
455+ // (checkout clones <server>/<owner>/<repo>): the private repository asks
456+ // for credentials, a dumb request has no route.
457+ for path, want := range map[string]int{
458+ "/owner/proj.git/info/refs?service=git-upload-pack": http.StatusUnauthorized,
459+ "/owner/proj/info/refs?service=git-upload-pack": http.StatusUnauthorized,
460+ "/other/proj/info/refs?service=git-upload-pack": http.StatusNotFound,
461+ "/owner/proj.git/info/refs": http.StatusNotFound,
462+ } {
463+ assert.Equal(t, want, b.get(path, nil).status, path)
464+ }
425465 assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner")
426466 assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page")
427467 assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status)
@@ -307,20 +307,50 @@ jobs:
307 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")307 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
308 }308 }
309 309
310-// A step timeout stays refused as long as the runner has none.310+// CI-2: planning runs what CI-1 refused (uses, container, services, a step
311-func TestCIStepTimeoutIsStillRefused(t *testing.T) {311+// timeout) and still refuses job-level uses, environment and concurrency.
312+func TestCIPlanningAcceptsCI2Features(t *testing.T) {
312 c := newCIHarness(t, nil)313 c := newCIHarness(t, nil)
313- c.registerRunner("alpha", "ubuntu-latest")314+ runner := c.registerRunner("alpha", "ubuntu-latest")
314 c.push("main", map[string]string{".github/workflows/s.yml": `on: push315 c.push("main", map[string]string{".github/workflows/s.yml": `on: push
315 jobs:316 jobs:
316 j:317 j:
317 runs-on: ubuntu-latest318 runs-on: ubuntu-latest
319+ container: alpine:3
320+ services:
321+ db:
322+ image: postgres:16
318 steps:323 steps:
324+ - uses: actions/checkout@v4
319 - timeout-minutes: 1325 - timeout-minutes: 1
320 run: echo326 run: echo
321 `}, "")327 `}, "")
322- c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })328+ job := runner.await()
323- assert.Equal(t, "not supported yet: timeout-minutes on a step", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError())329+ assert.Equal(t, "j", job.GetJobKey())
330+
331+ for _, tc := range []struct{ name, job, want string }{
332+ {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"},
333+ {"environment", "runs-on: ubuntu-latest\n environment: prod\n steps:\n - run: echo", "not supported yet: environment"},
334+ {"concurrency", "runs-on: ubuntu-latest\n concurrency: g\n steps:\n - run: echo", "not supported yet: concurrency"},
335+ } {
336+ t.Run(tc.name, func(t *testing.T) {
337+ body := "on: push\njobs:\n r:\n " + tc.job + "\n"
338+ c.push("main", map[string]string{".github/workflows/" + tc.name + ".yml": body}, "")
339+ var got string
340+ c.waitFor(tc.name+" fails", func() bool {
341+ for _, r := range c.runs() {
342+ if r.GetWorkflowPath() == ".github/workflows/"+tc.name+".yml" && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED {
343+ if jobs := c.getRun(r.GetId()).GetJobs(); len(jobs) == 1 {
344+ got = jobs[0].GetError()
345+ return true
346+ }
347+ }
348+ }
349+ return false
350+ })
351+ assert.Equal(t, tc.want, got)
352+ })
353+ }
324 }354 }
325 355
326 // A failed job with continue-on-error does not fail the run, keeps its own356 // A failed job with continue-on-error does not fail the run, keeps its own
@@ -416,12 +446,22 @@ func TestWebRedirectsOwnerURLs(t *testing.T) {
416 "/owner/proj": "/proj",446 "/owner/proj": "/proj",
417 "/owner/proj/commit/" + sha: "/proj/commit/" + sha,447 "/owner/proj/commit/" + sha: "/proj/commit/" + sha,
418 "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1",448 "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1",
419- "/owner/proj.git/info/refs": "/proj.git/info/refs",
420 } {449 } {
421 res := b.get(path, nil)450 res := b.get(path, nil)
422 assert.Equal(t, http.StatusPermanentRedirect, res.status, path)451 assert.Equal(t, http.StatusPermanentRedirect, res.status, path)
423 assert.Equal(t, to, res.hdr.Get("Location"), path)452 assert.Equal(t, to, res.hdr.Get("Location"), path)
424 }453 }
454+ // The git URLs of the owner are served by git over HTTP, not redirected
455+ // (checkout clones <server>/<owner>/<repo>): the private repository asks
456+ // for credentials, a dumb request has no route.
457+ for path, want := range map[string]int{
458+ "/owner/proj.git/info/refs?service=git-upload-pack": http.StatusUnauthorized,
459+ "/owner/proj/info/refs?service=git-upload-pack": http.StatusUnauthorized,
460+ "/other/proj/info/refs?service=git-upload-pack": http.StatusNotFound,
461+ "/owner/proj.git/info/refs": http.StatusNotFound,
462+ } {
463+ assert.Equal(t, want, b.get(path, nil).status, path)
464+ }
425 assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner")465 assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner")
426 assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page")466 assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page")
427 assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status)467 assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status)
M internal/app/forge.go +31 -3
@@ -20,6 +20,8 @@ import (
2020 "bnema.dev/ermite/internal/adapters/eventbus"
2121 "bnema.dev/ermite/internal/adapters/gitcli"
2222 "bnema.dev/ermite/internal/adapters/githttp"
23+ "bnema.dev/ermite/internal/adapters/githubapi"
24+ "bnema.dev/ermite/internal/adapters/gitremote"
2325 "bnema.dev/ermite/internal/adapters/httpserver"
2426 "bnema.dev/ermite/internal/adapters/kvstore"
2527 "bnema.dev/ermite/internal/adapters/rpc"
@@ -50,11 +52,22 @@ type options struct {
5052 eventBuffer int
5153 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
5254 revalidateEvery time.Duration
55+ // remote replaces the lister of github.com refs (tests never reach the
56+ // network).
57+ remoteLister ports.GitRemote
5358 // ci overrides the settings of the CI engine (tests shorten its
5459 // durations).
5560 ci func(*usecase.CIConfig)
5661 }
5762
63+// remote returns the lister of github.com refs: the test's, else def.
64+func (o options) remote(def ports.GitRemote) ports.GitRemote {
65+ if o.remoteLister != nil {
66+ return o.remoteLister
67+ }
68+ return def
69+}
70+
5871 // forge is the wired server: every adapter and use case, ready to serve.
5972 type forge struct {
6073 cfg config.Config
@@ -161,7 +174,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
161174 Msg("repositories reconciled")
162175
163176 f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
164- f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)
177+ f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock).WithRepoTokens(store.CI())
165178 f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
166179 f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
167180
@@ -199,7 +212,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
199212 Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
200213 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
201214 Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
202- PollTimeout: cfg.CIPollTimeout,
215+ PollTimeout: cfg.CIPollTimeout, Archive: git,
216+ Remote: opts.remote(gitremote.New(gitremote.Config{
217+ Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
218+ })),
203219 }
204220 if opts.ci != nil {
205221 opts.ci(&ciCfg)
@@ -279,15 +295,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
279295 Repos: f.Repos,
280296 Pushes: f.Pushes,
281297 Backend: git,
298+ Owner: cfg.Owner,
282299 Hooks: f.hooks,
283300 TrustedProxies: cfg.TrustedProxies,
284301 Limiter: limiter,
285302 MaxBodyBytes: maxBody,
286303 })
287- routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns))
304+ routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(web.Patterns))
288305 for _, p := range githttp.Patterns {
289306 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
290307 }
308+ // The owner form of the clone URL (github.repository), which GitHub's
309+ // actions use. config.Owner follows the rules of repository names, so it is
310+ // a literal segment that never overlaps /-/.
311+ for _, p := range githttp.OwnerPatterns(cfg.Owner) {
312+ routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
313+ }
314+ // The REST subset the actions call on github.api_url.
315+ api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter})
316+ for _, p := range githubapi.Patterns {
317+ routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
318+ }
291319 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
292320 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
293321 site, err := newWeb(cfg, f, limiter)
@@ -20,6 +20,8 @@ import (
20 "bnema.dev/ermite/internal/adapters/eventbus"20 "bnema.dev/ermite/internal/adapters/eventbus"
21 "bnema.dev/ermite/internal/adapters/gitcli"21 "bnema.dev/ermite/internal/adapters/gitcli"
22 "bnema.dev/ermite/internal/adapters/githttp"22 "bnema.dev/ermite/internal/adapters/githttp"
23+ "bnema.dev/ermite/internal/adapters/githubapi"
24+ "bnema.dev/ermite/internal/adapters/gitremote"
23 "bnema.dev/ermite/internal/adapters/httpserver"25 "bnema.dev/ermite/internal/adapters/httpserver"
24 "bnema.dev/ermite/internal/adapters/kvstore"26 "bnema.dev/ermite/internal/adapters/kvstore"
25 "bnema.dev/ermite/internal/adapters/rpc"27 "bnema.dev/ermite/internal/adapters/rpc"
@@ -50,11 +52,22 @@ type options struct {
50 eventBuffer int52 eventBuffer int
51 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.53 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
52 revalidateEvery time.Duration54 revalidateEvery time.Duration
55+ // remote replaces the lister of github.com refs (tests never reach the
56+ // network).
57+ remoteLister ports.GitRemote
53 // ci overrides the settings of the CI engine (tests shorten its58 // ci overrides the settings of the CI engine (tests shorten its
54 // durations).59 // durations).
55 ci func(*usecase.CIConfig)60 ci func(*usecase.CIConfig)
56 }61 }
57 62
63+// remote returns the lister of github.com refs: the test's, else def.
64+func (o options) remote(def ports.GitRemote) ports.GitRemote {
65+ if o.remoteLister != nil {
66+ return o.remoteLister
67+ }
68+ return def
69+}
70+
58 // forge is the wired server: every adapter and use case, ready to serve.71 // forge is the wired server: every adapter and use case, ready to serve.
59 type forge struct {72 type forge struct {
60 cfg config.Config73 cfg config.Config
@@ -161,7 +174,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
161 Msg("repositories reconciled")174 Msg("repositories reconciled")
162 175
163 f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())176 f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
164- f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)177+ f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock).WithRepoTokens(store.CI())
165 f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)178 f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
166 f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)179 f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
167 180
@@ -199,7 +212,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
199 Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,212 Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
200 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,213 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
201 Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,214 Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
202- PollTimeout: cfg.CIPollTimeout,215+ PollTimeout: cfg.CIPollTimeout, Archive: git,
216+ Remote: opts.remote(gitremote.New(gitremote.Config{
217+ Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
218+ })),
203 }219 }
204 if opts.ci != nil {220 if opts.ci != nil {
205 opts.ci(&ciCfg)221 opts.ci(&ciCfg)
@@ -279,15 +295,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
279 Repos: f.Repos,295 Repos: f.Repos,
280 Pushes: f.Pushes,296 Pushes: f.Pushes,
281 Backend: git,297 Backend: git,
298+ Owner: cfg.Owner,
282 Hooks: f.hooks,299 Hooks: f.hooks,
283 TrustedProxies: cfg.TrustedProxies,300 TrustedProxies: cfg.TrustedProxies,
284 Limiter: limiter,301 Limiter: limiter,
285 MaxBodyBytes: maxBody,302 MaxBodyBytes: maxBody,
286 })303 })
287- routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns))304+ routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(web.Patterns))
288 for _, p := range githttp.Patterns {305 for _, p := range githttp.Patterns {
289 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})306 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
290 }307 }
308+ // The owner form of the clone URL (github.repository), which GitHub's
309+ // actions use. config.Owner follows the rules of repository names, so it is
310+ // a literal segment that never overlaps /-/.
311+ for _, p := range githttp.OwnerPatterns(cfg.Owner) {
312+ routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
313+ }
314+ // The REST subset the actions call on github.api_url.
315+ api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter})
316+ for _, p := range githubapi.Patterns {
317+ routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
318+ }
291 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)319 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
292 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)320 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
293 site, err := newWeb(cfg, f, limiter)321 site, err := newWeb(cfg, f, limiter)
A internal/usecase/ci_githubapi.go +133 -0
@@ -0,0 +1,133 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "strings"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// The REST subset (ADR 0025, CI-2): what the actions that run on ermite call
17+// on github.api_url. The access rule is the forge's: a repository is read by
18+// anyone when public, and with repo:read when private, which the principal of a
19+// job repository token holds on the repository of its run (and the archive
20+// tokens on theirs).
21+
22+var _ ports.GitHubAPIService = (*CI)(nil)
23+
24+// githubRepo looks the repository up as GitHub names it, owner/repo. Another
25+// owner, a name that is not one and a repository the caller cannot read are all
26+// not found.
27+func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) {
28+ if !strings.EqualFold(owner, c.cfg.Owner) {
29+ return domain.Repo{}, domain.ErrNotFound
30+ }
31+ rn, err := domain.ParseRepoName(strings.ToLower(name))
32+ if err != nil {
33+ return domain.Repo{}, domain.ErrNotFound
34+ }
35+ return c.readableRepo(ctx, p, rn)
36+}
37+
38+// GetRepo returns GET /repos/{owner}/{repo}.
39+//
40+// The object is the repository of the push event (repoObj: real values where
41+// ermite has them, GitHub's for a repository without the feature elsewhere)
42+// with the REST differences: dates are ISO 8601 strings, and the permissions of
43+// the caller are given, as GitHub does for an authenticated request.
44+func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string) ([]byte, error) {
45+ repo, err := c.githubRepo(ctx, p, owner, name)
46+ if err != nil {
47+ return nil, err
48+ }
49+ branch, err := c.cfg.Repos.HeadBranch(ctx, repo.Name)
50+ if errors.Is(err, domain.ErrNotFound) {
51+ return nil, domain.ErrNotFound
52+ }
53+ if err != nil {
54+ return nil, err
55+ }
56+ if branch == "" {
57+ branch = "main"
58+ }
59+ u := c.urls(repo)
60+ created, pushed := repo.CreatedAt, repo.PushedAt
61+ obj := c.repoObj(repo, u, branch, created)
62+ obj["created_at"] = created.UTC().Format(timeLayout)
63+ if pushed.IsZero() {
64+ pushed = created
65+ }
66+ obj["pushed_at"] = pushed.UTC().Format(timeLayout)
67+ obj["updated_at"] = pushed.UTC().Format(timeLayout)
68+ delete(obj, "master_branch") // push event only
69+ delete(obj, "stargazers")
70+ delete(obj, "watchers")
71+ delete(obj, "forks")
72+ delete(obj, "open_issues")
73+ for k, v := range map[string]any{
74+ "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),
75+ "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),
76+ "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false,
77+ "use_squash_pr_title_as_default": false, "has_pull_requests": true,
78+ } {
79+ obj[k] = v
80+ }
81+ if !p.IsAnonymous() {
82+ write := canWrite(p, repo.Name)
83+ obj["permissions"] = map[string]bool{"admin": canAdmin(p, repo.Name), "maintain": write, "push": write, "triage": write, "pull": true}
84+ }
85+ return json.Marshal(obj)
86+}
87+
88+// timeLayout is the ISO 8601 form of GitHub's REST dates.
89+const timeLayout = "2006-01-02T15:04:05Z"
90+
91+// HashAlgorithm answers GET /repos/{owner}/{repo}/hash-algorithm: checkout asks
92+// it before it runs git init. ermite repositories are SHA-1.
93+func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name string) (string, error) {
94+ if _, err := c.githubRepo(ctx, p, owner, name); err != nil {
95+ return "", err
96+ }
97+ return "sha1", nil
98+}
99+
100+// Tarball streams the archive of a ref as `git archive` makes it, in one
101+// response (GitHub answers 302 to codeload: ermite serves it itself). The files
102+// are in a directory <owner>-<repo>-<7 first characters of the commit>, as
103+// GitHub names it.
104+func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error {
105+ repo, err := c.githubRepo(ctx, p, owner, name)
106+ if err != nil {
107+ return err
108+ }
109+ rev, err := domain.ParseRevision(ref)
110+ if err != nil {
111+ return domain.ErrNotFound
112+ }
113+ res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev)
114+ if err != nil {
115+ return err
116+ }
117+ dir := c.cfg.Owner + "-" + repo.Name.String() + "-" + shortID(res.CommitID.String())[:7]
118+ return c.cfg.Archive.Archive(ctx, repo.Name, res.CommitID, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }})
119+}
120+
121+// lazyWriter calls begin at the first write, so that nothing is sent before git
122+// produced a byte.
123+type lazyWriter struct {
124+ begin func() io.Writer
125+ w io.Writer
126+}
127+
128+func (l *lazyWriter) Write(b []byte) (int, error) {
129+ if l.w == nil {
130+ l.w = l.begin()
131+ }
132+ return l.w.Write(b)
133+}
@@ -0,0 +1,133 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "strings"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// The REST subset (ADR 0025, CI-2): what the actions that run on ermite call
17+// on github.api_url. The access rule is the forge's: a repository is read by
18+// anyone when public, and with repo:read when private, which the principal of a
19+// job repository token holds on the repository of its run (and the archive
20+// tokens on theirs).
21+
22+var _ ports.GitHubAPIService = (*CI)(nil)
23+
24+// githubRepo looks the repository up as GitHub names it, owner/repo. Another
25+// owner, a name that is not one and a repository the caller cannot read are all
26+// not found.
27+func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) {
28+ if !strings.EqualFold(owner, c.cfg.Owner) {
29+ return domain.Repo{}, domain.ErrNotFound
30+ }
31+ rn, err := domain.ParseRepoName(strings.ToLower(name))
32+ if err != nil {
33+ return domain.Repo{}, domain.ErrNotFound
34+ }
35+ return c.readableRepo(ctx, p, rn)
36+}
37+
38+// GetRepo returns GET /repos/{owner}/{repo}.
39+//
40+// The object is the repository of the push event (repoObj: real values where
41+// ermite has them, GitHub's for a repository without the feature elsewhere)
42+// with the REST differences: dates are ISO 8601 strings, and the permissions of
43+// the caller are given, as GitHub does for an authenticated request.
44+func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string) ([]byte, error) {
45+ repo, err := c.githubRepo(ctx, p, owner, name)
46+ if err != nil {
47+ return nil, err
48+ }
49+ branch, err := c.cfg.Repos.HeadBranch(ctx, repo.Name)
50+ if errors.Is(err, domain.ErrNotFound) {
51+ return nil, domain.ErrNotFound
52+ }
53+ if err != nil {
54+ return nil, err
55+ }
56+ if branch == "" {
57+ branch = "main"
58+ }
59+ u := c.urls(repo)
60+ created, pushed := repo.CreatedAt, repo.PushedAt
61+ obj := c.repoObj(repo, u, branch, created)
62+ obj["created_at"] = created.UTC().Format(timeLayout)
63+ if pushed.IsZero() {
64+ pushed = created
65+ }
66+ obj["pushed_at"] = pushed.UTC().Format(timeLayout)
67+ obj["updated_at"] = pushed.UTC().Format(timeLayout)
68+ delete(obj, "master_branch") // push event only
69+ delete(obj, "stargazers")
70+ delete(obj, "watchers")
71+ delete(obj, "forks")
72+ delete(obj, "open_issues")
73+ for k, v := range map[string]any{
74+ "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),
75+ "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),
76+ "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false,
77+ "use_squash_pr_title_as_default": false, "has_pull_requests": true,
78+ } {
79+ obj[k] = v
80+ }
81+ if !p.IsAnonymous() {
82+ write := canWrite(p, repo.Name)
83+ obj["permissions"] = map[string]bool{"admin": canAdmin(p, repo.Name), "maintain": write, "push": write, "triage": write, "pull": true}
84+ }
85+ return json.Marshal(obj)
86+}
87+
88+// timeLayout is the ISO 8601 form of GitHub's REST dates.
89+const timeLayout = "2006-01-02T15:04:05Z"
90+
91+// HashAlgorithm answers GET /repos/{owner}/{repo}/hash-algorithm: checkout asks
92+// it before it runs git init. ermite repositories are SHA-1.
93+func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name string) (string, error) {
94+ if _, err := c.githubRepo(ctx, p, owner, name); err != nil {
95+ return "", err
96+ }
97+ return "sha1", nil
98+}
99+
100+// Tarball streams the archive of a ref as `git archive` makes it, in one
101+// response (GitHub answers 302 to codeload: ermite serves it itself). The files
102+// are in a directory <owner>-<repo>-<7 first characters of the commit>, as
103+// GitHub names it.
104+func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error {
105+ repo, err := c.githubRepo(ctx, p, owner, name)
106+ if err != nil {
107+ return err
108+ }
109+ rev, err := domain.ParseRevision(ref)
110+ if err != nil {
111+ return domain.ErrNotFound
112+ }
113+ res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev)
114+ if err != nil {
115+ return err
116+ }
117+ dir := c.cfg.Owner + "-" + repo.Name.String() + "-" + shortID(res.CommitID.String())[:7]
118+ return c.cfg.Archive.Archive(ctx, repo.Name, res.CommitID, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }})
119+}
120+
121+// lazyWriter calls begin at the first write, so that nothing is sent before git
122+// produced a byte.
123+type lazyWriter struct {
124+ begin func() io.Writer
125+ w io.Writer
126+}
127+
128+func (l *lazyWriter) Write(b []byte) (int, error) {
129+ if l.w == nil {
130+ l.w = l.begin()
131+ }
132+ return l.w.Write(b)
133+}

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 2

Tags 41

↑↓ moveenter openesc close