detached 89ff433

fix(ci): never answer a repository of the server from github.com

When the owner is ERMITE_OWNER and the repository exists on ermite, github.com
no longer answers for that name: a pusher who cannot read it gets "repository
not found" at the first resolution and when the answer comes from a stored
resolution of the attempt, whatever source that stored. Only a repository that
does not exist here is github.com's. One function decides the source.
commit
89ff433aeaa3bb73d2a719b2e44cfdc18dfed4f0
author
brice · · 2026-10-13 11:34 UTC
parent
d024c7f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +65 -38
M internal/usecase/ci_actions.go +65 -38
@@ -172,6 +172,24 @@ func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p action
172172 ra := ports.ResolvedAction{
173173 Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source,
174174 }
175+ notFound := ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}
176+
177+ // A repository of this server that the pusher cannot read is never served
178+ // from anywhere else: not from github.com under the same name, whatever
179+ // source an earlier resolution of the attempt stored. It answers as a
180+ // repository that does not exist. (When it does not exist on this server, a
181+ // stored ermite resolution has nothing to serve either.)
182+ repo, onErmite, err := c.ermiteRepoOf(ctx, p)
183+ if err != nil {
184+ return ports.ResolvedAction{}, err
185+ }
186+ if onErmite {
187+ if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil {
188+ return ports.ResolvedAction{}, err
189+ } else if !ok {
190+ return notFound, nil
191+ }
192+ }
175193 switch res.Source {
176194 case domain.CIActionGitHub:
177195 // The case the workflow wrote: the stored resolution is shared by
@@ -179,23 +197,11 @@ func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p action
179197 ra.NameWithOwner = p.owner + "/" + p.repo
180198 ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA
181199 case domain.CIActionErmite:
182- name, err := domain.ParseRepoName(strings.ToLower(p.repo))
183- if err != nil {
184- return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
185- }
186- repo, err := c.cfg.Meta.Get(ctx, name)
187- if errors.Is(err, domain.ErrNotFound) {
188- return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
189- } else if err != nil {
190- return ports.ResolvedAction{}, err
191- }
192- if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil {
193- return ports.ResolvedAction{}, err
194- } else if !ok {
195- return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
200+ if !onErmite {
201+ return notFound, nil
196202 }
197203 ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA
198- ra.Token = c.archiveToken(rs, j, name, res.ResolvedSHA)
204+ ra.Token = c.archiveToken(rs, j, repo.Name, res.ResolvedSHA)
199205 }
200206 return ra, nil
201207 }
@@ -221,6 +227,29 @@ func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName, s
221227 return tok
222228 }
223229
230+// ermiteRepoOf finds the repository of this server that a reference names: the
231+// owner is ERMITE_OWNER (without regard to case) and the repository exists. ok
232+// is false for any other reference, which github.com serves. It is the one
233+// place that decides the source of an action, for a first resolution and for the
234+// answer from a stored one.
235+func (c *CI) ermiteRepoOf(ctx context.Context, a actionRef) (repo domain.Repo, ok bool, err error) {
236+ if !domain.OwnerMatches(c.cfg.Owner, a.owner) {
237+ return domain.Repo{}, false, nil
238+ }
239+ name, perr := domain.ParseRepoName(strings.ToLower(a.repo))
240+ if perr != nil {
241+ return domain.Repo{}, false, nil // not a name ermite can hold
242+ }
243+ repo, err = c.cfg.Meta.Get(ctx, name)
244+ switch {
245+ case errors.Is(err, domain.ErrNotFound):
246+ return domain.Repo{}, false, nil
247+ case err != nil:
248+ return domain.Repo{}, false, err
249+ }
250+ return repo, true, nil
251+}
252+
224253 // pusherAccess is who the actions of a run are resolved on behalf of: the
225254 // pusher recorded with the run.
226255 type pusherAccess struct{ pusher domain.CIPusher }
@@ -343,30 +372,28 @@ func errUnresolvedRepo(a actionRef) string {
343372 // does not resolve; err is a failure of the call.
344373 func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) {
345374 res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}
346- if domain.OwnerMatches(c.cfg.Owner, a.owner) {
347- if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil {
348- if repo, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil {
349- // A repository the pusher may not read answers as one that does
350- // not exist, before any ref is looked at, so that the texts leak
351- // nothing about it.
352- if ok, err := c.pusherCanRead(ctx, by, j.Repo, repo); err != nil {
353- return res, "", err
354- } else if !ok {
355- return res, errUnresolvedRepo(a), nil
356- }
357- sha, found, err := c.resolveOnErmite(ctx, name, a.ref)
358- if err != nil {
359- return res, "", err
360- }
361- if !found {
362- return res, errUnresolvedRef(a), nil
363- }
364- res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+name.String(), sha, domain.CIActionErmite
365- return res, "", nil
366- } else if !errors.Is(gerr, domain.ErrNotFound) {
367- return res, "", gerr
368- }
375+ repo, onErmite, err := c.ermiteRepoOf(ctx, a)
376+ if err != nil {
377+ return res, "", err
378+ }
379+ if onErmite {
380+ // The repository exists here, so github.com never answers for it. A
381+ // repository the pusher may not read answers as one that does not exist,
382+ // before any ref is looked at, so that the texts leak nothing about it.
383+ if ok, err := c.pusherCanRead(ctx, by, j.Repo, repo); err != nil {
384+ return res, "", err
385+ } else if !ok {
386+ return res, errUnresolvedRepo(a), nil
369387 }
388+ sha, found, err := c.resolveOnErmite(ctx, repo.Name, a.ref)
389+ if err != nil {
390+ return res, "", err
391+ }
392+ if !found {
393+ return res, errUnresolvedRef(a), nil
394+ }
395+ res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+repo.Name.String(), sha, domain.CIActionErmite
396+ return res, "", nil
370397 }
371398 res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub
372399 if low := strings.ToLower(a.ref); isHex40(low) {
@@ -172,6 +172,24 @@ func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p action
172 ra := ports.ResolvedAction{172 ra := ports.ResolvedAction{
173 Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source,173 Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source,
174 }174 }
175+ notFound := ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}
176+
177+ // A repository of this server that the pusher cannot read is never served
178+ // from anywhere else: not from github.com under the same name, whatever
179+ // source an earlier resolution of the attempt stored. It answers as a
180+ // repository that does not exist. (When it does not exist on this server, a
181+ // stored ermite resolution has nothing to serve either.)
182+ repo, onErmite, err := c.ermiteRepoOf(ctx, p)
183+ if err != nil {
184+ return ports.ResolvedAction{}, err
185+ }
186+ if onErmite {
187+ if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil {
188+ return ports.ResolvedAction{}, err
189+ } else if !ok {
190+ return notFound, nil
191+ }
192+ }
175 switch res.Source {193 switch res.Source {
176 case domain.CIActionGitHub:194 case domain.CIActionGitHub:
177 // The case the workflow wrote: the stored resolution is shared by195 // The case the workflow wrote: the stored resolution is shared by
@@ -179,23 +197,11 @@ func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p action
179 ra.NameWithOwner = p.owner + "/" + p.repo197 ra.NameWithOwner = p.owner + "/" + p.repo
180 ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA198 ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA
181 case domain.CIActionErmite:199 case domain.CIActionErmite:
182- name, err := domain.ParseRepoName(strings.ToLower(p.repo))200+ if !onErmite {
183- if err != nil {201+ return notFound, nil
184- return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
185- }
186- repo, err := c.cfg.Meta.Get(ctx, name)
187- if errors.Is(err, domain.ErrNotFound) {
188- return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
189- } else if err != nil {
190- return ports.ResolvedAction{}, err
191- }
192- if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil {
193- return ports.ResolvedAction{}, err
194- } else if !ok {
195- return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
196 }202 }
197 ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA203 ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA
198- ra.Token = c.archiveToken(rs, j, name, res.ResolvedSHA)204+ ra.Token = c.archiveToken(rs, j, repo.Name, res.ResolvedSHA)
199 }205 }
200 return ra, nil206 return ra, nil
201 }207 }
@@ -221,6 +227,29 @@ func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName, s
221 return tok227 return tok
222 }228 }
223 229
230+// ermiteRepoOf finds the repository of this server that a reference names: the
231+// owner is ERMITE_OWNER (without regard to case) and the repository exists. ok
232+// is false for any other reference, which github.com serves. It is the one
233+// place that decides the source of an action, for a first resolution and for the
234+// answer from a stored one.
235+func (c *CI) ermiteRepoOf(ctx context.Context, a actionRef) (repo domain.Repo, ok bool, err error) {
236+ if !domain.OwnerMatches(c.cfg.Owner, a.owner) {
237+ return domain.Repo{}, false, nil
238+ }
239+ name, perr := domain.ParseRepoName(strings.ToLower(a.repo))
240+ if perr != nil {
241+ return domain.Repo{}, false, nil // not a name ermite can hold
242+ }
243+ repo, err = c.cfg.Meta.Get(ctx, name)
244+ switch {
245+ case errors.Is(err, domain.ErrNotFound):
246+ return domain.Repo{}, false, nil
247+ case err != nil:
248+ return domain.Repo{}, false, err
249+ }
250+ return repo, true, nil
251+}
252+
224 // pusherAccess is who the actions of a run are resolved on behalf of: the253 // pusherAccess is who the actions of a run are resolved on behalf of: the
225 // pusher recorded with the run.254 // pusher recorded with the run.
226 type pusherAccess struct{ pusher domain.CIPusher }255 type pusherAccess struct{ pusher domain.CIPusher }
@@ -343,30 +372,28 @@ func errUnresolvedRepo(a actionRef) string {
343 // does not resolve; err is a failure of the call.372 // does not resolve; err is a failure of the call.
344 func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) {373 func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) {
345 res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}374 res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}
346- if domain.OwnerMatches(c.cfg.Owner, a.owner) {375+ repo, onErmite, err := c.ermiteRepoOf(ctx, a)
347- if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil {376+ if err != nil {
348- if repo, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil {377+ return res, "", err
349- // A repository the pusher may not read answers as one that does378+ }
350- // not exist, before any ref is looked at, so that the texts leak379+ if onErmite {
351- // nothing about it.380+ // The repository exists here, so github.com never answers for it. A
352- if ok, err := c.pusherCanRead(ctx, by, j.Repo, repo); err != nil {381+ // repository the pusher may not read answers as one that does not exist,
353- return res, "", err382+ // before any ref is looked at, so that the texts leak nothing about it.
354- } else if !ok {383+ if ok, err := c.pusherCanRead(ctx, by, j.Repo, repo); err != nil {
355- return res, errUnresolvedRepo(a), nil384+ return res, "", err
356- }385+ } else if !ok {
357- sha, found, err := c.resolveOnErmite(ctx, name, a.ref)386+ return res, errUnresolvedRepo(a), nil
358- if err != nil {
359- return res, "", err
360- }
361- if !found {
362- return res, errUnresolvedRef(a), nil
363- }
364- res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+name.String(), sha, domain.CIActionErmite
365- return res, "", nil
366- } else if !errors.Is(gerr, domain.ErrNotFound) {
367- return res, "", gerr
368- }
369 }387 }
388+ sha, found, err := c.resolveOnErmite(ctx, repo.Name, a.ref)
389+ if err != nil {
390+ return res, "", err
391+ }
392+ if !found {
393+ return res, errUnresolvedRef(a), nil
394+ }
395+ res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+repo.Name.String(), sha, domain.CIActionErmite
396+ return res, "", nil
370 }397 }
371 res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub398 res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub
372 if low := strings.ToLower(a.ref); isHex40(low) {399 if low := strings.ToLower(a.ref); isHex40(low) {

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 44

↑↓ moveenter openesc close