detached 3d22057

fix(ci): resolve actions of other repositories on behalf of the pusher, bind archive tokens to a commit

An action of another repository of the server was resolved for any run, and
its archive token read the whole repository. Now the pusher of a run is
recorded with it (root, owner, or the stored token by its id) and every answer
asks whether that pusher can read the action's repository with the forge's own
rule, looking the token up again, so a revoked or expired token, a repository
made private, or a token whose list lacks the repository answers
"repository not found", as for a repository that is not there.

The archive token is a principal of its own: no scope, only the grant to
download one commit of one repository. Only the archive endpoint reads it, and
only for that commit; Git over HTTP, the repository and hash-algorithm
endpoints and the API see a caller that reads nothing. Archive tokens are
indexed by the repository they read and deleted with it.

Also: a HEAD on the tarball resolves the ref and sends the headers without
building the archive; archive downloads are limited per client with an idle
write deadline; owner and repository names of an action use GitHub's charset;
ls-remote is limited to four at once and a call to 60 s (unavailable); the
owner is matched without regard to case in Git URLs, the REST subset and the
web; the proxy variables reach ls-remote.
commit
3d22057ff28fc642e218d86ea20d6ee33467bb89
author
brice · · 2026-10-13 11:21 UTC
parent
2b9c4c6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
29 files · +1334 -123
M README.md +6 -2
@@ -118,9 +118,13 @@ ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a
118118
119119 Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.
120120
121-Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it. The runner also gets read tokens for the repositories of the actions a job uses from this server (an action of `ERMITE_OWNER/<repo>` on ermite wins over the same name on github.com).
121+Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it.
122122
123-The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected). Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.
123+An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most three archives are written at once for one client address (`429` beyond), eight on the server, and a client that stops reading is cut after two minutes without progress.
124+
125+The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.
126+
127+Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git.
124128
125129 `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`.
126130
@@ -118,9 +118,13 @@ ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a
118 118
119 Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.119 Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.
120 120
121-Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it. The runner also gets read tokens for the repositories of the actions a job uses from this server (an action of `ERMITE_OWNER/<repo>` on ermite wins over the same name on github.com).121+Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it.
122 122
123-The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected). Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.123+An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most three archives are written at once for one client address (`429` beyond), eight on the server, and a client that stops reading is cut after two minutes without progress.
124+
125+The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.
126+
127+Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git.
124 128
125 `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`.129 `ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`.
126 130
M gen/ermite/storage/ci.pb.go +45 -11
@@ -580,7 +580,12 @@ type CIRunSource struct {
580580 GithubJson string `protobuf:"bytes,2,opt,name=github_json,json=githubJson,proto3" json:"github_json,omitempty"`
581581 // Repository of the run: the key of the run record is run/<repo>/<id>, and
582582 // the runner side knows a run by its id only.
583- Repo string `protobuf:"bytes,3,opt,name=repo,proto3" json:"repo,omitempty"`
583+ Repo string `protobuf:"bytes,3,opt,name=repo,proto3" json:"repo,omitempty"`
584+ // Who pushed the commit: "root", "owner", "token" or empty (a run stored
585+ // before it was recorded, or pushed by nobody). pusher_token_id is the ID of
586+ // the stored token for "token". What a token may read is looked up live.
587+ PusherKind string `protobuf:"bytes,5,opt,name=pusher_kind,json=pusherKind,proto3" json:"pusher_kind,omitempty"`
588+ PusherTokenId string `protobuf:"bytes,6,opt,name=pusher_token_id,json=pusherTokenId,proto3" json:"pusher_token_id,omitempty"`
584589 unknownFields protoimpl.UnknownFields
585590 sizeCache protoimpl.SizeCache
586591 }
@@ -643,6 +648,20 @@ func (x *CIRunSource) GetRepo() string {
643648 return ""
644649 }
645650
651+func (x *CIRunSource) GetPusherKind() string {
652+ if x != nil {
653+ return x.PusherKind
654+ }
655+ return ""
656+}
657+
658+func (x *CIRunSource) GetPusherTokenId() string {
659+ if x != nil {
660+ return x.PusherTokenId
661+ }
662+ return ""
663+}
664+
646665 // CIStep is a step of a job.
647666 type CIStep struct {
648667 state protoimpl.MessageState `protogen:"open.v1"`
@@ -1362,9 +1381,10 @@ func (x *CIRunner) GetTokenHash() []byte {
13621381
13631382 // CIRepoToken is a job repository token (the "github.token" of a job, or the
13641383 // token of one action archive), stored under "ghtoken/<id>". The secret is
1365-// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and
1366-// "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists
1367-// the tokens of a job so that they are revoked together.
1384+// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id,
1385+// "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a
1386+// job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the
1387+// tokens that read a repository, so that they go with it.
13681388 type CIRepoToken struct {
13691389 state protoimpl.MessageState `protogen:"open.v1"`
13701390 Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
@@ -1376,9 +1396,11 @@ type CIRepoToken struct {
13761396 RunAttempt int32 `protobuf:"varint,5,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"`
13771397 JobId string `protobuf:"bytes,6,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"`
13781398 // Set for the token of an action archive, unset for the job's own token.
1379- Archive bool `protobuf:"varint,7,opt,name=archive,proto3" json:"archive,omitempty"`
1380- ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
1381- CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
1399+ Archive bool `protobuf:"varint,7,opt,name=archive,proto3" json:"archive,omitempty"`
1400+ ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
1401+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
1402+ // The commit an archive token downloads.
1403+ Sha string `protobuf:"bytes,10,opt,name=sha,proto3" json:"sha,omitempty"`
13821404 unknownFields protoimpl.UnknownFields
13831405 sizeCache protoimpl.SizeCache
13841406 }
@@ -1476,6 +1498,13 @@ func (x *CIRepoToken) GetCreatedAt() *timestamppb.Timestamp {
14761498 return nil
14771499 }
14781500
1501+func (x *CIRepoToken) GetSha() string {
1502+ if x != nil {
1503+ return x.Sha
1504+ }
1505+ return ""
1506+}
1507+
14791508 // CIActionResolution is the resolution of an action reference for a run
14801509 // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>"
14811510 // so that every job of the attempt gets the same commit.
@@ -1579,13 +1608,16 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
15791608 "\fcompleted_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12)\n" +
15801609 "\x10cancel_requested\x18\x14 \x01(\bR\x0fcancelRequested\x12J\n" +
15811610 "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAt\x12\x12\n" +
1582- "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"~\n" +
1611+ "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"\xc7\x01\n" +
15831612 "\vCIRunSource\x12\x15\n" +
15841613 "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" +
15851614 "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" +
15861615 "\vgithub_json\x18\x02 \x01(\tR\n" +
15871616 "githubJson\x12\x12\n" +
1588- "\x04repo\x18\x03 \x01(\tR\x04repo\"\xe5\x02\n" +
1617+ "\x04repo\x18\x03 \x01(\tR\x04repo\x12\x1f\n" +
1618+ "\vpusher_kind\x18\x05 \x01(\tR\n" +
1619+ "pusherKind\x12&\n" +
1620+ "\x0fpusher_token_id\x18\x06 \x01(\tR\rpusherTokenId\"\xe5\x02\n" +
15891621 "\x06CIStep\x12\x16\n" +
15901622 "\x06number\x18\x01 \x01(\x05R\x06number\x12\x12\n" +
15911623 "\x04name\x18\x02 \x01(\tR\x04name\x121\n" +
@@ -1682,7 +1714,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
16821714 "\x02os\x18\a \x01(\tR\x02os\x12?\n" +
16831715 "\rregistered_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fregisteredAt\x12\x1d\n" +
16841716 "\n" +
1685- "token_hash\x18\t \x01(\fR\ttokenHash\"\xa4\x02\n" +
1717+ "token_hash\x18\t \x01(\fR\ttokenHash\"\xb6\x02\n" +
16861718 "\vCIRepoToken\x12\x0e\n" +
16871719 "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
16881720 "\x04hash\x18\x02 \x01(\fR\x04hash\x12\x12\n" +
@@ -1695,7 +1727,9 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
16951727 "\n" +
16961728 "expires_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x129\n" +
16971729 "\n" +
1698- "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\x89\x01\n" +
1730+ "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x10\n" +
1731+ "\x03sha\x18\n" +
1732+ " \x01(\tR\x03sha\"\x89\x01\n" +
16991733 "\x12CIActionResolution\x12\x10\n" +
17001734 "\x03key\x18\x01 \x01(\tR\x03key\x12&\n" +
17011735 "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12!\n" +
@@ -580,7 +580,12 @@ type CIRunSource struct {
580 GithubJson string `protobuf:"bytes,2,opt,name=github_json,json=githubJson,proto3" json:"github_json,omitempty"`580 GithubJson string `protobuf:"bytes,2,opt,name=github_json,json=githubJson,proto3" json:"github_json,omitempty"`
581 // Repository of the run: the key of the run record is run/<repo>/<id>, and581 // Repository of the run: the key of the run record is run/<repo>/<id>, and
582 // the runner side knows a run by its id only.582 // the runner side knows a run by its id only.
583- Repo string `protobuf:"bytes,3,opt,name=repo,proto3" json:"repo,omitempty"`583+ Repo string `protobuf:"bytes,3,opt,name=repo,proto3" json:"repo,omitempty"`
584+ // Who pushed the commit: "root", "owner", "token" or empty (a run stored
585+ // before it was recorded, or pushed by nobody). pusher_token_id is the ID of
586+ // the stored token for "token". What a token may read is looked up live.
587+ PusherKind string `protobuf:"bytes,5,opt,name=pusher_kind,json=pusherKind,proto3" json:"pusher_kind,omitempty"`
588+ PusherTokenId string `protobuf:"bytes,6,opt,name=pusher_token_id,json=pusherTokenId,proto3" json:"pusher_token_id,omitempty"`
584 unknownFields protoimpl.UnknownFields589 unknownFields protoimpl.UnknownFields
585 sizeCache protoimpl.SizeCache590 sizeCache protoimpl.SizeCache
586 }591 }
@@ -643,6 +648,20 @@ func (x *CIRunSource) GetRepo() string {
643 return ""648 return ""
644 }649 }
645 650
651+func (x *CIRunSource) GetPusherKind() string {
652+ if x != nil {
653+ return x.PusherKind
654+ }
655+ return ""
656+}
657+
658+func (x *CIRunSource) GetPusherTokenId() string {
659+ if x != nil {
660+ return x.PusherTokenId
661+ }
662+ return ""
663+}
664+
646 // CIStep is a step of a job.665 // CIStep is a step of a job.
647 type CIStep struct {666 type CIStep struct {
648 state protoimpl.MessageState `protogen:"open.v1"`667 state protoimpl.MessageState `protogen:"open.v1"`
@@ -1362,9 +1381,10 @@ func (x *CIRunner) GetTokenHash() []byte {
1362 1381
1363 // CIRepoToken is a job repository token (the "github.token" of a job, or the1382 // CIRepoToken is a job repository token (the "github.token" of a job, or the
1364 // token of one action archive), stored under "ghtoken/<id>". The secret is1383 // token of one action archive), stored under "ghtoken/<id>". The secret is
1365-// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and1384+// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id,
1366-// "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists1385+// "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a
1367-// the tokens of a job so that they are revoked together.1386+// job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the
1387+// tokens that read a repository, so that they go with it.
1368 type CIRepoToken struct {1388 type CIRepoToken struct {
1369 state protoimpl.MessageState `protogen:"open.v1"`1389 state protoimpl.MessageState `protogen:"open.v1"`
1370 Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`1390 Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
@@ -1376,9 +1396,11 @@ type CIRepoToken struct {
1376 RunAttempt int32 `protobuf:"varint,5,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"`1396 RunAttempt int32 `protobuf:"varint,5,opt,name=run_attempt,json=runAttempt,proto3" json:"run_attempt,omitempty"`
1377 JobId string `protobuf:"bytes,6,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"`1397 JobId string `protobuf:"bytes,6,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"`
1378 // Set for the token of an action archive, unset for the job's own token.1398 // Set for the token of an action archive, unset for the job's own token.
1379- Archive bool `protobuf:"varint,7,opt,name=archive,proto3" json:"archive,omitempty"`1399+ Archive bool `protobuf:"varint,7,opt,name=archive,proto3" json:"archive,omitempty"`
1380- ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`1400+ ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
1381- CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`1401+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
1402+ // The commit an archive token downloads.
1403+ Sha string `protobuf:"bytes,10,opt,name=sha,proto3" json:"sha,omitempty"`
1382 unknownFields protoimpl.UnknownFields1404 unknownFields protoimpl.UnknownFields
1383 sizeCache protoimpl.SizeCache1405 sizeCache protoimpl.SizeCache
1384 }1406 }
@@ -1476,6 +1498,13 @@ func (x *CIRepoToken) GetCreatedAt() *timestamppb.Timestamp {
1476 return nil1498 return nil
1477 }1499 }
1478 1500
1501+func (x *CIRepoToken) GetSha() string {
1502+ if x != nil {
1503+ return x.Sha
1504+ }
1505+ return ""
1506+}
1507+
1479 // CIActionResolution is the resolution of an action reference for a run1508 // CIActionResolution is the resolution of an action reference for a run
1480 // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>"1509 // attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>"
1481 // so that every job of the attempt gets the same commit.1510 // so that every job of the attempt gets the same commit.
@@ -1579,13 +1608,16 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
1579 "\fcompleted_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12)\n" +1608 "\fcompleted_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\vcompletedAt\x12)\n" +
1580 "\x10cancel_requested\x18\x14 \x01(\bR\x0fcancelRequested\x12J\n" +1609 "\x10cancel_requested\x18\x14 \x01(\bR\x0fcancelRequested\x12J\n" +
1581 "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAt\x12\x12\n" +1610 "\x13cancel_requested_at\x18\x15 \x01(\v2\x1a.google.protobuf.TimestampR\x11cancelRequestedAt\x12\x12\n" +
1582- "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"~\n" +1611+ "\x04name\x18\x16 \x01(\tR\x04nameJ\x04\b\x12\x10\x13J\x04\b\x13\x10\x14\"\xc7\x01\n" +
1583 "\vCIRunSource\x12\x15\n" +1612 "\vCIRunSource\x12\x15\n" +
1584 "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" +1613 "\x06run_id\x18\x04 \x01(\x03R\x05runId\x12#\n" +
1585 "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" +1614 "\rworkflow_yaml\x18\x01 \x01(\tR\fworkflowYaml\x12\x1f\n" +
1586 "\vgithub_json\x18\x02 \x01(\tR\n" +1615 "\vgithub_json\x18\x02 \x01(\tR\n" +
1587 "githubJson\x12\x12\n" +1616 "githubJson\x12\x12\n" +
1588- "\x04repo\x18\x03 \x01(\tR\x04repo\"\xe5\x02\n" +1617+ "\x04repo\x18\x03 \x01(\tR\x04repo\x12\x1f\n" +
1618+ "\vpusher_kind\x18\x05 \x01(\tR\n" +
1619+ "pusherKind\x12&\n" +
1620+ "\x0fpusher_token_id\x18\x06 \x01(\tR\rpusherTokenId\"\xe5\x02\n" +
1589 "\x06CIStep\x12\x16\n" +1621 "\x06CIStep\x12\x16\n" +
1590 "\x06number\x18\x01 \x01(\x05R\x06number\x12\x12\n" +1622 "\x06number\x18\x01 \x01(\x05R\x06number\x12\x12\n" +
1591 "\x04name\x18\x02 \x01(\tR\x04name\x121\n" +1623 "\x04name\x18\x02 \x01(\tR\x04name\x121\n" +
@@ -1682,7 +1714,7 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
1682 "\x02os\x18\a \x01(\tR\x02os\x12?\n" +1714 "\x02os\x18\a \x01(\tR\x02os\x12?\n" +
1683 "\rregistered_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fregisteredAt\x12\x1d\n" +1715 "\rregistered_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\fregisteredAt\x12\x1d\n" +
1684 "\n" +1716 "\n" +
1685- "token_hash\x18\t \x01(\fR\ttokenHash\"\xa4\x02\n" +1717+ "token_hash\x18\t \x01(\fR\ttokenHash\"\xb6\x02\n" +
1686 "\vCIRepoToken\x12\x0e\n" +1718 "\vCIRepoToken\x12\x0e\n" +
1687 "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +1719 "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
1688 "\x04hash\x18\x02 \x01(\fR\x04hash\x12\x12\n" +1720 "\x04hash\x18\x02 \x01(\fR\x04hash\x12\x12\n" +
@@ -1695,7 +1727,9 @@ const file_ermite_storage_ci_proto_rawDesc = "" +
1695 "\n" +1727 "\n" +
1696 "expires_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x129\n" +1728 "expires_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x129\n" +
1697 "\n" +1729 "\n" +
1698- "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\x89\x01\n" +1730+ "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x10\n" +
1731+ "\x03sha\x18\n" +
1732+ " \x01(\tR\x03sha\"\x89\x01\n" +
1699 "\x12CIActionResolution\x12\x10\n" +1733 "\x12CIActionResolution\x12\x10\n" +
1700 "\x03key\x18\x01 \x01(\tR\x03key\x12&\n" +1734 "\x03key\x18\x01 \x01(\tR\x03key\x12&\n" +
1701 "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12!\n" +1735 "\x0fname_with_owner\x18\x02 \x01(\tR\rnameWithOwner\x12!\n" +
M internal/adapters/githttp/handler.go +9 -5
@@ -42,8 +42,10 @@ var Patterns = []string{
4242 // ERMITE_OWNER: the URL github.repository gives (actions/checkout clones
4343 // <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal
4444 // 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.
45+// fall through to the web, which answers 404. The handler matches the owner
46+// without regard to case, as GitHub does, but the mux only the exact spelling
47+// of ERMITE_OWNER (lower case, like a repository name); register them only for
48+// a valid owner and set Config.Owner to the same.
4749 func OwnerPatterns(owner string) []string {
4850 out := make([]string, len(Patterns))
4951 for i, p := range Patterns {
@@ -176,8 +178,8 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) {
176178 p := strings.TrimPrefix(r.URL.Path, "/")
177179 var head, rest string
178180 for _, suffix := range gitSuffixes {
179- if h, ok := strings.CutSuffix(p, suffix); ok {
180- head, rest = h, suffix[1:]
181+ if trimmed, ok := strings.CutSuffix(p, suffix); ok {
182+ head, rest = trimmed, suffix[1:]
181183 break
182184 }
183185 }
@@ -186,7 +188,9 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) {
186188 }
187189 seg := head
188190 if owner, repo, ok := strings.Cut(head, "/"); ok {
189- if h.cfg.Owner == "" || owner != h.cfg.Owner {
191+ // The owner is matched as GitHub does, without regard to case, like the
192+ // REST subset and the references of actions (domain.OwnerMatches).
193+ if !domain.OwnerMatches(h.cfg.Owner, owner) {
190194 return route{}, http.StatusNotFound
191195 }
192196 seg = repo
@@ -42,8 +42,10 @@ var Patterns = []string{
42 // ERMITE_OWNER: the URL github.repository gives (actions/checkout clones42 // ERMITE_OWNER: the URL github.repository gives (actions/checkout clones
43 // <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal43 // <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 owners44 // 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 valid45+// fall through to the web, which answers 404. The handler matches the owner
46-// owner; Config.Owner must be the same.46+// without regard to case, as GitHub does, but the mux only the exact spelling
47+// of ERMITE_OWNER (lower case, like a repository name); register them only for
48+// a valid owner and set Config.Owner to the same.
47 func OwnerPatterns(owner string) []string {49 func OwnerPatterns(owner string) []string {
48 out := make([]string, len(Patterns))50 out := make([]string, len(Patterns))
49 for i, p := range Patterns {51 for i, p := range Patterns {
@@ -176,8 +178,8 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) {
176 p := strings.TrimPrefix(r.URL.Path, "/")178 p := strings.TrimPrefix(r.URL.Path, "/")
177 var head, rest string179 var head, rest string
178 for _, suffix := range gitSuffixes {180 for _, suffix := range gitSuffixes {
179- if h, ok := strings.CutSuffix(p, suffix); ok {181+ if trimmed, ok := strings.CutSuffix(p, suffix); ok {
180- head, rest = h, suffix[1:]182+ head, rest = trimmed, suffix[1:]
181 break183 break
182 }184 }
183 }185 }
@@ -186,7 +188,9 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) {
186 }188 }
187 seg := head189 seg := head
188 if owner, repo, ok := strings.Cut(head, "/"); ok {190 if owner, repo, ok := strings.Cut(head, "/"); ok {
189- if h.cfg.Owner == "" || owner != h.cfg.Owner {191+ // The owner is matched as GitHub does, without regard to case, like the
192+ // REST subset and the references of actions (domain.OwnerMatches).
193+ if !domain.OwnerMatches(h.cfg.Owner, owner) {
190 return route{}, http.StatusNotFound194 return route{}, http.StatusNotFound
191 }195 }
192 seg = repo196 seg = repo
M internal/adapters/githttp/handler_test.go +3 -2
@@ -11,6 +11,7 @@ import (
1111 "net/netip"
1212 "os"
1313 "path/filepath"
14+ "slices"
1415 "strings"
1516 "sync"
1617 "testing"
@@ -188,7 +189,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
188189 }
189190 r.h = githttp.New(cfg)
190191 mux := http.NewServeMux()
191- for _, p := range append(githttp.Patterns, githttp.OwnerPatterns("acme")...) {
192+ for _, p := range slices.Concat(githttp.Patterns, githttp.OwnerPatterns("acme"), githttp.OwnerPatterns("ACME")) {
192193 mux.Handle(p, r.h)
193194 }
194195 r.srv = httptest.NewServer(mux)
@@ -522,7 +523,7 @@ func TestHandlerServesTheOwnerForm(t *testing.T) {
522523 {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200},
523524 {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200},
524525 {"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+ {"owner case does not matter", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 200},
526527 {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404},
527528 {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404},
528529 {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404},
@@ -11,6 +11,7 @@ import (
11 "net/netip"11 "net/netip"
12 "os"12 "os"
13 "path/filepath"13 "path/filepath"
14+ "slices"
14 "strings"15 "strings"
15 "sync"16 "sync"
16 "testing"17 "testing"
@@ -188,7 +189,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
188 }189 }
189 r.h = githttp.New(cfg)190 r.h = githttp.New(cfg)
190 mux := http.NewServeMux()191 mux := http.NewServeMux()
191- for _, p := range append(githttp.Patterns, githttp.OwnerPatterns("acme")...) {192+ for _, p := range slices.Concat(githttp.Patterns, githttp.OwnerPatterns("acme"), githttp.OwnerPatterns("ACME")) {
192 mux.Handle(p, r.h)193 mux.Handle(p, r.h)
193 }194 }
194 r.srv = httptest.NewServer(mux)195 r.srv = httptest.NewServer(mux)
@@ -522,7 +523,7 @@ func TestHandlerServesTheOwnerForm(t *testing.T) {
522 {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200},523 {"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 {"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 {"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+ {"owner case does not matter", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 200},
526 {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404},527 {"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 {"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 {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404},
M internal/adapters/githubapi/handler.go +93 -24
@@ -16,6 +16,7 @@
1616 package githubapi
1717
1818 import (
19+ "cmp"
1920 "context"
2021 "errors"
2122 "io"
@@ -23,6 +24,8 @@ import (
2324 "net/netip"
2425 "strconv"
2526 "strings"
27+ "sync"
28+ "time"
2629
2730 "github.com/bnema/zerowrap"
2831
@@ -40,8 +43,19 @@ var Patterns = []string{Prefix + "/"}
4043 // notFoundBody is GitHub's body for an unknown endpoint or repository.
4144 const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
4245
43-// DefaultMaxArchives bounds the archives written at once.
44-const DefaultMaxArchives = 8
46+// Bounds on the archives written at once.
47+const (
48+ // DefaultMaxArchives is the archives written at once on the server.
49+ DefaultMaxArchives = 8
50+ // DefaultMaxArchivesPerClient is the archives written at once for one client
51+ // (see clientlimit.Key): a runner downloads the actions of a job one by one,
52+ // so a few are plenty, and one client cannot take every slot.
53+ DefaultMaxArchivesPerClient = 3
54+ // DefaultWriteIdleTimeout is how long a write of the archive may wait for the
55+ // client. The deadline moves forward with every chunk: an archive is bounded
56+ // by its size, not by its duration, but a client that stops reading is cut.
57+ DefaultWriteIdleTimeout = 2 * time.Minute
58+)
4559
4660 // Config configures a Handler.
4761 type Config struct {
@@ -52,8 +66,12 @@ type Config struct {
5266 // Limiter counts failed authentications; nil builds a default one.
5367 Limiter ports.FailureLimiter
5468 // MaxArchives bounds the archives written at once (each runs a git
55- // process); zero means DefaultMaxArchives.
56- MaxArchives int
69+ // process); zero means DefaultMaxArchives. MaxArchivesPerClient bounds them
70+ // per client; zero means DefaultMaxArchivesPerClient. WriteIdleTimeout is
71+ // the idle write deadline; zero means DefaultWriteIdleTimeout.
72+ MaxArchives int
73+ MaxArchivesPerClient int
74+ WriteIdleTimeout time.Duration
5775 }
5876
5977 // Handler serves the REST subset.
@@ -61,6 +79,11 @@ type Handler struct {
6179 cfg Config
6280 limiter ports.FailureLimiter
6381 archives chan struct{}
82+ perCli int
83+ idle time.Duration
84+
85+ mu sync.Mutex // guards clients
86+ clients map[string]int // archives being written, per clientlimit.Key
6487 }
6588
6689 // New builds a Handler.
@@ -74,6 +97,9 @@ func New(cfg Config) *Handler {
7497 n = DefaultMaxArchives
7598 }
7699 h.archives = make(chan struct{}, n)
100+ h.perCli = cmp.Or(cfg.MaxArchivesPerClient, DefaultMaxArchivesPerClient)
101+ h.idle = cmp.Or(cfg.WriteIdleTimeout, DefaultWriteIdleTimeout)
102+ h.clients = make(map[string]int)
77103 return h
78104 }
79105
@@ -127,7 +153,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
127153 }
128154 writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`))
129155 case "tarball":
130- h.tarball(w, r, p, owner, repo, ref)
156+ h.tarball(w, r, p, owner, repo, ref, clientlimit.Key(addr))
131157 }
132158 }
133159
@@ -150,35 +176,33 @@ func route(rest string) (kind, owner, repo, ref string, ok bool) {
150176 return "", "", "", "", false
151177 }
152178
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) {
179+// tarball answers an archive request. A HEAD resolves the ref and sends the
180+// headers of the archive, and runs no git. A GET streams the archive; it is not
181+// a 302 to a codeload: ermite serves the bytes itself, which the runner follows
182+// without a second credential.
183+func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref, client string) {
156184 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- }
165185 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) {
186+ name, err := h.cfg.API.TarballName(ctx, p, owner, repo, ref)
187+ if err != nil {
173188 h.fail(ctx, w, err)
189+ return
174190 }
191+ setArchiveHeaders(w, name)
192+ w.WriteHeader(http.StatusOK)
175193 return
176194 }
195+ release, ok := h.acquire(w, client)
196+ if !ok {
197+ return
198+ }
199+ defer release()
200+ rc := http.NewResponseController(w)
177201 started := false
178202 err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer {
179203 started = true
180204 setArchiveHeaders(w, name)
181- return w
205+ return &idleWriter{w: w, rc: rc, idle: h.idle}
182206 })
183207 switch {
184208 case err == nil:
@@ -191,6 +215,51 @@ func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Princ
191215 }
192216 }
193217
218+// acquire takes an archive slot for the client, or answers 429 (the client
219+// already has its share) or 503 (the server writes all it can) and reports
220+// false. It never waits: an archive can take minutes.
221+func (h *Handler) acquire(w http.ResponseWriter, client string) (release func(), ok bool) {
222+ h.mu.Lock()
223+ if h.clients[client] >= h.perCli {
224+ h.mu.Unlock()
225+ w.Header().Set("Retry-After", "5")
226+ jsonError(w, http.StatusTooManyRequests, "Too many archives in progress")
227+ return nil, false
228+ }
229+ h.clients[client]++
230+ h.mu.Unlock()
231+ releaseClient := func() {
232+ h.mu.Lock()
233+ if h.clients[client]--; h.clients[client] <= 0 {
234+ delete(h.clients, client)
235+ }
236+ h.mu.Unlock()
237+ }
238+ select {
239+ case h.archives <- struct{}{}:
240+ return func() { <-h.archives; releaseClient() }, true
241+ default:
242+ releaseClient()
243+ w.Header().Set("Retry-After", "5")
244+ jsonError(w, http.StatusServiceUnavailable, "Server busy")
245+ return nil, false
246+ }
247+}
248+
249+// idleWriter moves the write deadline of the connection forward before each
250+// chunk, so that only a client that stops reading is cut. Where the response
251+// writer cannot set a deadline, the writes are not bounded here.
252+type idleWriter struct {
253+ w io.Writer
254+ rc *http.ResponseController
255+ idle time.Duration
256+}
257+
258+func (i *idleWriter) Write(b []byte) (int, error) {
259+ _ = i.rc.SetWriteDeadline(time.Now().Add(i.idle))
260+ return i.w.Write(b)
261+}
262+
194263 func setArchiveHeaders(w http.ResponseWriter, name string) {
195264 w.Header().Set("Content-Type", "application/x-gzip")
196265 w.Header().Set("Content-Disposition", "attachment; filename="+name)
@@ -16,6 +16,7 @@
16 package githubapi16 package githubapi
17 17
18 import (18 import (
19+ "cmp"
19 "context"20 "context"
20 "errors"21 "errors"
21 "io"22 "io"
@@ -23,6 +24,8 @@ import (
23 "net/netip"24 "net/netip"
24 "strconv"25 "strconv"
25 "strings"26 "strings"
27+ "sync"
28+ "time"
26 29
27 "github.com/bnema/zerowrap"30 "github.com/bnema/zerowrap"
28 31
@@ -40,8 +43,19 @@ var Patterns = []string{Prefix + "/"}
40 // notFoundBody is GitHub's body for an unknown endpoint or repository.43 // notFoundBody is GitHub's body for an unknown endpoint or repository.
41 const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`44 const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
42 45
43-// DefaultMaxArchives bounds the archives written at once.46+// Bounds on the archives written at once.
44-const DefaultMaxArchives = 847+const (
48+ // DefaultMaxArchives is the archives written at once on the server.
49+ DefaultMaxArchives = 8
50+ // DefaultMaxArchivesPerClient is the archives written at once for one client
51+ // (see clientlimit.Key): a runner downloads the actions of a job one by one,
52+ // so a few are plenty, and one client cannot take every slot.
53+ DefaultMaxArchivesPerClient = 3
54+ // DefaultWriteIdleTimeout is how long a write of the archive may wait for the
55+ // client. The deadline moves forward with every chunk: an archive is bounded
56+ // by its size, not by its duration, but a client that stops reading is cut.
57+ DefaultWriteIdleTimeout = 2 * time.Minute
58+)
45 59
46 // Config configures a Handler.60 // Config configures a Handler.
47 type Config struct {61 type Config struct {
@@ -52,8 +66,12 @@ type Config struct {
52 // Limiter counts failed authentications; nil builds a default one.66 // Limiter counts failed authentications; nil builds a default one.
53 Limiter ports.FailureLimiter67 Limiter ports.FailureLimiter
54 // MaxArchives bounds the archives written at once (each runs a git68 // MaxArchives bounds the archives written at once (each runs a git
55- // process); zero means DefaultMaxArchives.69+ // process); zero means DefaultMaxArchives. MaxArchivesPerClient bounds them
56- MaxArchives int70+ // per client; zero means DefaultMaxArchivesPerClient. WriteIdleTimeout is
71+ // the idle write deadline; zero means DefaultWriteIdleTimeout.
72+ MaxArchives int
73+ MaxArchivesPerClient int
74+ WriteIdleTimeout time.Duration
57 }75 }
58 76
59 // Handler serves the REST subset.77 // Handler serves the REST subset.
@@ -61,6 +79,11 @@ type Handler struct {
61 cfg Config79 cfg Config
62 limiter ports.FailureLimiter80 limiter ports.FailureLimiter
63 archives chan struct{}81 archives chan struct{}
82+ perCli int
83+ idle time.Duration
84+
85+ mu sync.Mutex // guards clients
86+ clients map[string]int // archives being written, per clientlimit.Key
64 }87 }
65 88
66 // New builds a Handler.89 // New builds a Handler.
@@ -74,6 +97,9 @@ func New(cfg Config) *Handler {
74 n = DefaultMaxArchives97 n = DefaultMaxArchives
75 }98 }
76 h.archives = make(chan struct{}, n)99 h.archives = make(chan struct{}, n)
100+ h.perCli = cmp.Or(cfg.MaxArchivesPerClient, DefaultMaxArchivesPerClient)
101+ h.idle = cmp.Or(cfg.WriteIdleTimeout, DefaultWriteIdleTimeout)
102+ h.clients = make(map[string]int)
77 return h103 return h
78 }104 }
79 105
@@ -127,7 +153,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
127 }153 }
128 writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`))154 writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`))
129 case "tarball":155 case "tarball":
130- h.tarball(w, r, p, owner, repo, ref)156+ h.tarball(w, r, p, owner, repo, ref, clientlimit.Key(addr))
131 }157 }
132 }158 }
133 159
@@ -150,35 +176,33 @@ func route(rest string) (kind, owner, repo, ref string, ok bool) {
150 return "", "", "", "", false176 return "", "", "", "", false
151 }177 }
152 178
153-// tarball streams the archive. It is not a 302 to a codeload: ermite serves the179+// tarball answers an archive request. A HEAD resolves the ref and sends the
154-// bytes itself, which the runner follows without a second credential.180+// headers of the archive, and runs no git. A GET streams the archive; it is not
155-func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref string) {181+// a 302 to a codeload: ermite serves the bytes itself, which the runner follows
182+// without a second credential.
183+func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref, client string) {
156 ctx := r.Context()184 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 {185 if r.Method == http.MethodHead {
166- // Resolve without writing: a HEAD is answered with the headers only.186+ name, err := h.cfg.API.TarballName(ctx, p, owner, repo, ref)
167- err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer {187+ if err != nil {
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)188 h.fail(ctx, w, err)
189+ return
174 }190 }
191+ setArchiveHeaders(w, name)
192+ w.WriteHeader(http.StatusOK)
175 return193 return
176 }194 }
195+ release, ok := h.acquire(w, client)
196+ if !ok {
197+ return
198+ }
199+ defer release()
200+ rc := http.NewResponseController(w)
177 started := false201 started := false
178 err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer {202 err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer {
179 started = true203 started = true
180 setArchiveHeaders(w, name)204 setArchiveHeaders(w, name)
181- return w205+ return &idleWriter{w: w, rc: rc, idle: h.idle}
182 })206 })
183 switch {207 switch {
184 case err == nil:208 case err == nil:
@@ -191,6 +215,51 @@ func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Princ
191 }215 }
192 }216 }
193 217
218+// acquire takes an archive slot for the client, or answers 429 (the client
219+// already has its share) or 503 (the server writes all it can) and reports
220+// false. It never waits: an archive can take minutes.
221+func (h *Handler) acquire(w http.ResponseWriter, client string) (release func(), ok bool) {
222+ h.mu.Lock()
223+ if h.clients[client] >= h.perCli {
224+ h.mu.Unlock()
225+ w.Header().Set("Retry-After", "5")
226+ jsonError(w, http.StatusTooManyRequests, "Too many archives in progress")
227+ return nil, false
228+ }
229+ h.clients[client]++
230+ h.mu.Unlock()
231+ releaseClient := func() {
232+ h.mu.Lock()
233+ if h.clients[client]--; h.clients[client] <= 0 {
234+ delete(h.clients, client)
235+ }
236+ h.mu.Unlock()
237+ }
238+ select {
239+ case h.archives <- struct{}{}:
240+ return func() { <-h.archives; releaseClient() }, true
241+ default:
242+ releaseClient()
243+ w.Header().Set("Retry-After", "5")
244+ jsonError(w, http.StatusServiceUnavailable, "Server busy")
245+ return nil, false
246+ }
247+}
248+
249+// idleWriter moves the write deadline of the connection forward before each
250+// chunk, so that only a client that stops reading is cut. Where the response
251+// writer cannot set a deadline, the writes are not bounded here.
252+type idleWriter struct {
253+ w io.Writer
254+ rc *http.ResponseController
255+ idle time.Duration
256+}
257+
258+func (i *idleWriter) Write(b []byte) (int, error) {
259+ _ = i.rc.SetWriteDeadline(time.Now().Add(i.idle))
260+ return i.w.Write(b)
261+}
262+
194 func setArchiveHeaders(w http.ResponseWriter, name string) {263 func setArchiveHeaders(w http.ResponseWriter, name string) {
195 w.Header().Set("Content-Type", "application/x-gzip")264 w.Header().Set("Content-Type", "application/x-gzip")
196 w.Header().Set("Content-Disposition", "attachment; filename="+name)265 w.Header().Set("Content-Disposition", "attachment; filename="+name)
M internal/adapters/gitremote/gitremote.go +24 -0
@@ -45,6 +45,29 @@ type Config struct {
4545 MaxOutput int
4646 // AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default.
4747 AllowedProtocols string
48+ // Proxy is the proxy environment forwarded to git, as NAME=value pairs: see
49+ // ProxyEnv. Nothing else of the environment of ermite reaches git.
50+ Proxy []string
51+}
52+
53+// proxyVars are the variables git (through libcurl) reads to reach github.com
54+// through a proxy. They are the only ones of the environment of ermite that
55+// the ls-remote processes get.
56+var proxyVars = []string{
57+ "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "ALL_PROXY",
58+ "https_proxy", "http_proxy", "no_proxy", "all_proxy",
59+}
60+
61+// ProxyEnv picks the proxy variables from an environment lookup (os.Getenv),
62+// for Config.Proxy. Empty ones are left out.
63+func ProxyEnv(get func(string) string) []string {
64+ var out []string
65+ for _, name := range proxyVars {
66+ if v := get(name); v != "" {
67+ out = append(out, name+"="+v)
68+ }
69+ }
70+ return out
4871 }
4972
5073 // Remote implements ports.GitRemote over the git binary.
@@ -121,6 +144,7 @@ func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.Remo
121144 "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never",
122145 "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path,
123146 }
147+ cmd.Env = append(cmd.Env, r.cfg.Proxy...)
124148 cmd.Dir = r.cfg.Home
125149 cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096}
126150 cmd.WaitDelay = 2 * time.Second
@@ -45,6 +45,29 @@ type Config struct {
45 MaxOutput int45 MaxOutput int
46 // AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default.46 // AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default.
47 AllowedProtocols string47 AllowedProtocols string
48+ // Proxy is the proxy environment forwarded to git, as NAME=value pairs: see
49+ // ProxyEnv. Nothing else of the environment of ermite reaches git.
50+ Proxy []string
51+}
52+
53+// proxyVars are the variables git (through libcurl) reads to reach github.com
54+// through a proxy. They are the only ones of the environment of ermite that
55+// the ls-remote processes get.
56+var proxyVars = []string{
57+ "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "ALL_PROXY",
58+ "https_proxy", "http_proxy", "no_proxy", "all_proxy",
59+}
60+
61+// ProxyEnv picks the proxy variables from an environment lookup (os.Getenv),
62+// for Config.Proxy. Empty ones are left out.
63+func ProxyEnv(get func(string) string) []string {
64+ var out []string
65+ for _, name := range proxyVars {
66+ if v := get(name); v != "" {
67+ out = append(out, name+"="+v)
68+ }
69+ }
70+ return out
48 }71 }
49 72
50 // Remote implements ports.GitRemote over the git binary.73 // Remote implements ports.GitRemote over the git binary.
@@ -121,6 +144,7 @@ func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.Remo
121 "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never",144 "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never",
122 "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path,145 "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path,
123 }146 }
147+ cmd.Env = append(cmd.Env, r.cfg.Proxy...)
124 cmd.Dir = r.cfg.Home148 cmd.Dir = r.cfg.Home
125 cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096}149 cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096}
126 cmd.WaitDelay = 2 * time.Second150 cmd.WaitDelay = 2 * time.Second
M internal/adapters/gitremote/gitremote_test.go +31 -0
@@ -146,3 +146,34 @@ func TestListRefsGitEnvironment(t *testing.T) {
146146 assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*")
147147 assert.Contains(t, env, "credential.helper=")
148148 }
149+
150+// GO-008: the proxy variables of ermite reach git, and nothing else of its
151+// environment does.
152+func TestListRefsForwardsTheProxyEnvironment(t *testing.T) {
153+ out := filepath.Join(t.TempDir(), "env")
154+ bin := filepath.Join(t.TempDir(), "git")
155+ require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755))
156+ t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128")
157+ t.Setenv("no_proxy", "localhost,.internal")
158+ t.Setenv("HTTP_PROXY", "")
159+ t.Setenv("GITHUB_TOKEN", "not-for-git")
160+
161+ proxy := gitremote.ProxyEnv(os.Getenv)
162+ assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out")
163+
164+ _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r")
165+ require.NoError(t, err)
166+ b, err := os.ReadFile(out)
167+ require.NoError(t, err)
168+ env := string(b)
169+ assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128")
170+ assert.Contains(t, env, "no_proxy=localhost,.internal")
171+ assert.NotContains(t, env, "not-for-git")
172+ assert.NotContains(t, env, "HTTP_PROXY=")
173+
174+ // Without Config.Proxy nothing is forwarded, whatever the process has.
175+ _, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r")
176+ require.NoError(t, err)
177+ b, _ = os.ReadFile(out)
178+ assert.NotContains(t, string(b), "proxy.internal")
179+}
@@ -146,3 +146,34 @@ func TestListRefsGitEnvironment(t *testing.T) {
146 assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*")146 assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*")
147 assert.Contains(t, env, "credential.helper=")147 assert.Contains(t, env, "credential.helper=")
148 }148 }
149+
150+// GO-008: the proxy variables of ermite reach git, and nothing else of its
151+// environment does.
152+func TestListRefsForwardsTheProxyEnvironment(t *testing.T) {
153+ out := filepath.Join(t.TempDir(), "env")
154+ bin := filepath.Join(t.TempDir(), "git")
155+ require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755))
156+ t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128")
157+ t.Setenv("no_proxy", "localhost,.internal")
158+ t.Setenv("HTTP_PROXY", "")
159+ t.Setenv("GITHUB_TOKEN", "not-for-git")
160+
161+ proxy := gitremote.ProxyEnv(os.Getenv)
162+ assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out")
163+
164+ _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r")
165+ require.NoError(t, err)
166+ b, err := os.ReadFile(out)
167+ require.NoError(t, err)
168+ env := string(b)
169+ assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128")
170+ assert.Contains(t, env, "no_proxy=localhost,.internal")
171+ assert.NotContains(t, env, "not-for-git")
172+ assert.NotContains(t, env, "HTTP_PROXY=")
173+
174+ // Without Config.Proxy nothing is forwarded, whatever the process has.
175+ _, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r")
176+ require.NoError(t, err)
177+ b, _ = os.ReadFile(out)
178+ assert.NotContains(t, string(b), "proxy.internal")
179+}
M internal/adapters/kvstore/ci.go +6 -1
@@ -590,6 +590,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s
590590 }
591591 src := mustMarshal(&storagepb.CIRunSource{
592592 RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON,
593+ PusherKind: string(draft.Source.Pusher.Kind), PusherTokenId: draft.Source.Pusher.TokenID,
593594 })
594595 b.RequireAbsent(runSrcKey(draft.Run.ID))
595596 b.Put(runSrcKey(draft.Run.ID), src)
@@ -642,7 +643,10 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource,
642643 if err != nil {
643644 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
644645 }
645- return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil
646+ return domain.CIRunSource{
647+ RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson(),
648+ Pusher: domain.CIPusher{Kind: domain.CIPusherKind(pb.GetPusherKind()), TokenID: pb.GetPusherTokenId()},
649+ }, nil
646650 }
647651
648652 // ListRuns returns the runs of the repository, newest first.
@@ -856,6 +860,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by
856860 }
857861 keys = append(keys, slices.Clone(key))
858862 }
863+ keys = append(keys, repoReaderKeys(ctx, snap, repo)...)
859864 for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} {
860865 for k := range snap.Prefix(ctx, []byte(p)) {
861866 keys = append(keys, slices.Clone(k))
@@ -590,6 +590,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s
590 }590 }
591 src := mustMarshal(&storagepb.CIRunSource{591 src := mustMarshal(&storagepb.CIRunSource{
592 RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON,592 RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON,
593+ PusherKind: string(draft.Source.Pusher.Kind), PusherTokenId: draft.Source.Pusher.TokenID,
593 })594 })
594 b.RequireAbsent(runSrcKey(draft.Run.ID))595 b.RequireAbsent(runSrcKey(draft.Run.ID))
595 b.Put(runSrcKey(draft.Run.ID), src)596 b.Put(runSrcKey(draft.Run.ID), src)
@@ -642,7 +643,10 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource,
642 if err != nil {643 if err != nil {
643 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)644 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
644 }645 }
645- return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil646+ return domain.CIRunSource{
647+ RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson(),
648+ Pusher: domain.CIPusher{Kind: domain.CIPusherKind(pb.GetPusherKind()), TokenID: pb.GetPusherTokenId()},
649+ }, nil
646 }650 }
647 651
648 // ListRuns returns the runs of the repository, newest first.652 // ListRuns returns the runs of the repository, newest first.
@@ -856,6 +860,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by
856 }860 }
857 keys = append(keys, slices.Clone(key))861 keys = append(keys, slices.Clone(key))
858 }862 }
863+ keys = append(keys, repoReaderKeys(ctx, snap, repo)...)
859 for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} {864 for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} {
860 for k := range snap.Prefix(ctx, []byte(p)) {865 for k := range snap.Prefix(ctx, []byte(p)) {
861 keys = append(keys, slices.Clone(k))866 keys = append(keys, slices.Clone(k))
M internal/adapters/kvstore/ci_token.go +39 -8
@@ -22,7 +22,8 @@ import (
2222 //
2323 // ghtoken/<id> CIRepoToken
2424 // ghtokenhash/<hash> token id
25-// ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> empty: the tokens of a job
25+// ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> the id: the tokens of a job
26+// ghtokenrepo/<repo>/<id> the job key: the tokens that read a repository
2627 // actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution
2728 //
2829 // Stored tokens use token/ and tokenhash/: the two families never meet, so a
@@ -31,6 +32,7 @@ const (
3132 repoTokenPrefix = "ghtoken/"
3233 repoTokenHashPrefix = "ghtokenhash/"
3334 repoTokenJobPrefix = "ghtokenjob/"
35+ repoTokenRepoPrefix = "ghtokenrepo/"
3436 resolutionPrefix = "actres/"
3537 )
3638
@@ -46,6 +48,12 @@ func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string {
4648 func repoTokenJobKey(t domain.CIRepoToken) []byte {
4749 return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID)
4850 }
51+func repoTokenRepoPrefixOf(repo domain.RepoName) string {
52+ return repoTokenRepoPrefix + repo.String() + "/"
53+}
54+func repoTokenRepoKey(repo domain.RepoName, id string) []byte {
55+ return []byte(repoTokenRepoPrefixOf(repo) + id)
56+}
4957 func resolutionKey(runID int64, attempt int32, key string) []byte {
5058 sum := sha256.Sum256([]byte(key))
5159 return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:])))
@@ -54,7 +62,7 @@ func resolutionKey(runID int64, attempt int32, key string) []byte {
5462 func encodeRepoToken(t domain.CIRepoToken) []byte {
5563 return mustMarshal(&storagepb.CIRepoToken{
5664 Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,
57- Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt),
65+ Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha,
5866 })
5967 }
6068
@@ -70,6 +78,7 @@ func decodeRepoToken(val []byte) (domain.CIRepoToken, error) {
7078 t := domain.CIRepoToken{
7179 ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),
7280 Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),
81+ Sha: pb.GetSha(),
7382 }
7483 copy(t.Hash[:], pb.GetHash())
7584 return t, nil
@@ -90,6 +99,7 @@ func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite,
9099 if it, ok := snap.Get(ctx, repoTokenKey(id)); ok {
91100 if t, err := decodeRepoToken(it.Value); err == nil {
92101 b.Delete(repoTokenHashKey(t.Hash))
102+ b.Delete(repoTokenRepoKey(t.Repo, id))
93103 }
94104 }
95105 }
@@ -100,6 +110,7 @@ func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite,
100110 b.Put(repoTokenKey(t.ID), encodeRepoToken(t))
101111 b.Put(repoTokenHashKey(t.Hash), []byte(t.ID))
102112 b.Put(repoTokenJobKey(t), []byte(t.ID))
113+ b.Put(repoTokenRepoKey(t.Repo, t.ID), []byte(t.ID))
103114 guarded[t.Repo] = true
104115 }
105116 for _, r := range w.Resolutions {
@@ -158,20 +169,40 @@ func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32
158169 }, nil
159170 }
160171
172+// tokenKeys lists the keys of the token with that id: the record, its hash
173+// index, its entry in the list of its job and in the list of its repository.
174+func tokenKeys(ctx context.Context, snap *kv.Snapshot, id string) [][]byte {
175+ keys := [][]byte{repoTokenKey(id)}
176+ if it, ok := snap.Get(ctx, repoTokenKey(id)); ok {
177+ if t, err := decodeRepoToken(it.Value); err == nil {
178+ keys = append(keys, repoTokenHashKey(t.Hash), repoTokenJobKey(t), repoTokenRepoKey(t.Repo, id))
179+ }
180+ }
181+ return keys
182+}
183+
161184 // repoTokenKeys lists the keys of the repository tokens and action
162185 // resolutions of a run, for its deletion.
163186 func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {
164187 var keys [][]byte
165188 for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) {
166- keys = append(keys, append([]byte(nil), key...), repoTokenKey(string(item.Value)))
167- if it, ok := snap.Get(ctx, repoTokenKey(string(item.Value))); ok {
168- if t, err := decodeRepoToken(it.Value); err == nil {
169- keys = append(keys, repoTokenHashKey(t.Hash))
170- }
171- }
189+ keys = append(keys, append([]byte(nil), key...))
190+ keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...)
172191 }
173192 for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) {
174193 keys = append(keys, append([]byte(nil), key...))
175194 }
176195 return keys
177196 }
197+
198+// repoReaderKeys lists the keys of every repository token that reads the
199+// repository, whichever job holds it: the archive tokens given to the jobs of
200+// other repositories go with the repository they read.
201+func repoReaderKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
202+ var keys [][]byte
203+ for key, item := range snap.Prefix(ctx, []byte(repoTokenRepoPrefixOf(repo))) {
204+ keys = append(keys, append([]byte(nil), key...))
205+ keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...)
206+ }
207+ return keys
208+}
@@ -22,7 +22,8 @@ import (
22 //22 //
23 // ghtoken/<id> CIRepoToken23 // ghtoken/<id> CIRepoToken
24 // ghtokenhash/<hash> token id24 // ghtokenhash/<hash> token id
25-// ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> empty: the tokens of a job25+// ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> the id: the tokens of a job
26+// ghtokenrepo/<repo>/<id> the job key: the tokens that read a repository
26 // actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution27 // actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution
27 //28 //
28 // Stored tokens use token/ and tokenhash/: the two families never meet, so a29 // Stored tokens use token/ and tokenhash/: the two families never meet, so a
@@ -31,6 +32,7 @@ const (
31 repoTokenPrefix = "ghtoken/"32 repoTokenPrefix = "ghtoken/"
32 repoTokenHashPrefix = "ghtokenhash/"33 repoTokenHashPrefix = "ghtokenhash/"
33 repoTokenJobPrefix = "ghtokenjob/"34 repoTokenJobPrefix = "ghtokenjob/"
35+ repoTokenRepoPrefix = "ghtokenrepo/"
34 resolutionPrefix = "actres/"36 resolutionPrefix = "actres/"
35 )37 )
36 38
@@ -46,6 +48,12 @@ func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string {
46 func repoTokenJobKey(t domain.CIRepoToken) []byte {48 func repoTokenJobKey(t domain.CIRepoToken) []byte {
47 return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID)49 return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID)
48 }50 }
51+func repoTokenRepoPrefixOf(repo domain.RepoName) string {
52+ return repoTokenRepoPrefix + repo.String() + "/"
53+}
54+func repoTokenRepoKey(repo domain.RepoName, id string) []byte {
55+ return []byte(repoTokenRepoPrefixOf(repo) + id)
56+}
49 func resolutionKey(runID int64, attempt int32, key string) []byte {57 func resolutionKey(runID int64, attempt int32, key string) []byte {
50 sum := sha256.Sum256([]byte(key))58 sum := sha256.Sum256([]byte(key))
51 return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:])))59 return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:])))
@@ -54,7 +62,7 @@ func resolutionKey(runID int64, attempt int32, key string) []byte {
54 func encodeRepoToken(t domain.CIRepoToken) []byte {62 func encodeRepoToken(t domain.CIRepoToken) []byte {
55 return mustMarshal(&storagepb.CIRepoToken{63 return mustMarshal(&storagepb.CIRepoToken{
56 Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,64 Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,
57- Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt),65+ Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha,
58 })66 })
59 }67 }
60 68
@@ -70,6 +78,7 @@ func decodeRepoToken(val []byte) (domain.CIRepoToken, error) {
70 t := domain.CIRepoToken{78 t := domain.CIRepoToken{
71 ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),79 ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),
72 Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),80 Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),
81+ Sha: pb.GetSha(),
73 }82 }
74 copy(t.Hash[:], pb.GetHash())83 copy(t.Hash[:], pb.GetHash())
75 return t, nil84 return t, nil
@@ -90,6 +99,7 @@ func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite,
90 if it, ok := snap.Get(ctx, repoTokenKey(id)); ok {99 if it, ok := snap.Get(ctx, repoTokenKey(id)); ok {
91 if t, err := decodeRepoToken(it.Value); err == nil {100 if t, err := decodeRepoToken(it.Value); err == nil {
92 b.Delete(repoTokenHashKey(t.Hash))101 b.Delete(repoTokenHashKey(t.Hash))
102+ b.Delete(repoTokenRepoKey(t.Repo, id))
93 }103 }
94 }104 }
95 }105 }
@@ -100,6 +110,7 @@ func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite,
100 b.Put(repoTokenKey(t.ID), encodeRepoToken(t))110 b.Put(repoTokenKey(t.ID), encodeRepoToken(t))
101 b.Put(repoTokenHashKey(t.Hash), []byte(t.ID))111 b.Put(repoTokenHashKey(t.Hash), []byte(t.ID))
102 b.Put(repoTokenJobKey(t), []byte(t.ID))112 b.Put(repoTokenJobKey(t), []byte(t.ID))
113+ b.Put(repoTokenRepoKey(t.Repo, t.ID), []byte(t.ID))
103 guarded[t.Repo] = true114 guarded[t.Repo] = true
104 }115 }
105 for _, r := range w.Resolutions {116 for _, r := range w.Resolutions {
@@ -158,20 +169,40 @@ func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32
158 }, nil169 }, nil
159 }170 }
160 171
172+// tokenKeys lists the keys of the token with that id: the record, its hash
173+// index, its entry in the list of its job and in the list of its repository.
174+func tokenKeys(ctx context.Context, snap *kv.Snapshot, id string) [][]byte {
175+ keys := [][]byte{repoTokenKey(id)}
176+ if it, ok := snap.Get(ctx, repoTokenKey(id)); ok {
177+ if t, err := decodeRepoToken(it.Value); err == nil {
178+ keys = append(keys, repoTokenHashKey(t.Hash), repoTokenJobKey(t), repoTokenRepoKey(t.Repo, id))
179+ }
180+ }
181+ return keys
182+}
183+
161 // repoTokenKeys lists the keys of the repository tokens and action184 // repoTokenKeys lists the keys of the repository tokens and action
162 // resolutions of a run, for its deletion.185 // resolutions of a run, for its deletion.
163 func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {186 func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {
164 var keys [][]byte187 var keys [][]byte
165 for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) {188 for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) {
166- keys = append(keys, append([]byte(nil), key...), repoTokenKey(string(item.Value)))189+ keys = append(keys, append([]byte(nil), key...))
167- if it, ok := snap.Get(ctx, repoTokenKey(string(item.Value))); ok {190+ keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...)
168- if t, err := decodeRepoToken(it.Value); err == nil {
169- keys = append(keys, repoTokenHashKey(t.Hash))
170- }
171- }
172 }191 }
173 for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) {192 for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) {
174 keys = append(keys, append([]byte(nil), key...))193 keys = append(keys, append([]byte(nil), key...))
175 }194 }
176 return keys195 return keys
177 }196 }
197+
198+// repoReaderKeys lists the keys of every repository token that reads the
199+// repository, whichever job holds it: the archive tokens given to the jobs of
200+// other repositories go with the repository they read.
201+func repoReaderKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
202+ var keys [][]byte
203+ for key, item := range snap.Prefix(ctx, []byte(repoTokenRepoPrefixOf(repo))) {
204+ keys = append(keys, append([]byte(nil), key...))
205+ keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...)
206+ }
207+ return keys
208+}
M internal/adapters/kvstore/ci_token_test.go +53 -0
@@ -140,3 +140,56 @@ type jobRefT struct {
140140 RunID int64
141141 JobID string
142142 }
143+
144+// GO-006: an archive token is indexed by the repository it reads, so deleting
145+// that repository deletes it, though it belongs to a job of another one.
146+func TestCIArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) {
147+ ctx := context.Background()
148+ s, ci, run := ciRepo(t)
149+ target := name(t, "action")
150+ require.NoError(t, s.Create(ctx, domain.Repo{Name: target, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
151+ d, err := ci.CreateRun(ctx, run, "w.yml", draftOf(run, "w.yml", 1))
152+ require.NoError(t, err)
153+ ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}
154+
155+ own := repoToken("own", "ermitegh_own", ref, run, false)
156+ arch := repoToken("arch", "ermitegh_arch", ref, target, true)
157+ arch.Sha = "1111111111111111111111111111111111111111"
158+ _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch}})
159+ require.NoError(t, err)
160+ got, err := ci.FindRepoTokenByHash(ctx, arch.Hash)
161+ require.NoError(t, err)
162+ assert.Equal(t, arch.Sha, got.Sha, "the commit is stored")
163+ assert.True(t, got.Archive)
164+
165+ require.NoError(t, s.Delete(ctx, target))
166+ _, err = ci.FindRepoTokenByHash(ctx, arch.Hash)
167+ assert.ErrorIs(t, err, domain.ErrNotFound, "gone with the repository it reads")
168+ _, err = ci.GetRepoToken(ctx, "arch")
169+ assert.ErrorIs(t, err, domain.ErrNotFound)
170+ _, err = ci.FindRepoTokenByHash(ctx, own.Hash)
171+ assert.NoError(t, err, "the token of the run's own repository is untouched")
172+
173+ // Revoking the job afterwards finds no stale entry to trip on.
174+ _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: ref.JobID}}})
175+ require.NoError(t, err)
176+ _, err = ci.FindRepoTokenByHash(ctx, own.Hash)
177+ assert.ErrorIs(t, err, domain.ErrNotFound)
178+}
179+
180+// GO-001: the pusher recorded with a run comes back with its source.
181+func TestCIRunSourceKeepsThePusher(t *testing.T) {
182+ ctx := context.Background()
183+ _, ci, n := ciRepo(t)
184+ for i, p := range []domain.CIPusher{{Kind: domain.CIPusherRoot}, {Kind: domain.CIPusherOwner}, {Kind: domain.CIPusherToken, TokenID: "abc123"}, {}} {
185+ d, err := ci.CreateRun(ctx, n, "w.yml", func(id, number int64) ports.CIRunDraft {
186+ dr := draftOf(n, "w.yml", 0)(id, number)
187+ dr.Source.Pusher = p
188+ return dr
189+ })
190+ require.NoError(t, err, i)
191+ src, err := ci.GetRunSource(ctx, d.Run.ID)
192+ require.NoError(t, err)
193+ assert.Equal(t, p, src.Pusher)
194+ }
195+}
@@ -140,3 +140,56 @@ type jobRefT struct {
140 RunID int64140 RunID int64
141 JobID string141 JobID string
142 }142 }
143+
144+// GO-006: an archive token is indexed by the repository it reads, so deleting
145+// that repository deletes it, though it belongs to a job of another one.
146+func TestCIArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) {
147+ ctx := context.Background()
148+ s, ci, run := ciRepo(t)
149+ target := name(t, "action")
150+ require.NoError(t, s.Create(ctx, domain.Repo{Name: target, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
151+ d, err := ci.CreateRun(ctx, run, "w.yml", draftOf(run, "w.yml", 1))
152+ require.NoError(t, err)
153+ ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}
154+
155+ own := repoToken("own", "ermitegh_own", ref, run, false)
156+ arch := repoToken("arch", "ermitegh_arch", ref, target, true)
157+ arch.Sha = "1111111111111111111111111111111111111111"
158+ _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch}})
159+ require.NoError(t, err)
160+ got, err := ci.FindRepoTokenByHash(ctx, arch.Hash)
161+ require.NoError(t, err)
162+ assert.Equal(t, arch.Sha, got.Sha, "the commit is stored")
163+ assert.True(t, got.Archive)
164+
165+ require.NoError(t, s.Delete(ctx, target))
166+ _, err = ci.FindRepoTokenByHash(ctx, arch.Hash)
167+ assert.ErrorIs(t, err, domain.ErrNotFound, "gone with the repository it reads")
168+ _, err = ci.GetRepoToken(ctx, "arch")
169+ assert.ErrorIs(t, err, domain.ErrNotFound)
170+ _, err = ci.FindRepoTokenByHash(ctx, own.Hash)
171+ assert.NoError(t, err, "the token of the run's own repository is untouched")
172+
173+ // Revoking the job afterwards finds no stale entry to trip on.
174+ _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: ref.JobID}}})
175+ require.NoError(t, err)
176+ _, err = ci.FindRepoTokenByHash(ctx, own.Hash)
177+ assert.ErrorIs(t, err, domain.ErrNotFound)
178+}
179+
180+// GO-001: the pusher recorded with a run comes back with its source.
181+func TestCIRunSourceKeepsThePusher(t *testing.T) {
182+ ctx := context.Background()
183+ _, ci, n := ciRepo(t)
184+ for i, p := range []domain.CIPusher{{Kind: domain.CIPusherRoot}, {Kind: domain.CIPusherOwner}, {Kind: domain.CIPusherToken, TokenID: "abc123"}, {}} {
185+ d, err := ci.CreateRun(ctx, n, "w.yml", func(id, number int64) ports.CIRunDraft {
186+ dr := draftOf(n, "w.yml", 0)(id, number)
187+ dr.Source.Pusher = p
188+ return dr
189+ })
190+ require.NoError(t, err, i)
191+ src, err := ci.GetRunSource(ctx, d.Run.ID)
192+ require.NoError(t, err)
193+ assert.Equal(t, p, src.Pusher)
194+ }
195+}
M internal/adapters/web/web.go +1 -1
@@ -235,7 +235,7 @@ func (s *Server) ownerRedirect(r *http.Request) (string, bool) {
235235 return "", false
236236 }
237237 first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
238- if !ok || first != s.cfg.Owner || rest == "" {
238+ if !ok || !domain.OwnerMatches(s.cfg.Owner, first) || rest == "" {
239239 return "", false
240240 }
241241 repo, _, _ := strings.Cut(rest, "/")
@@ -235,7 +235,7 @@ func (s *Server) ownerRedirect(r *http.Request) (string, bool) {
235 return "", false235 return "", false
236 }236 }
237 first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")237 first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
238- if !ok || first != s.cfg.Owner || rest == "" {238+ if !ok || !domain.OwnerMatches(s.cfg.Owner, first) || rest == "" {
239 return "", false239 return "", false
240 }240 }
241 repo, _, _ := strings.Cut(rest, "/")241 repo, _, _ := strings.Cut(rest, "/")
M internal/app/ci_execution_test.go +523 -4
@@ -11,10 +11,12 @@ import (
1111 "encoding/json"
1212 "io"
1313 "net/http"
14+ "net/http/httptest"
1415 "path/filepath"
1516 "slices"
1617 "strings"
1718 "sync"
19+ "sync/atomic"
1820 "testing"
1921 "time"
2022
@@ -655,7 +657,7 @@ func TestResolveActionsAuthentication(t *testing.T) {
655657 }
656658
657659 // 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
660+// with a per-job token bound to that commit of that repository, and the same ref gives the
659661 // same commit to every job of the attempt (and a new one to a re-run).
660662 func TestResolveActionsFromErmite(t *testing.T) {
661663 remote := newFakeRemote(t)
@@ -685,7 +687,7 @@ func TestResolveActionsFromErmite(t *testing.T) {
685687 }
686688 assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner())
687689 assert.Equal(t, "sub", got[1].GetPath())
688- assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per repository and job")
690+ assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per commit and job")
689691 assert.Contains(t, got[4].GetError(), "unable to find version `gone`")
690692 assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all")
691693 assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`")
@@ -698,9 +700,9 @@ func TestResolveActionsFromErmite(t *testing.T) {
698700 require.Equal(t, http.StatusOK, status)
699701 assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"])
700702 status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch))
701- assert.Equal(t, http.StatusOK, status, "and clones it")
703+ assert.Equal(t, http.StatusNotFound, status, "it does not clone: the archive of one commit is all it gets")
702704 status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch))
703- assert.Equal(t, http.StatusForbidden, status, "never writes")
705+ assert.Equal(t, http.StatusNotFound, status, "and never writes")
704706 status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch))
705707 assert.Equal(t, http.StatusNotFound, status, "another private repository")
706708 status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch))
@@ -790,3 +792,520 @@ func TestRepositoryTokensAreNeverLogged(t *testing.T) {
790792 }
791793 assert.NotContains(t, logs, strings.Repeat("Z", 43))
792794 }
795+
796+// pushAs pushes a commit to branch of the repository with the token and returns
797+// its sha.
798+func (c *ciHarness) pushAs(token, branch string, files map[string]string) string {
799+ c.t.Helper()
800+ if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil {
801+ c.mustGit(c.wt, "checkout", "-q", "-b", branch)
802+ }
803+ for name, content := range files {
804+ c.writeFile(c.wt, name, []byte(content))
805+ }
806+ c.mustGit(c.wt, "push", "-q", c.urlAuth(token, "/"+c.repo+".git"), branch)
807+ return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
808+}
809+
810+const oneJobWorkflow = ".github/workflows/ci.yml"
811+
812+func oneJobYAML() string { return strings.Split(ciWorkflow, " test:")[0] }
813+
814+// GO-001a: an action of another repository of ermite is resolved on behalf of
815+// the pusher of the run, with the rule of the forge: a pusher that cannot read
816+// the repository gets "repository not found", exactly as for one that does not
817+// exist, and the check is made again at each answer with the live token.
818+func TestResolveActionsOfAnotherRepositoryNeedsAPusherWhoReadsIt(t *testing.T) {
819+ notFound := "Unable to resolve action `owner/private-action@v1`, repository not found"
820+ missing := "Unable to resolve action `owner/does-not-exist@v1`, repository not found"
821+
822+ type scenario struct {
823+ name string
824+ // token makes the stored token that pushes the run, nil for the root token.
825+ token func(c *ciHarness) (secret, id string)
826+ reads bool
827+ }
828+ scenarios := []scenario{
829+ {"root", nil, true},
830+ {"a token without the repository in its list", func(c *ciHarness) (string, string) {
831+ return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
832+ }, false},
833+ {"a token without repo:read", func(c *ciHarness) (string, string) {
834+ return c.branchToken([]domain.Scope{domain.ScopeRepoWrite}, nil)
835+ }, false},
836+ {"a token that lists both repositories", func(c *ciHarness) (string, string) {
837+ return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "private-action")
838+ }, true},
839+ {"a token for every repository", func(c *ciHarness) (string, string) {
840+ return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
841+ }, true},
842+ }
843+ for _, sc := range scenarios {
844+ t.Run(sc.name, func(t *testing.T) {
845+ c := newExecHarness(t, newFakeRemote(t), nil)
846+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
847+ secret := adminToken
848+ if sc.token != nil {
849+ secret, _ = sc.token(c)
850+ }
851+ runner := c.registerRunner("alpha", "ubuntu-latest")
852+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
853+ job := runner.await()
854+ c.heartbeat(runner, job)
855+
856+ got := c.resolve(runner, job, "owner/private-action@v1", "owner/does-not-exist@v1")
857+ assert.Equal(t, missing, got[1].GetError())
858+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
859+ got = c.resolve(runner, job, "owner/private-action@v1")
860+ if sc.reads {
861+ require.Empty(t, got[0].GetError())
862+ assert.True(t, domain.IsRepoToken(got[0].GetToken()))
863+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
864+ } else {
865+ assert.Equal(t, notFound, got[0].GetError(), "as GitHub says for a repository that is not there")
866+ assert.Empty(t, got[0].GetToken())
867+ assert.Empty(t, got[0].GetResolvedSha())
868+ assert.Empty(t, got[0].GetTarballUrl())
869+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, got[0].GetSource())
870+ }
871+ // A wrong ref of a repository that cannot be read leaks nothing either:
872+ // the same text as for a missing repository, not "unable to find version".
873+ bad := c.resolve(runner, job, "owner/private-action@nope")
874+ if sc.reads {
875+ assert.Contains(t, bad[0].GetError(), "unable to find version `nope`")
876+ } else {
877+ assert.Equal(t, "Unable to resolve action `owner/private-action@nope`, repository not found", bad[0].GetError())
878+ }
879+ })
880+ }
881+
882+ t.Run("the token is looked up again, so revoking it stops the answers", func(t *testing.T) {
883+ c := newExecHarness(t, newFakeRemote(t), nil)
884+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
885+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
886+ secret, id := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
887+ runner := c.registerRunner("alpha", "ubuntu-latest")
888+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
889+ job := runner.await()
890+ c.heartbeat(runner, job)
891+
892+ first := c.resolve(runner, job, "owner/private-action@v1")
893+ require.Empty(t, first[0].GetError())
894+
895+ require.NoError(t, c.forge.Tokens.Revoke(c.ctx, domain.Root(), id))
896+ again := c.resolve(runner, job, "owner/private-action@v1")
897+ assert.Equal(t, notFound, again[0].GetError(), "the commit is resolved already, the pusher no longer reads")
898+ assert.Empty(t, again[0].GetToken())
899+ })
900+
901+ t.Run("a repository made private since", func(t *testing.T) {
902+ c := newExecHarness(t, newFakeRemote(t), nil)
903+ c.seed("private-action", domain.VisibilityPublic, "action.yml")
904+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
905+ secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
906+ runner := c.registerRunner("alpha", "ubuntu-latest")
907+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
908+ job := runner.await()
909+ c.heartbeat(runner, job)
910+
911+ assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError(), "public: anyone reads it")
912+ priv := domain.VisibilityPrivate
913+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("private-action"), ports.UpdateRepoParams{Visibility: &priv})
914+ require.NoError(t, err)
915+ assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
916+ })
917+
918+ t.Run("an expired token", func(t *testing.T) {
919+ c := newExecHarness(t, newFakeRemote(t), nil)
920+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
921+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
922+ exp := time.Now().Add(1500 * time.Millisecond)
923+ got, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{
924+ Label: "short", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, ExpiresAt: exp,
925+ })
926+ require.NoError(t, err)
927+ runner := c.registerRunner("alpha", "ubuntu-latest")
928+ c.pushAs(got.Secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
929+ job := runner.await()
930+ c.heartbeat(runner, job)
931+ assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
932+ time.Sleep(time.Until(exp) + 200*time.Millisecond)
933+ assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
934+ })
935+
936+ t.Run("the repository of the run is always readable by its job", func(t *testing.T) {
937+ c := newExecHarness(t, newFakeRemote(t), nil)
938+ secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
939+ runner := c.registerRunner("alpha", "ubuntu-latest")
940+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
941+ job := runner.await()
942+ c.heartbeat(runner, job)
943+ got := c.resolve(runner, job, "owner/proj@main")
944+ require.Empty(t, got[0].GetError())
945+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
946+ })
947+
948+ t.Run("a merge made by the owner's session", func(t *testing.T) {
949+ // The runs of merged requests record the owner: a principal that reads
950+ // everything.
951+ p := domain.PusherOf(domain.Owner())
952+ assert.Equal(t, domain.CIPusherOwner, p.Kind)
953+ })
954+}
955+
956+// GO-001b: the archive token is bound to (repository, commit). Only the archive
957+// endpoint reads it, only for that commit; it is refused by Git over HTTP, the
958+// repository and hash-algorithm endpoints, and for any other commit, ref or
959+// repository, public or not.
960+func TestArchiveTokenIsBoundToItsCommit(t *testing.T) {
961+ c := newExecHarness(t, newFakeRemote(t), nil)
962+ c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js")
963+ first := c.bareGit("myaction", "rev-parse", "HEAD")
964+ c.mustGit(c.repoDir("myaction"), "tag", "v1")
965+ // A second commit, to have a sha the token was not issued for.
966+ other := c.workTree("main", "action.yml", "dist/index.js", "more.js")
967+ c.mustGit(other, "push", "-q", "-f", c.urlAuth(adminToken, "/myaction.git"), "main")
968+ second := c.bareGit("myaction", "rev-parse", "HEAD")
969+ require.NotEqual(t, first, second)
970+ c.seed("openrepo", domain.VisibilityPublic, "a.txt")
971+ openSha := c.bareGit("openrepo", "rev-parse", "HEAD")
972+
973+ runner := c.registerRunner("alpha", "ubuntu-latest")
974+ job := c.startJob(runner)
975+ c.heartbeat(runner, job)
976+ got := c.resolve(runner, job, "owner/myaction@v1", "owner/myaction@"+second)
977+ require.Empty(t, got[0].GetError())
978+ require.Empty(t, got[1].GetError())
979+ assert.Equal(t, first, got[0].GetResolvedSha())
980+ assert.Equal(t, second, got[1].GetResolvedSha())
981+ assert.NotEqual(t, got[0].GetToken(), got[1].GetToken(), "one token per commit")
982+ arch := got[0].GetToken()
983+ tarball := func(repo, ref, tok string) int {
984+ status, _, _ := c.get("/-/github/api/repos/owner/"+repo+"/tarball/"+ref, basic(tok))
985+ return status
986+ }
987+
988+ // What it is for: that commit, named by its sha or by a ref that resolves to it.
989+ assert.Equal(t, http.StatusOK, tarball("myaction", first, arch))
990+ assert.Equal(t, http.StatusOK, tarball("myaction", "v1", arch), "any ref that resolves to the commit")
991+ assert.Equal(t, http.StatusOK, tarball("MyAction", first, arch), "names are case-insensitive")
992+ status, _, hdr := c.head("/-/github/api/repos/owner/myaction/tarball/"+first, basic(arch))
993+ assert.Equal(t, http.StatusOK, status)
994+ assert.Contains(t, hdr.Get("Content-Disposition"), "owner-myaction-"+first[:7])
995+
996+ // Nothing else.
997+ assert.Equal(t, http.StatusNotFound, tarball("myaction", second, arch), "another commit of the repository")
998+ assert.Equal(t, http.StatusNotFound, tarball("myaction", "main", arch), "a ref that now points elsewhere")
999+ assert.Equal(t, http.StatusNotFound, tarball("myaction", "", arch), "the default branch")
1000+ assert.Equal(t, http.StatusNotFound, tarball("proj", "main", arch), "the repository of the run")
1001+ assert.Equal(t, http.StatusNotFound, tarball("openrepo", openSha, arch), "not even a public repository")
1002+ assert.Equal(t, http.StatusUnauthorized, tarball("myaction", first, arch+"x"), "a token that is not one")
1003+ status, _, _ = c.head("/-/github/api/repos/owner/myaction/tarball/"+second, basic(arch))
1004+ assert.Equal(t, http.StatusNotFound, status, "HEAD is checked like GET")
1005+ for _, p := range []string{
1006+ "/owner/myaction/info/refs?service=git-upload-pack",
1007+ "/owner/myaction.git/info/refs?service=git-upload-pack",
1008+ "/myaction/info/refs?service=git-upload-pack",
1009+ "/owner/myaction/info/refs?service=git-receive-pack",
1010+ "/owner/openrepo/info/refs?service=git-upload-pack",
1011+ "/-/github/api/repos/owner/myaction",
1012+ "/-/github/api/repos/owner/myaction/hash-algorithm",
1013+ "/-/github/api/repos/owner/openrepo",
1014+ "/-/github/api/repos/owner/openrepo/hash-algorithm",
1015+ } {
1016+ status, _, _ := c.get(p, basic(arch))
1017+ assert.Contains(t, []int{http.StatusNotFound, http.StatusUnauthorized}, status, p)
1018+ }
1019+ // It lists no repository over the API either, and holds no scope there.
1020+ list, err := c.api(arch).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{}))
1021+ require.NoError(t, err)
1022+ names := []string{}
1023+ for _, r := range list.Msg.GetRepos() {
1024+ names = append(names, r.GetName())
1025+ }
1026+ assert.Empty(t, names, "it reads nothing, not even the public repositories an anonymous caller reads")
1027+ _, err = c.api(arch).repo.GetRepo(c.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "myaction"}))
1028+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
1029+
1030+ // The job's own token is the opposite: it reads its repository whole, and
1031+ // the archive of nothing else.
1032+ own := job.GetGithubToken()
1033+ assert.Equal(t, http.StatusNotFound, tarball("myaction", first, own))
1034+ assert.Equal(t, http.StatusOK, tarball("proj", "main", own))
1035+
1036+ // Principals.
1037+ p, err := c.forge.Auth.Authenticate(c.ctx, arch)
1038+ require.NoError(t, err)
1039+ repo, sha, ok := p.ArchiveGrant()
1040+ require.True(t, ok)
1041+ assert.Equal(t, "myaction", repo.String())
1042+ assert.Equal(t, first, sha)
1043+ assert.Empty(t, p.Scopes())
1044+ assert.False(t, p.HasScope(domain.ScopeRepoRead))
1045+ assert.False(t, p.Allows(domain.ScopeRepoRead, repo))
1046+ assert.False(t, p.IsRoot())
1047+ ownP, err := c.forge.Auth.Authenticate(c.ctx, own)
1048+ require.NoError(t, err)
1049+ _, _, ok = ownP.ArchiveGrant()
1050+ assert.False(t, ok)
1051+}
1052+
1053+func (c *ciHarness) head(path, authorization string) (int, string, http.Header) {
1054+ c.t.Helper()
1055+ req, err := http.NewRequest(http.MethodHead, c.url(path), nil)
1056+ require.NoError(c.t, err)
1057+ if authorization != "" {
1058+ req.Header.Set("Authorization", authorization)
1059+ }
1060+ resp, err := http.DefaultClient.Do(req)
1061+ require.NoError(c.t, err)
1062+ defer func() { _ = resp.Body.Close() }()
1063+ b, _ := io.ReadAll(resp.Body)
1064+ return resp.StatusCode, string(b), resp.Header
1065+}
1066+
1067+// GO-002: a HEAD of the tarball answers the headers of the archive of the ref it
1068+// resolves, runs no git archive, and 404s a ref that does not resolve.
1069+func TestTarballHeadRunsNoArchive(t *testing.T) {
1070+ c := newExecHarness(t, newFakeRemote(t), nil)
1071+ runner := c.registerRunner("alpha", "ubuntu-latest")
1072+ job := c.startJob(runner)
1073+ tok := job.GetGithubToken()
1074+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
1075+
1076+ status, body, hdr := c.head("/-/github/api/repos/owner/proj/tarball/main", basic(tok))
1077+ assert.Equal(t, http.StatusOK, status)
1078+ assert.Empty(t, body, "no body")
1079+ assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz")
1080+ assert.Contains(t, hdr.Get("Content-Type"), "gzip")
1081+ status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/nope", basic(tok))
1082+ assert.Equal(t, http.StatusNotFound, status)
1083+ status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/main", "")
1084+ assert.Equal(t, http.StatusNotFound, status, "private and anonymous")
1085+ status, _, _ = c.head("/-/github/api/repos/owner/other/tarball/main", basic(tok))
1086+ assert.Equal(t, http.StatusNotFound, status)
1087+}
1088+
1089+// GO-003: one client cannot take every archive slot, and the slots are given
1090+// back.
1091+func TestArchiveDownloadsAreLimitedPerClient(t *testing.T) {
1092+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.archivesPerClient = 1 })
1093+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
1094+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
1095+ require.NoError(t, err)
1096+ c.wt = h.workTree("main")
1097+ c.writeFile(c.wt, "big.bin", bytes.Repeat([]byte("0123456789abcdef"), 1<<20)) // 16 MiB, compressible
1098+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
1099+
1100+ // Start a download and read none of it: the response is held open.
1101+ req, err := http.NewRequest(http.MethodGet, c.url("/-/github/api/repos/owner/proj/tarball/main"), nil)
1102+ require.NoError(t, err)
1103+ req.Header.Set("Authorization", "Bearer "+adminToken)
1104+ resp, err := http.DefaultClient.Do(req)
1105+ require.NoError(t, err)
1106+
1107+ var status int
1108+ c.waitFor("the second download is refused", func() bool {
1109+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1110+ return status == http.StatusTooManyRequests
1111+ })
1112+ assert.Equal(t, http.StatusTooManyRequests, status)
1113+ hs, _, _ := c.head("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1114+ assert.Equal(t, http.StatusOK, hs, "a HEAD takes no slot")
1115+ metaStatus, _, _ := c.get("/-/github/api/repos/owner/proj", "Bearer "+adminToken)
1116+ assert.Equal(t, http.StatusOK, metaStatus, "nor does the repository endpoint")
1117+
1118+ // Reading it to the end gives the slot back.
1119+ _, err = io.Copy(io.Discard, resp.Body)
1120+ require.NoError(t, err)
1121+ _ = resp.Body.Close()
1122+ c.waitFor("the slot is given back", func() bool {
1123+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1124+ return status == http.StatusOK
1125+ })
1126+}
1127+
1128+// GO-004: owner and repository are validated with GitHub's charset for every
1129+// source; nothing but a name reaches a URL, a path or a command.
1130+func TestResolveActionsRejectsMalformedNames(t *testing.T) {
1131+ remote := newFakeRemote(t)
1132+ c := newExecHarness(t, remote, nil)
1133+ runner := c.registerRunner("alpha", "ubuntu-latest")
1134+ job := c.startJob(runner)
1135+ c.heartbeat(runner, job)
1136+
1137+ bad := []string{
1138+ "../..@" + shaMain, ".../x@" + shaMain, "../x@" + shaMain, "..//x@" + shaMain, "a/..@" + shaMain, "../b@" + shaMain,
1139+ "a/b%2e@" + shaMain, "a%2fb/c@" + shaMain, "a/b?x=1@" + shaMain, "a/b#f@" + shaMain, "a b/c@" + shaMain,
1140+ "a/b c@" + shaMain, "a:b/c@" + shaMain, "a/b\n@" + shaMain, "a@b/c@v1", "a/b\x00@v1", "a/...@v1", "a/.@v1",
1141+ "é/b@v1", strings.Repeat("a", 101) + "/b@v1", "a/" + strings.Repeat("b", 101) + "@v1", "/@v1", "@@",
1142+ }
1143+ got := c.resolve(runner, job, bad...)
1144+ require.Len(t, got, len(bad))
1145+ for i, g := range got {
1146+ assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual '"+bad[i]+"'", g.GetError(), "%q", bad[i])
1147+ assert.Empty(t, g.GetTarballUrl(), "%q", bad[i])
1148+ assert.Empty(t, g.GetToken(), "%q", bad[i])
1149+ }
1150+ assert.Zero(t, remote.count(), "no malformed name reached ls-remote")
1151+
1152+ // Names GitHub accepts still do.
1153+ remote.set("a.b/c_d-e", ptrRef("refs/tags/v1", shaMain))
1154+ ok := c.resolve(runner, job, "a.b/c_d-e@v1", "Owner/repo/sub@"+shaMain)
1155+ assert.Empty(t, ok[0].GetError())
1156+ assert.Empty(t, ok[1].GetError())
1157+}
1158+
1159+// A token revoked, a job lost, the server restarted: the archive token goes with
1160+// the job, and it was never written anywhere but hashed.
1161+func TestArchiveTokenIsRevokedWithTheJobAfterARestart(t *testing.T) {
1162+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
1163+ cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 600*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
1164+ })
1165+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1166+ sha := c.bareGit("myaction", "rev-parse", "HEAD")
1167+ runner := c.registerRunner("alpha", "ubuntu-latest")
1168+ job := c.startJob(runner)
1169+ c.heartbeat(runner, job)
1170+ arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken()
1171+ require.NotEmpty(t, arch)
1172+ url := "/-/github/api/repos/owner/myaction/tarball/" + sha
1173+ status, _, _ := c.get(url, basic(arch))
1174+ require.Equal(t, http.StatusOK, status)
1175+
1176+ // Restart: the tokens are in the store, the secrets in memory are gone.
1177+ cfg, hookExe := c.forge.cfg, binary(t)
1178+ c.srv.Close()
1179+ c.forge.Close(c.ctx)
1180+ f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, remoteLister: newFakeRemote(t).m, ci: func(cc *usecase.CIConfig) {
1181+ cc.HeartbeatDeadline, cc.Tick = 600*time.Millisecond, 20*time.Millisecond
1182+ }})
1183+ require.NoError(t, err)
1184+ t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) })
1185+ srv := httptest.NewServer(f2.server.Handler())
1186+ t.Cleanup(srv.Close)
1187+
1188+ get := func(tok string) int {
1189+ req, _ := http.NewRequest(http.MethodGet, srv.URL+url, nil)
1190+ req.Header.Set("Authorization", basic(tok))
1191+ resp, err := http.DefaultClient.Do(req)
1192+ require.NoError(t, err)
1193+ _ = resp.Body.Close()
1194+ return resp.StatusCode
1195+ }
1196+ assert.Equal(t, http.StatusOK, get(arch), "the token survives a restart")
1197+
1198+ // The runner is gone: the heartbeat deadline of the new server passes, the
1199+ // job is lost and its tokens with it.
1200+ require.Eventually(t, func() bool { return get(arch) == http.StatusUnauthorized }, 10*time.Second, 50*time.Millisecond)
1201+ assert.Equal(t, http.StatusUnauthorized, get(job.GetGithubToken()), "the job's own token as well")
1202+ api := newClients(srv.URL+"/-/api", adminToken)
1203+ res, err := api.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: job.GetRunId()}))
1204+ require.NoError(t, err)
1205+ assert.Contains(t, res.Msg.GetJobs()[0].GetError(), "lost communication")
1206+}
1207+
1208+// GO-006: the archive tokens that read a repository go when it is deleted, and
1209+// the run that held one goes on without a stale index.
1210+func TestArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) {
1211+ c := newExecHarness(t, newFakeRemote(t), nil)
1212+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1213+ sha := c.bareGit("myaction", "rev-parse", "HEAD")
1214+ runner := c.registerRunner("alpha", "ubuntu-latest")
1215+ job := c.startJob(runner)
1216+ c.heartbeat(runner, job)
1217+ arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken()
1218+ _, err := c.forge.Auth.Authenticate(c.ctx, arch)
1219+ require.NoError(t, err)
1220+
1221+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("myaction")))
1222+ _, err = c.forge.Auth.Authenticate(c.ctx, arch)
1223+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "deleted with the repository it reads")
1224+ _, err = c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken())
1225+ assert.NoError(t, err, "the job's own token is the run's repository's, not touched")
1226+
1227+ // A repository created again under the name does not inherit the token.
1228+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1229+ _, err = c.forge.Auth.Authenticate(c.ctx, arch)
1230+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
1231+ status, _, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+sha, basic(arch))
1232+ assert.Equal(t, http.StatusUnauthorized, status)
1233+
1234+ // The job ending afterwards revokes what is left without an error.
1235+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
1236+}
1237+
1238+// GO-005: ls-remote runs are bounded server-wide and a call has a deadline.
1239+func TestResolveActionsBoundsRemoteWork(t *testing.T) {
1240+ t.Run("concurrency", func(t *testing.T) {
1241+ // The jobs of a run resolve at the same time: five calls, two slots.
1242+ var running, peak atomic.Int32
1243+ release := make(chan struct{})
1244+ remote := newFakeRemote(t)
1245+ remote.m.ExpectedCalls = nil
1246+ remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1247+ func(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
1248+ n := running.Add(1)
1249+ defer running.Add(-1)
1250+ for {
1251+ p := peak.Load()
1252+ if n <= p || peak.CompareAndSwap(p, n) {
1253+ break
1254+ }
1255+ }
1256+ select {
1257+ case <-release:
1258+ case <-ctx.Done():
1259+ return nil, ctx.Err()
1260+ }
1261+ return []ports.RemoteRef{{Name: "refs/tags/v1", ID: shaMain}}, nil
1262+ }).Maybe()
1263+ c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.RemoteConcurrency = 2 })
1264+ runner := c.registerRunner("alpha", "ubuntu-latest")
1265+ job := c.startJob(runner)
1266+ c.heartbeat(runner, job)
1267+
1268+ var wg sync.WaitGroup
1269+ results := make([][]*pb.ResolvedAction, 5)
1270+ errs := make([]error, 5)
1271+ for i, repo := range []string{"one", "two", "three", "four", "five"} {
1272+ wg.Add(1)
1273+ go func() {
1274+ defer wg.Done()
1275+ results[i], errs[i] = c.resolveErr(runner, job, "a/"+repo+"@v1")
1276+ }()
1277+ }
1278+ require.Eventually(t, func() bool { return running.Load() == 2 }, 5*time.Second, 10*time.Millisecond)
1279+ time.Sleep(150 * time.Millisecond)
1280+ assert.EqualValues(t, 2, running.Load(), "the others wait for a slot")
1281+ close(release)
1282+ wg.Wait()
1283+ assert.LessOrEqual(t, peak.Load(), int32(2))
1284+ for i := range results {
1285+ require.NoError(t, errs[i])
1286+ assert.Empty(t, results[i][0].GetError(), results[i][0].GetRef())
1287+ }
1288+ })
1289+
1290+ t.Run("deadline", func(t *testing.T) {
1291+ remote := newFakeRemote(t)
1292+ remote.m.ExpectedCalls = nil
1293+ remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1294+ func(ctx context.Context, _, _ string) ([]ports.RemoteRef, error) {
1295+ <-ctx.Done()
1296+ return nil, ctx.Err()
1297+ }).Maybe()
1298+ c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.ResolveTimeout = 300 * time.Millisecond })
1299+ runner := c.registerRunner("alpha", "ubuntu-latest")
1300+ job := c.startJob(runner)
1301+ c.heartbeat(runner, job)
1302+ start := time.Now()
1303+ _, err := c.resolveErr(runner, job, "slow/action@v1")
1304+ assert.Equal(t, connect.CodeUnavailable, ciErrCode(err), "a deadline of ours is unavailable, not deadline_exceeded")
1305+ assert.Less(t, time.Since(start), 5*time.Second)
1306+ // The job goes on: the next call is answered.
1307+ ok, err := c.resolveErr(runner, job, "a/b@"+shaMain)
1308+ require.NoError(t, err)
1309+ assert.Empty(t, ok[0].GetError())
1310+ })
1311+}
@@ -11,10 +11,12 @@ import (
11 "encoding/json"11 "encoding/json"
12 "io"12 "io"
13 "net/http"13 "net/http"
14+ "net/http/httptest"
14 "path/filepath"15 "path/filepath"
15 "slices"16 "slices"
16 "strings"17 "strings"
17 "sync"18 "sync"
19+ "sync/atomic"
18 "testing"20 "testing"
19 "time"21 "time"
20 22
@@ -655,7 +657,7 @@ func TestResolveActionsAuthentication(t *testing.T) {
655 }657 }
656 658
657 // AC4: an action of a repository of ermite wins over github.com, is downloaded659 // 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 the660+// with a per-job token bound to that commit of that repository, and the same ref gives the
659 // same commit to every job of the attempt (and a new one to a re-run).661 // same commit to every job of the attempt (and a new one to a re-run).
660 func TestResolveActionsFromErmite(t *testing.T) {662 func TestResolveActionsFromErmite(t *testing.T) {
661 remote := newFakeRemote(t)663 remote := newFakeRemote(t)
@@ -685,7 +687,7 @@ func TestResolveActionsFromErmite(t *testing.T) {
685 }687 }
686 assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner())688 assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner())
687 assert.Equal(t, "sub", got[1].GetPath())689 assert.Equal(t, "sub", got[1].GetPath())
688- assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per repository and job")690+ assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per commit and job")
689 assert.Contains(t, got[4].GetError(), "unable to find version `gone`")691 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")692 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`")693 assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`")
@@ -698,9 +700,9 @@ func TestResolveActionsFromErmite(t *testing.T) {
698 require.Equal(t, http.StatusOK, status)700 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"])701 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))702 status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch))
701- assert.Equal(t, http.StatusOK, status, "and clones it")703+ assert.Equal(t, http.StatusNotFound, status, "it does not clone: the archive of one commit is all it gets")
702 status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch))704 status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch))
703- assert.Equal(t, http.StatusForbidden, status, "never writes")705+ assert.Equal(t, http.StatusNotFound, status, "and never writes")
704 status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch))706 status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch))
705 assert.Equal(t, http.StatusNotFound, status, "another private repository")707 assert.Equal(t, http.StatusNotFound, status, "another private repository")
706 status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch))708 status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch))
@@ -790,3 +792,520 @@ func TestRepositoryTokensAreNeverLogged(t *testing.T) {
790 }792 }
791 assert.NotContains(t, logs, strings.Repeat("Z", 43))793 assert.NotContains(t, logs, strings.Repeat("Z", 43))
792 }794 }
795+
796+// pushAs pushes a commit to branch of the repository with the token and returns
797+// its sha.
798+func (c *ciHarness) pushAs(token, branch string, files map[string]string) string {
799+ c.t.Helper()
800+ if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil {
801+ c.mustGit(c.wt, "checkout", "-q", "-b", branch)
802+ }
803+ for name, content := range files {
804+ c.writeFile(c.wt, name, []byte(content))
805+ }
806+ c.mustGit(c.wt, "push", "-q", c.urlAuth(token, "/"+c.repo+".git"), branch)
807+ return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
808+}
809+
810+const oneJobWorkflow = ".github/workflows/ci.yml"
811+
812+func oneJobYAML() string { return strings.Split(ciWorkflow, " test:")[0] }
813+
814+// GO-001a: an action of another repository of ermite is resolved on behalf of
815+// the pusher of the run, with the rule of the forge: a pusher that cannot read
816+// the repository gets "repository not found", exactly as for one that does not
817+// exist, and the check is made again at each answer with the live token.
818+func TestResolveActionsOfAnotherRepositoryNeedsAPusherWhoReadsIt(t *testing.T) {
819+ notFound := "Unable to resolve action `owner/private-action@v1`, repository not found"
820+ missing := "Unable to resolve action `owner/does-not-exist@v1`, repository not found"
821+
822+ type scenario struct {
823+ name string
824+ // token makes the stored token that pushes the run, nil for the root token.
825+ token func(c *ciHarness) (secret, id string)
826+ reads bool
827+ }
828+ scenarios := []scenario{
829+ {"root", nil, true},
830+ {"a token without the repository in its list", func(c *ciHarness) (string, string) {
831+ return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
832+ }, false},
833+ {"a token without repo:read", func(c *ciHarness) (string, string) {
834+ return c.branchToken([]domain.Scope{domain.ScopeRepoWrite}, nil)
835+ }, false},
836+ {"a token that lists both repositories", func(c *ciHarness) (string, string) {
837+ return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "private-action")
838+ }, true},
839+ {"a token for every repository", func(c *ciHarness) (string, string) {
840+ return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
841+ }, true},
842+ }
843+ for _, sc := range scenarios {
844+ t.Run(sc.name, func(t *testing.T) {
845+ c := newExecHarness(t, newFakeRemote(t), nil)
846+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
847+ secret := adminToken
848+ if sc.token != nil {
849+ secret, _ = sc.token(c)
850+ }
851+ runner := c.registerRunner("alpha", "ubuntu-latest")
852+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
853+ job := runner.await()
854+ c.heartbeat(runner, job)
855+
856+ got := c.resolve(runner, job, "owner/private-action@v1", "owner/does-not-exist@v1")
857+ assert.Equal(t, missing, got[1].GetError())
858+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
859+ got = c.resolve(runner, job, "owner/private-action@v1")
860+ if sc.reads {
861+ require.Empty(t, got[0].GetError())
862+ assert.True(t, domain.IsRepoToken(got[0].GetToken()))
863+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
864+ } else {
865+ assert.Equal(t, notFound, got[0].GetError(), "as GitHub says for a repository that is not there")
866+ assert.Empty(t, got[0].GetToken())
867+ assert.Empty(t, got[0].GetResolvedSha())
868+ assert.Empty(t, got[0].GetTarballUrl())
869+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, got[0].GetSource())
870+ }
871+ // A wrong ref of a repository that cannot be read leaks nothing either:
872+ // the same text as for a missing repository, not "unable to find version".
873+ bad := c.resolve(runner, job, "owner/private-action@nope")
874+ if sc.reads {
875+ assert.Contains(t, bad[0].GetError(), "unable to find version `nope`")
876+ } else {
877+ assert.Equal(t, "Unable to resolve action `owner/private-action@nope`, repository not found", bad[0].GetError())
878+ }
879+ })
880+ }
881+
882+ t.Run("the token is looked up again, so revoking it stops the answers", func(t *testing.T) {
883+ c := newExecHarness(t, newFakeRemote(t), nil)
884+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
885+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
886+ secret, id := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
887+ runner := c.registerRunner("alpha", "ubuntu-latest")
888+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
889+ job := runner.await()
890+ c.heartbeat(runner, job)
891+
892+ first := c.resolve(runner, job, "owner/private-action@v1")
893+ require.Empty(t, first[0].GetError())
894+
895+ require.NoError(t, c.forge.Tokens.Revoke(c.ctx, domain.Root(), id))
896+ again := c.resolve(runner, job, "owner/private-action@v1")
897+ assert.Equal(t, notFound, again[0].GetError(), "the commit is resolved already, the pusher no longer reads")
898+ assert.Empty(t, again[0].GetToken())
899+ })
900+
901+ t.Run("a repository made private since", func(t *testing.T) {
902+ c := newExecHarness(t, newFakeRemote(t), nil)
903+ c.seed("private-action", domain.VisibilityPublic, "action.yml")
904+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
905+ secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
906+ runner := c.registerRunner("alpha", "ubuntu-latest")
907+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
908+ job := runner.await()
909+ c.heartbeat(runner, job)
910+
911+ assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError(), "public: anyone reads it")
912+ priv := domain.VisibilityPrivate
913+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("private-action"), ports.UpdateRepoParams{Visibility: &priv})
914+ require.NoError(t, err)
915+ assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
916+ })
917+
918+ t.Run("an expired token", func(t *testing.T) {
919+ c := newExecHarness(t, newFakeRemote(t), nil)
920+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
921+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
922+ exp := time.Now().Add(1500 * time.Millisecond)
923+ got, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{
924+ Label: "short", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, ExpiresAt: exp,
925+ })
926+ require.NoError(t, err)
927+ runner := c.registerRunner("alpha", "ubuntu-latest")
928+ c.pushAs(got.Secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
929+ job := runner.await()
930+ c.heartbeat(runner, job)
931+ assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
932+ time.Sleep(time.Until(exp) + 200*time.Millisecond)
933+ assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
934+ })
935+
936+ t.Run("the repository of the run is always readable by its job", func(t *testing.T) {
937+ c := newExecHarness(t, newFakeRemote(t), nil)
938+ secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
939+ runner := c.registerRunner("alpha", "ubuntu-latest")
940+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
941+ job := runner.await()
942+ c.heartbeat(runner, job)
943+ got := c.resolve(runner, job, "owner/proj@main")
944+ require.Empty(t, got[0].GetError())
945+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
946+ })
947+
948+ t.Run("a merge made by the owner's session", func(t *testing.T) {
949+ // The runs of merged requests record the owner: a principal that reads
950+ // everything.
951+ p := domain.PusherOf(domain.Owner())
952+ assert.Equal(t, domain.CIPusherOwner, p.Kind)
953+ })
954+}
955+
956+// GO-001b: the archive token is bound to (repository, commit). Only the archive
957+// endpoint reads it, only for that commit; it is refused by Git over HTTP, the
958+// repository and hash-algorithm endpoints, and for any other commit, ref or
959+// repository, public or not.
960+func TestArchiveTokenIsBoundToItsCommit(t *testing.T) {
961+ c := newExecHarness(t, newFakeRemote(t), nil)
962+ c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js")
963+ first := c.bareGit("myaction", "rev-parse", "HEAD")
964+ c.mustGit(c.repoDir("myaction"), "tag", "v1")
965+ // A second commit, to have a sha the token was not issued for.
966+ other := c.workTree("main", "action.yml", "dist/index.js", "more.js")
967+ c.mustGit(other, "push", "-q", "-f", c.urlAuth(adminToken, "/myaction.git"), "main")
968+ second := c.bareGit("myaction", "rev-parse", "HEAD")
969+ require.NotEqual(t, first, second)
970+ c.seed("openrepo", domain.VisibilityPublic, "a.txt")
971+ openSha := c.bareGit("openrepo", "rev-parse", "HEAD")
972+
973+ runner := c.registerRunner("alpha", "ubuntu-latest")
974+ job := c.startJob(runner)
975+ c.heartbeat(runner, job)
976+ got := c.resolve(runner, job, "owner/myaction@v1", "owner/myaction@"+second)
977+ require.Empty(t, got[0].GetError())
978+ require.Empty(t, got[1].GetError())
979+ assert.Equal(t, first, got[0].GetResolvedSha())
980+ assert.Equal(t, second, got[1].GetResolvedSha())
981+ assert.NotEqual(t, got[0].GetToken(), got[1].GetToken(), "one token per commit")
982+ arch := got[0].GetToken()
983+ tarball := func(repo, ref, tok string) int {
984+ status, _, _ := c.get("/-/github/api/repos/owner/"+repo+"/tarball/"+ref, basic(tok))
985+ return status
986+ }
987+
988+ // What it is for: that commit, named by its sha or by a ref that resolves to it.
989+ assert.Equal(t, http.StatusOK, tarball("myaction", first, arch))
990+ assert.Equal(t, http.StatusOK, tarball("myaction", "v1", arch), "any ref that resolves to the commit")
991+ assert.Equal(t, http.StatusOK, tarball("MyAction", first, arch), "names are case-insensitive")
992+ status, _, hdr := c.head("/-/github/api/repos/owner/myaction/tarball/"+first, basic(arch))
993+ assert.Equal(t, http.StatusOK, status)
994+ assert.Contains(t, hdr.Get("Content-Disposition"), "owner-myaction-"+first[:7])
995+
996+ // Nothing else.
997+ assert.Equal(t, http.StatusNotFound, tarball("myaction", second, arch), "another commit of the repository")
998+ assert.Equal(t, http.StatusNotFound, tarball("myaction", "main", arch), "a ref that now points elsewhere")
999+ assert.Equal(t, http.StatusNotFound, tarball("myaction", "", arch), "the default branch")
1000+ assert.Equal(t, http.StatusNotFound, tarball("proj", "main", arch), "the repository of the run")
1001+ assert.Equal(t, http.StatusNotFound, tarball("openrepo", openSha, arch), "not even a public repository")
1002+ assert.Equal(t, http.StatusUnauthorized, tarball("myaction", first, arch+"x"), "a token that is not one")
1003+ status, _, _ = c.head("/-/github/api/repos/owner/myaction/tarball/"+second, basic(arch))
1004+ assert.Equal(t, http.StatusNotFound, status, "HEAD is checked like GET")
1005+ for _, p := range []string{
1006+ "/owner/myaction/info/refs?service=git-upload-pack",
1007+ "/owner/myaction.git/info/refs?service=git-upload-pack",
1008+ "/myaction/info/refs?service=git-upload-pack",
1009+ "/owner/myaction/info/refs?service=git-receive-pack",
1010+ "/owner/openrepo/info/refs?service=git-upload-pack",
1011+ "/-/github/api/repos/owner/myaction",
1012+ "/-/github/api/repos/owner/myaction/hash-algorithm",
1013+ "/-/github/api/repos/owner/openrepo",
1014+ "/-/github/api/repos/owner/openrepo/hash-algorithm",
1015+ } {
1016+ status, _, _ := c.get(p, basic(arch))
1017+ assert.Contains(t, []int{http.StatusNotFound, http.StatusUnauthorized}, status, p)
1018+ }
1019+ // It lists no repository over the API either, and holds no scope there.
1020+ list, err := c.api(arch).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{}))
1021+ require.NoError(t, err)
1022+ names := []string{}
1023+ for _, r := range list.Msg.GetRepos() {
1024+ names = append(names, r.GetName())
1025+ }
1026+ assert.Empty(t, names, "it reads nothing, not even the public repositories an anonymous caller reads")
1027+ _, err = c.api(arch).repo.GetRepo(c.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "myaction"}))
1028+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
1029+
1030+ // The job's own token is the opposite: it reads its repository whole, and
1031+ // the archive of nothing else.
1032+ own := job.GetGithubToken()
1033+ assert.Equal(t, http.StatusNotFound, tarball("myaction", first, own))
1034+ assert.Equal(t, http.StatusOK, tarball("proj", "main", own))
1035+
1036+ // Principals.
1037+ p, err := c.forge.Auth.Authenticate(c.ctx, arch)
1038+ require.NoError(t, err)
1039+ repo, sha, ok := p.ArchiveGrant()
1040+ require.True(t, ok)
1041+ assert.Equal(t, "myaction", repo.String())
1042+ assert.Equal(t, first, sha)
1043+ assert.Empty(t, p.Scopes())
1044+ assert.False(t, p.HasScope(domain.ScopeRepoRead))
1045+ assert.False(t, p.Allows(domain.ScopeRepoRead, repo))
1046+ assert.False(t, p.IsRoot())
1047+ ownP, err := c.forge.Auth.Authenticate(c.ctx, own)
1048+ require.NoError(t, err)
1049+ _, _, ok = ownP.ArchiveGrant()
1050+ assert.False(t, ok)
1051+}
1052+
1053+func (c *ciHarness) head(path, authorization string) (int, string, http.Header) {
1054+ c.t.Helper()
1055+ req, err := http.NewRequest(http.MethodHead, c.url(path), nil)
1056+ require.NoError(c.t, err)
1057+ if authorization != "" {
1058+ req.Header.Set("Authorization", authorization)
1059+ }
1060+ resp, err := http.DefaultClient.Do(req)
1061+ require.NoError(c.t, err)
1062+ defer func() { _ = resp.Body.Close() }()
1063+ b, _ := io.ReadAll(resp.Body)
1064+ return resp.StatusCode, string(b), resp.Header
1065+}
1066+
1067+// GO-002: a HEAD of the tarball answers the headers of the archive of the ref it
1068+// resolves, runs no git archive, and 404s a ref that does not resolve.
1069+func TestTarballHeadRunsNoArchive(t *testing.T) {
1070+ c := newExecHarness(t, newFakeRemote(t), nil)
1071+ runner := c.registerRunner("alpha", "ubuntu-latest")
1072+ job := c.startJob(runner)
1073+ tok := job.GetGithubToken()
1074+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
1075+
1076+ status, body, hdr := c.head("/-/github/api/repos/owner/proj/tarball/main", basic(tok))
1077+ assert.Equal(t, http.StatusOK, status)
1078+ assert.Empty(t, body, "no body")
1079+ assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz")
1080+ assert.Contains(t, hdr.Get("Content-Type"), "gzip")
1081+ status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/nope", basic(tok))
1082+ assert.Equal(t, http.StatusNotFound, status)
1083+ status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/main", "")
1084+ assert.Equal(t, http.StatusNotFound, status, "private and anonymous")
1085+ status, _, _ = c.head("/-/github/api/repos/owner/other/tarball/main", basic(tok))
1086+ assert.Equal(t, http.StatusNotFound, status)
1087+}
1088+
1089+// GO-003: one client cannot take every archive slot, and the slots are given
1090+// back.
1091+func TestArchiveDownloadsAreLimitedPerClient(t *testing.T) {
1092+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.archivesPerClient = 1 })
1093+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
1094+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
1095+ require.NoError(t, err)
1096+ c.wt = h.workTree("main")
1097+ c.writeFile(c.wt, "big.bin", bytes.Repeat([]byte("0123456789abcdef"), 1<<20)) // 16 MiB, compressible
1098+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
1099+
1100+ // Start a download and read none of it: the response is held open.
1101+ req, err := http.NewRequest(http.MethodGet, c.url("/-/github/api/repos/owner/proj/tarball/main"), nil)
1102+ require.NoError(t, err)
1103+ req.Header.Set("Authorization", "Bearer "+adminToken)
1104+ resp, err := http.DefaultClient.Do(req)
1105+ require.NoError(t, err)
1106+
1107+ var status int
1108+ c.waitFor("the second download is refused", func() bool {
1109+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1110+ return status == http.StatusTooManyRequests
1111+ })
1112+ assert.Equal(t, http.StatusTooManyRequests, status)
1113+ hs, _, _ := c.head("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1114+ assert.Equal(t, http.StatusOK, hs, "a HEAD takes no slot")
1115+ metaStatus, _, _ := c.get("/-/github/api/repos/owner/proj", "Bearer "+adminToken)
1116+ assert.Equal(t, http.StatusOK, metaStatus, "nor does the repository endpoint")
1117+
1118+ // Reading it to the end gives the slot back.
1119+ _, err = io.Copy(io.Discard, resp.Body)
1120+ require.NoError(t, err)
1121+ _ = resp.Body.Close()
1122+ c.waitFor("the slot is given back", func() bool {
1123+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1124+ return status == http.StatusOK
1125+ })
1126+}
1127+
1128+// GO-004: owner and repository are validated with GitHub's charset for every
1129+// source; nothing but a name reaches a URL, a path or a command.
1130+func TestResolveActionsRejectsMalformedNames(t *testing.T) {
1131+ remote := newFakeRemote(t)
1132+ c := newExecHarness(t, remote, nil)
1133+ runner := c.registerRunner("alpha", "ubuntu-latest")
1134+ job := c.startJob(runner)
1135+ c.heartbeat(runner, job)
1136+
1137+ bad := []string{
1138+ "../..@" + shaMain, ".../x@" + shaMain, "../x@" + shaMain, "..//x@" + shaMain, "a/..@" + shaMain, "../b@" + shaMain,
1139+ "a/b%2e@" + shaMain, "a%2fb/c@" + shaMain, "a/b?x=1@" + shaMain, "a/b#f@" + shaMain, "a b/c@" + shaMain,
1140+ "a/b c@" + shaMain, "a:b/c@" + shaMain, "a/b\n@" + shaMain, "a@b/c@v1", "a/b\x00@v1", "a/...@v1", "a/.@v1",
1141+ "é/b@v1", strings.Repeat("a", 101) + "/b@v1", "a/" + strings.Repeat("b", 101) + "@v1", "/@v1", "@@",
1142+ }
1143+ got := c.resolve(runner, job, bad...)
1144+ require.Len(t, got, len(bad))
1145+ for i, g := range got {
1146+ assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual '"+bad[i]+"'", g.GetError(), "%q", bad[i])
1147+ assert.Empty(t, g.GetTarballUrl(), "%q", bad[i])
1148+ assert.Empty(t, g.GetToken(), "%q", bad[i])
1149+ }
1150+ assert.Zero(t, remote.count(), "no malformed name reached ls-remote")
1151+
1152+ // Names GitHub accepts still do.
1153+ remote.set("a.b/c_d-e", ptrRef("refs/tags/v1", shaMain))
1154+ ok := c.resolve(runner, job, "a.b/c_d-e@v1", "Owner/repo/sub@"+shaMain)
1155+ assert.Empty(t, ok[0].GetError())
1156+ assert.Empty(t, ok[1].GetError())
1157+}
1158+
1159+// A token revoked, a job lost, the server restarted: the archive token goes with
1160+// the job, and it was never written anywhere but hashed.
1161+func TestArchiveTokenIsRevokedWithTheJobAfterARestart(t *testing.T) {
1162+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
1163+ cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 600*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
1164+ })
1165+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1166+ sha := c.bareGit("myaction", "rev-parse", "HEAD")
1167+ runner := c.registerRunner("alpha", "ubuntu-latest")
1168+ job := c.startJob(runner)
1169+ c.heartbeat(runner, job)
1170+ arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken()
1171+ require.NotEmpty(t, arch)
1172+ url := "/-/github/api/repos/owner/myaction/tarball/" + sha
1173+ status, _, _ := c.get(url, basic(arch))
1174+ require.Equal(t, http.StatusOK, status)
1175+
1176+ // Restart: the tokens are in the store, the secrets in memory are gone.
1177+ cfg, hookExe := c.forge.cfg, binary(t)
1178+ c.srv.Close()
1179+ c.forge.Close(c.ctx)
1180+ f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, remoteLister: newFakeRemote(t).m, ci: func(cc *usecase.CIConfig) {
1181+ cc.HeartbeatDeadline, cc.Tick = 600*time.Millisecond, 20*time.Millisecond
1182+ }})
1183+ require.NoError(t, err)
1184+ t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) })
1185+ srv := httptest.NewServer(f2.server.Handler())
1186+ t.Cleanup(srv.Close)
1187+
1188+ get := func(tok string) int {
1189+ req, _ := http.NewRequest(http.MethodGet, srv.URL+url, nil)
1190+ req.Header.Set("Authorization", basic(tok))
1191+ resp, err := http.DefaultClient.Do(req)
1192+ require.NoError(t, err)
1193+ _ = resp.Body.Close()
1194+ return resp.StatusCode
1195+ }
1196+ assert.Equal(t, http.StatusOK, get(arch), "the token survives a restart")
1197+
1198+ // The runner is gone: the heartbeat deadline of the new server passes, the
1199+ // job is lost and its tokens with it.
1200+ require.Eventually(t, func() bool { return get(arch) == http.StatusUnauthorized }, 10*time.Second, 50*time.Millisecond)
1201+ assert.Equal(t, http.StatusUnauthorized, get(job.GetGithubToken()), "the job's own token as well")
1202+ api := newClients(srv.URL+"/-/api", adminToken)
1203+ res, err := api.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: job.GetRunId()}))
1204+ require.NoError(t, err)
1205+ assert.Contains(t, res.Msg.GetJobs()[0].GetError(), "lost communication")
1206+}
1207+
1208+// GO-006: the archive tokens that read a repository go when it is deleted, and
1209+// the run that held one goes on without a stale index.
1210+func TestArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) {
1211+ c := newExecHarness(t, newFakeRemote(t), nil)
1212+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1213+ sha := c.bareGit("myaction", "rev-parse", "HEAD")
1214+ runner := c.registerRunner("alpha", "ubuntu-latest")
1215+ job := c.startJob(runner)
1216+ c.heartbeat(runner, job)
1217+ arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken()
1218+ _, err := c.forge.Auth.Authenticate(c.ctx, arch)
1219+ require.NoError(t, err)
1220+
1221+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("myaction")))
1222+ _, err = c.forge.Auth.Authenticate(c.ctx, arch)
1223+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "deleted with the repository it reads")
1224+ _, err = c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken())
1225+ assert.NoError(t, err, "the job's own token is the run's repository's, not touched")
1226+
1227+ // A repository created again under the name does not inherit the token.
1228+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1229+ _, err = c.forge.Auth.Authenticate(c.ctx, arch)
1230+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
1231+ status, _, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+sha, basic(arch))
1232+ assert.Equal(t, http.StatusUnauthorized, status)
1233+
1234+ // The job ending afterwards revokes what is left without an error.
1235+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
1236+}
1237+
1238+// GO-005: ls-remote runs are bounded server-wide and a call has a deadline.
1239+func TestResolveActionsBoundsRemoteWork(t *testing.T) {
1240+ t.Run("concurrency", func(t *testing.T) {
1241+ // The jobs of a run resolve at the same time: five calls, two slots.
1242+ var running, peak atomic.Int32
1243+ release := make(chan struct{})
1244+ remote := newFakeRemote(t)
1245+ remote.m.ExpectedCalls = nil
1246+ remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1247+ func(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
1248+ n := running.Add(1)
1249+ defer running.Add(-1)
1250+ for {
1251+ p := peak.Load()
1252+ if n <= p || peak.CompareAndSwap(p, n) {
1253+ break
1254+ }
1255+ }
1256+ select {
1257+ case <-release:
1258+ case <-ctx.Done():
1259+ return nil, ctx.Err()
1260+ }
1261+ return []ports.RemoteRef{{Name: "refs/tags/v1", ID: shaMain}}, nil
1262+ }).Maybe()
1263+ c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.RemoteConcurrency = 2 })
1264+ runner := c.registerRunner("alpha", "ubuntu-latest")
1265+ job := c.startJob(runner)
1266+ c.heartbeat(runner, job)
1267+
1268+ var wg sync.WaitGroup
1269+ results := make([][]*pb.ResolvedAction, 5)
1270+ errs := make([]error, 5)
1271+ for i, repo := range []string{"one", "two", "three", "four", "five"} {
1272+ wg.Add(1)
1273+ go func() {
1274+ defer wg.Done()
1275+ results[i], errs[i] = c.resolveErr(runner, job, "a/"+repo+"@v1")
1276+ }()
1277+ }
1278+ require.Eventually(t, func() bool { return running.Load() == 2 }, 5*time.Second, 10*time.Millisecond)
1279+ time.Sleep(150 * time.Millisecond)
1280+ assert.EqualValues(t, 2, running.Load(), "the others wait for a slot")
1281+ close(release)
1282+ wg.Wait()
1283+ assert.LessOrEqual(t, peak.Load(), int32(2))
1284+ for i := range results {
1285+ require.NoError(t, errs[i])
1286+ assert.Empty(t, results[i][0].GetError(), results[i][0].GetRef())
1287+ }
1288+ })
1289+
1290+ t.Run("deadline", func(t *testing.T) {
1291+ remote := newFakeRemote(t)
1292+ remote.m.ExpectedCalls = nil
1293+ remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1294+ func(ctx context.Context, _, _ string) ([]ports.RemoteRef, error) {
1295+ <-ctx.Done()
1296+ return nil, ctx.Err()
1297+ }).Maybe()
1298+ c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.ResolveTimeout = 300 * time.Millisecond })
1299+ runner := c.registerRunner("alpha", "ubuntu-latest")
1300+ job := c.startJob(runner)
1301+ c.heartbeat(runner, job)
1302+ start := time.Now()
1303+ _, err := c.resolveErr(runner, job, "slow/action@v1")
1304+ assert.Equal(t, connect.CodeUnavailable, ciErrCode(err), "a deadline of ours is unavailable, not deadline_exceeded")
1305+ assert.Less(t, time.Since(start), 5*time.Second)
1306+ // The job goes on: the next call is answered.
1307+ ok, err := c.resolveErr(runner, job, "a/b@"+shaMain)
1308+ require.NoError(t, err)
1309+ assert.Empty(t, ok[0].GetError())
1310+ })
1311+}
M internal/app/forge.go +6 -1
@@ -52,6 +52,9 @@ type options struct {
5252 eventBuffer int
5353 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
5454 revalidateEvery time.Duration
55+ // archivesPerClient overrides the archives one client downloads at once
56+ // when positive.
57+ archivesPerClient int
5558 // remote replaces the lister of github.com refs (tests never reach the
5659 // network).
5760 remoteLister ports.GitRemote
@@ -215,7 +218,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
215218 PollTimeout: cfg.CIPollTimeout, Archive: git,
216219 Remote: opts.remote(gitremote.New(gitremote.Config{
217220 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
221+ Proxy: gitremote.ProxyEnv(os.Getenv),
218222 })),
223+ Tokens: store.Tokens(),
219224 }
220225 if opts.ci != nil {
221226 opts.ci(&ciCfg)
@@ -312,7 +317,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
312317 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
313318 }
314319 // 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})
320+ api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
316321 for _, p := range githubapi.Patterns {
317322 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
318323 }
@@ -52,6 +52,9 @@ type options struct {
52 eventBuffer int52 eventBuffer int
53 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.53 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
54 revalidateEvery time.Duration54 revalidateEvery time.Duration
55+ // archivesPerClient overrides the archives one client downloads at once
56+ // when positive.
57+ archivesPerClient int
55 // remote replaces the lister of github.com refs (tests never reach the58 // remote replaces the lister of github.com refs (tests never reach the
56 // network).59 // network).
57 remoteLister ports.GitRemote60 remoteLister ports.GitRemote
@@ -215,7 +218,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
215 PollTimeout: cfg.CIPollTimeout, Archive: git,218 PollTimeout: cfg.CIPollTimeout, Archive: git,
216 Remote: opts.remote(gitremote.New(gitremote.Config{219 Remote: opts.remote(gitremote.New(gitremote.Config{
217 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),220 Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
221+ Proxy: gitremote.ProxyEnv(os.Getenv),
218 })),222 })),
223+ Tokens: store.Tokens(),
219 }224 }
220 if opts.ci != nil {225 if opts.ci != nil {
221 opts.ci(&ciCfg)226 opts.ci(&ciCfg)
@@ -312,7 +317,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
312 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})317 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
313 }318 }
314 // The REST subset the actions call on github.api_url.319 // 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})320+ api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
316 for _, p := range githubapi.Patterns {321 for _, p := range githubapi.Patterns {
317 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})322 routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
318 }323 }
M internal/domain/ci.go +3 -0
@@ -191,6 +191,9 @@ type CIRunSource struct {
191191 Repo RepoName
192192 WorkflowYAML string
193193 GithubJSON string
194+ // Pusher is who pushed the commit: what the actions of other repositories
195+ // of the server may be resolved on behalf of.
196+ Pusher CIPusher
194197 }
195198
196199 // CIStep is a step of a job with where its lines are in the job log.
@@ -191,6 +191,9 @@ type CIRunSource struct {
191 Repo RepoName191 Repo RepoName
192 WorkflowYAML string192 WorkflowYAML string
193 GithubJSON string193 GithubJSON string
194+ // Pusher is who pushed the commit: what the actions of other repositories
195+ // of the server may be resolved on behalf of.
196+ Pusher CIPusher
194 }197 }
195 198
196 // CIStep is a step of a job with where its lines are in the job log.199 // CIStep is a step of a job with where its lines are in the job log.
M internal/domain/ci_token.go +59 -7
@@ -37,8 +37,11 @@ type CIRepoToken struct {
3737 RunAttempt int32
3838 JobID string
3939 // Archive is set for the token of an action archive, unset for the job's
40- // own token ("github.token").
41- Archive bool
40+ // own token ("github.token"). An archive token downloads the commit Sha of
41+ // Repo and nothing else.
42+ Archive bool
43+ // Sha is the commit an archive token is bound to; empty for the job's own.
44+ Sha string
4245 ExpiresAt time.Time
4346 CreatedAt time.Time
4447 }
@@ -51,18 +54,67 @@ func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresA
5154 const RepoTokenGrace = 5 * time.Minute
5255
5356 // RepoTokenPrincipal returns the principal of the holder of a job repository
54-// token. It is shaped like the principal of a stored token (the scope
55-// repo:read on one repository, an expiry), so that every authorization rule
56-// of the forge applies to it unchanged: it reads that repository, even when
57-// private, and writes nothing. Its TokenID starts with "job:" and matches no
58-// stored token.
57+// token.
58+//
59+// The job's own token is shaped like a stored token (the scope repo:read on one
60+// repository, an expiry), so that every authorization rule of the forge applies
61+// to it unchanged: it reads that repository, even when private, and writes
62+// nothing.
63+//
64+// An archive token holds no scope at all: it is the grant to download one
65+// commit of one repository as an archive, which only the archive endpoint
66+// reads (Principal.ArchiveGrant). Git over HTTP, the repository endpoint and
67+// the rest of the API see a caller with no rights, so a private repository
68+// does not show itself to it.
69+//
70+// Both have a TokenID that starts with "job:" and matches no stored token.
5971 func RepoTokenPrincipal(t CIRepoToken) Principal {
72+ if t.Archive {
73+ return Principal{
74+ kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt,
75+ archiveRepo: t.Repo, archiveSha: t.Sha,
76+ }
77+ }
6078 return TokenPrincipal(Token{
6179 ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead},
6280 Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt,
6381 })
6482 }
6583
84+// CIPusherKind tells who pushed the commit of a run.
85+type CIPusherKind string
86+
87+// Pusher kinds. The zero value is a run stored before the pusher was recorded.
88+const (
89+ CIPusherNone CIPusherKind = ""
90+ CIPusherRoot CIPusherKind = "root"
91+ CIPusherOwner CIPusherKind = "owner"
92+ CIPusherToken CIPusherKind = "token"
93+)
94+
95+// CIPusher is who pushed the commit of a run, as recorded with the run. A
96+// token is recorded by its ID only: what it may read is looked up when it is
97+// needed, so a token revoked since no longer reads anything on behalf of the
98+// run.
99+type CIPusher struct {
100+ Kind CIPusherKind
101+ TokenID string
102+}
103+
104+// PusherOf records the pusher p. Anonymous callers, which cannot push, and the
105+// holders of job tokens, which cannot either, are recorded as nobody.
106+func PusherOf(p Principal) CIPusher {
107+ switch {
108+ case p.IsOwner():
109+ return CIPusher{Kind: CIPusherOwner}
110+ case p.IsRoot():
111+ return CIPusher{Kind: CIPusherRoot}
112+ case p.TokenID() != "" && !IsRepoTokenID(p.TokenID()):
113+ return CIPusher{Kind: CIPusherToken, TokenID: p.TokenID()}
114+ }
115+ return CIPusher{}
116+}
117+
66118 // CIActionSource says where an action was found.
67119 type CIActionSource int
68120
@@ -37,8 +37,11 @@ type CIRepoToken struct {
37 RunAttempt int3237 RunAttempt int32
38 JobID string38 JobID string
39 // Archive is set for the token of an action archive, unset for the job's39 // Archive is set for the token of an action archive, unset for the job's
40- // own token ("github.token").40+ // own token ("github.token"). An archive token downloads the commit Sha of
41- Archive bool41+ // Repo and nothing else.
42+ Archive bool
43+ // Sha is the commit an archive token is bound to; empty for the job's own.
44+ Sha string
42 ExpiresAt time.Time45 ExpiresAt time.Time
43 CreatedAt time.Time46 CreatedAt time.Time
44 }47 }
@@ -51,18 +54,67 @@ func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresA
51 const RepoTokenGrace = 5 * time.Minute54 const RepoTokenGrace = 5 * time.Minute
52 55
53 // RepoTokenPrincipal returns the principal of the holder of a job repository56 // RepoTokenPrincipal returns the principal of the holder of a job repository
54-// token. It is shaped like the principal of a stored token (the scope57+// token.
55-// repo:read on one repository, an expiry), so that every authorization rule58+//
56-// of the forge applies to it unchanged: it reads that repository, even when59+// The job's own token is shaped like a stored token (the scope repo:read on one
57-// private, and writes nothing. Its TokenID starts with "job:" and matches no60+// repository, an expiry), so that every authorization rule of the forge applies
58-// stored token.61+// to it unchanged: it reads that repository, even when private, and writes
62+// nothing.
63+//
64+// An archive token holds no scope at all: it is the grant to download one
65+// commit of one repository as an archive, which only the archive endpoint
66+// reads (Principal.ArchiveGrant). Git over HTTP, the repository endpoint and
67+// the rest of the API see a caller with no rights, so a private repository
68+// does not show itself to it.
69+//
70+// Both have a TokenID that starts with "job:" and matches no stored token.
59 func RepoTokenPrincipal(t CIRepoToken) Principal {71 func RepoTokenPrincipal(t CIRepoToken) Principal {
72+ if t.Archive {
73+ return Principal{
74+ kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt,
75+ archiveRepo: t.Repo, archiveSha: t.Sha,
76+ }
77+ }
60 return TokenPrincipal(Token{78 return TokenPrincipal(Token{
61 ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead},79 ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead},
62 Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt,80 Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt,
63 })81 })
64 }82 }
65 83
84+// CIPusherKind tells who pushed the commit of a run.
85+type CIPusherKind string
86+
87+// Pusher kinds. The zero value is a run stored before the pusher was recorded.
88+const (
89+ CIPusherNone CIPusherKind = ""
90+ CIPusherRoot CIPusherKind = "root"
91+ CIPusherOwner CIPusherKind = "owner"
92+ CIPusherToken CIPusherKind = "token"
93+)
94+
95+// CIPusher is who pushed the commit of a run, as recorded with the run. A
96+// token is recorded by its ID only: what it may read is looked up when it is
97+// needed, so a token revoked since no longer reads anything on behalf of the
98+// run.
99+type CIPusher struct {
100+ Kind CIPusherKind
101+ TokenID string
102+}
103+
104+// PusherOf records the pusher p. Anonymous callers, which cannot push, and the
105+// holders of job tokens, which cannot either, are recorded as nobody.
106+func PusherOf(p Principal) CIPusher {
107+ switch {
108+ case p.IsOwner():
109+ return CIPusher{Kind: CIPusherOwner}
110+ case p.IsRoot():
111+ return CIPusher{Kind: CIPusherRoot}
112+ case p.TokenID() != "" && !IsRepoTokenID(p.TokenID()):
113+ return CIPusher{Kind: CIPusherToken, TokenID: p.TokenID()}
114+ }
115+ return CIPusher{}
116+}
117+
66 // CIActionSource says where an action was found.118 // CIActionSource says where an action was found.
67 type CIActionSource int119 type CIActionSource int
68 120
M internal/domain/ci_token_test.go +53 -0
@@ -51,3 +51,56 @@ func TestRepoTokenPrincipalReadsOneRepositoryAndNothingElse(t *testing.T) {
5151 assert.False(t, tok.Expired(exp.Add(-time.Second)))
5252 assert.True(t, tok.Expired(exp), "the expiry instant is already over")
5353 }
54+
55+func TestArchivePrincipalHoldsNoScopeAndOneGrant(t *testing.T) {
56+ t.Parallel()
57+ repo, _ := domain.ParseRepoName("action")
58+ sha := strings.Repeat("a", 40)
59+ exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
60+ p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab", Repo: repo, Archive: true, Sha: sha, ExpiresAt: exp})
61+
62+ gr, gs, ok := p.ArchiveGrant()
63+ assert.True(t, ok)
64+ assert.Equal(t, repo, gr)
65+ assert.Equal(t, sha, gs)
66+ for _, s := range domain.AllScopes() {
67+ assert.False(t, p.HasScope(s), s)
68+ assert.False(t, p.Allows(s, repo), s)
69+ }
70+ assert.False(t, p.IsRoot())
71+ assert.False(t, p.IsAnonymous(), "it is authenticated, with nothing to read")
72+ assert.False(t, p.HasOwnerRights())
73+ assert.Equal(t, exp, p.ExpiresAt())
74+ assert.True(t, domain.IsRepoTokenID(p.TokenID()))
75+ assert.Equal(t, "archive", p.Label())
76+
77+ own := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "cd", Repo: repo, ExpiresAt: exp})
78+ _, _, ok = own.ArchiveGrant()
79+ assert.False(t, ok, "the job's own token has no grant, it has the scope")
80+ for _, other := range []domain.Principal{domain.Anonymous(), domain.Root(), domain.Owner()} {
81+ _, _, ok = other.ArchiveGrant()
82+ assert.False(t, ok)
83+ }
84+}
85+
86+func TestPusherOf(t *testing.T) {
87+ t.Parallel()
88+ repo, _ := domain.ParseRepoName("proj")
89+ assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherRoot}, domain.PusherOf(domain.Root()))
90+ assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherOwner}, domain.PusherOf(domain.Owner()))
91+ assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "abc"},
92+ domain.PusherOf(domain.TokenPrincipal(domain.Token{ID: "abc", Scopes: []domain.Scope{domain.ScopeRepoWrite}})))
93+ assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.Anonymous()))
94+ assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "x", Repo: repo})), "a job token never pushes")
95+}
96+
97+func TestOwnerMatches(t *testing.T) {
98+ t.Parallel()
99+ assert.True(t, domain.OwnerMatches("acme", "acme"))
100+ assert.True(t, domain.OwnerMatches("acme", "ACME"))
101+ assert.True(t, domain.OwnerMatches("acme", "Acme"))
102+ assert.False(t, domain.OwnerMatches("acme", "acme2"))
103+ assert.False(t, domain.OwnerMatches("acme", ""))
104+ assert.False(t, domain.OwnerMatches("", ""), "no owner configured matches nothing")
105+ assert.False(t, domain.OwnerMatches("", "acme"))
106+}
@@ -51,3 +51,56 @@ func TestRepoTokenPrincipalReadsOneRepositoryAndNothingElse(t *testing.T) {
51 assert.False(t, tok.Expired(exp.Add(-time.Second)))51 assert.False(t, tok.Expired(exp.Add(-time.Second)))
52 assert.True(t, tok.Expired(exp), "the expiry instant is already over")52 assert.True(t, tok.Expired(exp), "the expiry instant is already over")
53 }53 }
54+
55+func TestArchivePrincipalHoldsNoScopeAndOneGrant(t *testing.T) {
56+ t.Parallel()
57+ repo, _ := domain.ParseRepoName("action")
58+ sha := strings.Repeat("a", 40)
59+ exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
60+ p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab", Repo: repo, Archive: true, Sha: sha, ExpiresAt: exp})
61+
62+ gr, gs, ok := p.ArchiveGrant()
63+ assert.True(t, ok)
64+ assert.Equal(t, repo, gr)
65+ assert.Equal(t, sha, gs)
66+ for _, s := range domain.AllScopes() {
67+ assert.False(t, p.HasScope(s), s)
68+ assert.False(t, p.Allows(s, repo), s)
69+ }
70+ assert.False(t, p.IsRoot())
71+ assert.False(t, p.IsAnonymous(), "it is authenticated, with nothing to read")
72+ assert.False(t, p.HasOwnerRights())
73+ assert.Equal(t, exp, p.ExpiresAt())
74+ assert.True(t, domain.IsRepoTokenID(p.TokenID()))
75+ assert.Equal(t, "archive", p.Label())
76+
77+ own := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "cd", Repo: repo, ExpiresAt: exp})
78+ _, _, ok = own.ArchiveGrant()
79+ assert.False(t, ok, "the job's own token has no grant, it has the scope")
80+ for _, other := range []domain.Principal{domain.Anonymous(), domain.Root(), domain.Owner()} {
81+ _, _, ok = other.ArchiveGrant()
82+ assert.False(t, ok)
83+ }
84+}
85+
86+func TestPusherOf(t *testing.T) {
87+ t.Parallel()
88+ repo, _ := domain.ParseRepoName("proj")
89+ assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherRoot}, domain.PusherOf(domain.Root()))
90+ assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherOwner}, domain.PusherOf(domain.Owner()))
91+ assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "abc"},
92+ domain.PusherOf(domain.TokenPrincipal(domain.Token{ID: "abc", Scopes: []domain.Scope{domain.ScopeRepoWrite}})))
93+ assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.Anonymous()))
94+ assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "x", Repo: repo})), "a job token never pushes")
95+}
96+
97+func TestOwnerMatches(t *testing.T) {
98+ t.Parallel()
99+ assert.True(t, domain.OwnerMatches("acme", "acme"))
100+ assert.True(t, domain.OwnerMatches("acme", "ACME"))
101+ assert.True(t, domain.OwnerMatches("acme", "Acme"))
102+ assert.False(t, domain.OwnerMatches("acme", "acme2"))
103+ assert.False(t, domain.OwnerMatches("acme", ""))
104+ assert.False(t, domain.OwnerMatches("", ""), "no owner configured matches nothing")
105+ assert.False(t, domain.OwnerMatches("", "acme"))
106+}
M internal/domain/name.go +9 -0
@@ -101,3 +101,12 @@ func nameProblem(s string) string {
101101 }
102102 return ""
103103 }
104+
105+// OwnerMatches reports whether name is the owner of the forge, as GitHub
106+// matches the owner of a repository: without regard to case. It is the one
107+// rule for the owner segment of the Git URLs, of the REST subset and of the
108+// references of actions. configured is ERMITE_OWNER, which is lower case (it
109+// follows the rules of repository names); an empty one matches nothing.
110+func OwnerMatches(configured, name string) bool {
111+ return configured != "" && strings.EqualFold(configured, name)
112+}
@@ -101,3 +101,12 @@ func nameProblem(s string) string {
101 }101 }
102 return ""102 return ""
103 }103 }
104+
105+// OwnerMatches reports whether name is the owner of the forge, as GitHub
106+// matches the owner of a repository: without regard to case. It is the one
107+// rule for the owner segment of the Git URLs, of the REST subset and of the
108+// references of actions. configured is ERMITE_OWNER, which is lower case (it
109+// follows the rules of repository names); an empty one matches nothing.
110+func OwnerMatches(configured, name string) bool {
111+ return configured != "" && strings.EqualFold(configured, name)
112+}
M internal/domain/principal.go +20 -0
@@ -17,6 +17,10 @@ const (
1717 kindRoot
1818 kindToken
1919 kindOwner
20+ // kindArchive is the holder of the archive token of an action: it holds no
21+ // scope at all, only a grant to download one commit of one repository (see
22+ // ArchivePrincipal).
23+ kindArchive
2024 )
2125
2226 // Principal is the authenticated identity of a caller: nobody, the root
@@ -31,6 +35,9 @@ type Principal struct {
3135 branchPrefixes []string
3236 expiresAt time.Time
3337 label string
38+ // archiveRepo and archiveSha are the grant of a kindArchive principal.
39+ archiveRepo RepoName
40+ archiveSha string
3441 }
3542
3643 // Anonymous returns the principal of a caller without credentials.
@@ -67,6 +74,8 @@ func (p Principal) Label() string {
6774 return "owner"
6875 case kindToken:
6976 return "token"
77+ case kindArchive:
78+ return "archive"
7079 }
7180 return "anonymous"
7281 }
@@ -187,3 +196,14 @@ func (p Principal) Allows(s Scope, repo RepoName) bool {
187196 // TokenLabel returns the label of the stored token, or "" for other
188197 // principals.
189198 func (p Principal) TokenLabel() string { return p.label }
199+
200+// ArchiveGrant returns the one commit of one repository that p may download as
201+// an archive, and ok true, when p is the holder of an archive token. Only the
202+// archive endpoint of the REST subset reads it: for everything else an archive
203+// principal holds no scope, so it reads what an anonymous caller reads.
204+func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) {
205+ if p.kind != kindArchive {
206+ return RepoName{}, "", false
207+ }
208+ return p.archiveRepo, p.archiveSha, true
209+}
@@ -17,6 +17,10 @@ const (
17 kindRoot17 kindRoot
18 kindToken18 kindToken
19 kindOwner19 kindOwner
20+ // kindArchive is the holder of the archive token of an action: it holds no
21+ // scope at all, only a grant to download one commit of one repository (see
22+ // ArchivePrincipal).
23+ kindArchive
20 )24 )
21 25
22 // Principal is the authenticated identity of a caller: nobody, the root26 // Principal is the authenticated identity of a caller: nobody, the root
@@ -31,6 +35,9 @@ type Principal struct {
31 branchPrefixes []string35 branchPrefixes []string
32 expiresAt time.Time36 expiresAt time.Time
33 label string37 label string
38+ // archiveRepo and archiveSha are the grant of a kindArchive principal.
39+ archiveRepo RepoName
40+ archiveSha string
34 }41 }
35 42
36 // Anonymous returns the principal of a caller without credentials.43 // Anonymous returns the principal of a caller without credentials.
@@ -67,6 +74,8 @@ func (p Principal) Label() string {
67 return "owner"74 return "owner"
68 case kindToken:75 case kindToken:
69 return "token"76 return "token"
77+ case kindArchive:
78+ return "archive"
70 }79 }
71 return "anonymous"80 return "anonymous"
72 }81 }
@@ -187,3 +196,14 @@ func (p Principal) Allows(s Scope, repo RepoName) bool {
187 // TokenLabel returns the label of the stored token, or "" for other196 // TokenLabel returns the label of the stored token, or "" for other
188 // principals.197 // principals.
189 func (p Principal) TokenLabel() string { return p.label }198 func (p Principal) TokenLabel() string { return p.label }
199+
200+// ArchiveGrant returns the one commit of one repository that p may download as
201+// an archive, and ok true, when p is the holder of an archive token. Only the
202+// archive endpoint of the REST subset reads it: for everything else an archive
203+// principal holds no scope, so it reads what an anonymous caller reads.
204+func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) {
205+ if p.kind != kindArchive {
206+ return RepoName{}, "", false
207+ }
208+ return p.archiveRepo, p.archiveSha, true
209+}
M internal/ports/mocks/GitHubAPIService.go +84 -0
@@ -286,3 +286,87 @@ func (_c *GitHubAPIService_Tarball_Call) RunAndReturn(run func(ctx context.Conte
286286 _c.Call.Return(run)
287287 return _c
288288 }
289+
290+// TarballName provides a mock function for the type GitHubAPIService
291+func (_mock *GitHubAPIService) TarballName(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error) {
292+ ret := _mock.Called(ctx, p, owner, repo, ref)
293+
294+ if len(ret) == 0 {
295+ panic("no return value specified for TarballName")
296+ }
297+
298+ var r0 string
299+ var r1 error
300+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) (string, error)); ok {
301+ return returnFunc(ctx, p, owner, repo, ref)
302+ }
303+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) string); ok {
304+ r0 = returnFunc(ctx, p, owner, repo, ref)
305+ } else {
306+ r0 = ret.Get(0).(string)
307+ }
308+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, string) error); ok {
309+ r1 = returnFunc(ctx, p, owner, repo, ref)
310+ } else {
311+ r1 = ret.Error(1)
312+ }
313+ return r0, r1
314+}
315+
316+// GitHubAPIService_TarballName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TarballName'
317+type GitHubAPIService_TarballName_Call struct {
318+ *mock.Call
319+}
320+
321+// TarballName is a helper method to define mock.On call
322+// - ctx context.Context
323+// - p domain.Principal
324+// - owner string
325+// - repo string
326+// - ref string
327+func (_e *GitHubAPIService_Expecter) TarballName(ctx any, p any, owner any, repo any, ref any) *GitHubAPIService_TarballName_Call {
328+ return &GitHubAPIService_TarballName_Call{Call: _e.mock.On("TarballName", ctx, p, owner, repo, ref)}
329+}
330+
331+func (_c *GitHubAPIService_TarballName_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string)) *GitHubAPIService_TarballName_Call {
332+ _c.Call.Run(func(args mock.Arguments) {
333+ var arg0 context.Context
334+ if args[0] != nil {
335+ arg0 = args[0].(context.Context)
336+ }
337+ var arg1 domain.Principal
338+ if args[1] != nil {
339+ arg1 = args[1].(domain.Principal)
340+ }
341+ var arg2 string
342+ if args[2] != nil {
343+ arg2 = args[2].(string)
344+ }
345+ var arg3 string
346+ if args[3] != nil {
347+ arg3 = args[3].(string)
348+ }
349+ var arg4 string
350+ if args[4] != nil {
351+ arg4 = args[4].(string)
352+ }
353+ run(
354+ arg0,
355+ arg1,
356+ arg2,
357+ arg3,
358+ arg4,
359+ )
360+ })
361+ return _c
362+}
363+
364+func (_c *GitHubAPIService_TarballName_Call) Return(s string, err error) *GitHubAPIService_TarballName_Call {
365+ _c.Call.Return(s, err)
366+ return _c
367+}
368+
369+func (_c *GitHubAPIService_TarballName_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error)) *GitHubAPIService_TarballName_Call {
370+ _c.Call.Return(run)
371+ return _c
372+}
@@ -286,3 +286,87 @@ func (_c *GitHubAPIService_Tarball_Call) RunAndReturn(run func(ctx context.Conte
286 _c.Call.Return(run)286 _c.Call.Return(run)
287 return _c287 return _c
288 }288 }
289+
290+// TarballName provides a mock function for the type GitHubAPIService
291+func (_mock *GitHubAPIService) TarballName(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error) {
292+ ret := _mock.Called(ctx, p, owner, repo, ref)
293+
294+ if len(ret) == 0 {
295+ panic("no return value specified for TarballName")
296+ }
297+
298+ var r0 string
299+ var r1 error
300+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) (string, error)); ok {
301+ return returnFunc(ctx, p, owner, repo, ref)
302+ }
303+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) string); ok {
304+ r0 = returnFunc(ctx, p, owner, repo, ref)
305+ } else {
306+ r0 = ret.Get(0).(string)
307+ }
308+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, string) error); ok {
309+ r1 = returnFunc(ctx, p, owner, repo, ref)
310+ } else {
311+ r1 = ret.Error(1)
312+ }
313+ return r0, r1
314+}
315+
316+// GitHubAPIService_TarballName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TarballName'
317+type GitHubAPIService_TarballName_Call struct {
318+ *mock.Call
319+}
320+
321+// TarballName is a helper method to define mock.On call
322+// - ctx context.Context
323+// - p domain.Principal
324+// - owner string
325+// - repo string
326+// - ref string
327+func (_e *GitHubAPIService_Expecter) TarballName(ctx any, p any, owner any, repo any, ref any) *GitHubAPIService_TarballName_Call {
328+ return &GitHubAPIService_TarballName_Call{Call: _e.mock.On("TarballName", ctx, p, owner, repo, ref)}
329+}
330+
331+func (_c *GitHubAPIService_TarballName_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string)) *GitHubAPIService_TarballName_Call {
332+ _c.Call.Run(func(args mock.Arguments) {
333+ var arg0 context.Context
334+ if args[0] != nil {
335+ arg0 = args[0].(context.Context)
336+ }
337+ var arg1 domain.Principal
338+ if args[1] != nil {
339+ arg1 = args[1].(domain.Principal)
340+ }
341+ var arg2 string
342+ if args[2] != nil {
343+ arg2 = args[2].(string)
344+ }
345+ var arg3 string
346+ if args[3] != nil {
347+ arg3 = args[3].(string)
348+ }
349+ var arg4 string
350+ if args[4] != nil {
351+ arg4 = args[4].(string)
352+ }
353+ run(
354+ arg0,
355+ arg1,
356+ arg2,
357+ arg3,
358+ arg4,
359+ )
360+ })
361+ return _c
362+}
363+
364+func (_c *GitHubAPIService_TarballName_Call) Return(s string, err error) *GitHubAPIService_TarballName_Call {
365+ _c.Call.Return(s, err)
366+ return _c
367+}
368+
369+func (_c *GitHubAPIService_TarballName_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error)) *GitHubAPIService_TarballName_Call {
370+ _c.Call.Return(run)
371+ return _c
372+}
M internal/ports/services.go +6 -1
@@ -67,10 +67,15 @@ type GitHubAPIService interface {
6767 HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error)
6868 // Tarball resolves ref (a branch, a tag or a commit id; empty means the
6969 // default branch) and writes the gzip-compressed tar of the commit to the
70- // writer that begin returns. begin is called once, with the file name of the
70+ // writer that begin returns; the archive token of an action (see
71+ // domain.RepoTokenPrincipal) gets the commit it was issued for and no other.
72+ // begin is called once, with the file name of the
7173 // archive, before the first byte and not at all when the ref does not
7274 // resolve (domain.ErrNotFound).
7375 Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error
76+ // TarballName is Tarball for a HEAD request: the same access and the same
77+ // resolution of ref, then the file name, without producing the archive.
78+ TarballName(ctx context.Context, p domain.Principal, owner, repo, ref string) (string, error)
7479 }
7580
7681 // BrowseService is the read side of a repository's content. *usecase.Browse
@@ -67,10 +67,15 @@ type GitHubAPIService interface {
67 HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error)67 HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error)
68 // Tarball resolves ref (a branch, a tag or a commit id; empty means the68 // Tarball resolves ref (a branch, a tag or a commit id; empty means the
69 // default branch) and writes the gzip-compressed tar of the commit to the69 // default branch) and writes the gzip-compressed tar of the commit to the
70- // writer that begin returns. begin is called once, with the file name of the70+ // writer that begin returns; the archive token of an action (see
71+ // domain.RepoTokenPrincipal) gets the commit it was issued for and no other.
72+ // begin is called once, with the file name of the
71 // archive, before the first byte and not at all when the ref does not73 // archive, before the first byte and not at all when the ref does not
72 // resolve (domain.ErrNotFound).74 // resolve (domain.ErrNotFound).
73 Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error75 Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error
76+ // TarballName is Tarball for a HEAD request: the same access and the same
77+ // resolution of ref, then the file name, without producing the archive.
78+ TarballName(ctx context.Context, p domain.Principal, owner, repo, ref string) (string, error)
74 }79 }
75 80
76 // BrowseService is the read side of a repository's content. *usecase.Browse81 // BrowseService is the read side of a repository's content. *usecase.Browse
M internal/usecase/access.go +6 -0
@@ -20,6 +20,12 @@ func canRead(p domain.Principal, repo domain.Repo) bool {
2020 // canReadAs is canRead for a repository known by its name and visibility only,
2121 // such as one that no longer exists.
2222 func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool {
23+ if _, _, archive := p.ArchiveGrant(); archive {
24+ // The holder of an archive token downloads one commit through the
25+ // archive endpoint, which checks its grant; everywhere else it reads
26+ // nothing, not even a public repository (domain.RepoTokenPrincipal).
27+ return false
28+ }
2329 if vis == domain.VisibilityPublic {
2430 return true
2531 }
@@ -20,6 +20,12 @@ func canRead(p domain.Principal, repo domain.Repo) bool {
20 // canReadAs is canRead for a repository known by its name and visibility only,20 // canReadAs is canRead for a repository known by its name and visibility only,
21 // such as one that no longer exists.21 // such as one that no longer exists.
22 func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool {22 func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool {
23+ if _, _, archive := p.ArchiveGrant(); archive {
24+ // The holder of an archive token downloads one commit through the
25+ // archive endpoint, which checks its grant; everywhere else it reads
26+ // nothing, not even a public repository (domain.RepoTokenPrincipal).
27+ return false
28+ }
23 if vis == domain.VisibilityPublic {29 if vis == domain.VisibilityPublic {
24 return true30 return true
25 }31 }
M internal/usecase/ci.go +29 -10
@@ -29,6 +29,11 @@ const (
2929 DefaultCIKeepalive = 30 * time.Second
3030 // DefaultCITriggerQueue is the size of the queue of pushes to plan.
3131 DefaultCITriggerQueue = 256
32+ // DefaultCIRemoteConcurrency is the git ls-remote processes ResolveActions
33+ // runs at once, whatever the number of jobs that resolve.
34+ DefaultCIRemoteConcurrency = 4
35+ // DefaultCIResolveTimeout bounds one ResolveActions call.
36+ DefaultCIResolveTimeout = 60 * time.Second
3237 // MaxCIChecks is the most checks ListChecks returns.
3338 MaxCIChecks = 1000
3439 // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log
@@ -61,6 +66,15 @@ type CIConfig struct {
6166 Remote ports.GitRemote
6267 // Archive serves the tarballs of the REST subset.
6368 Archive ports.GitArchive
69+ // Tokens finds the stored token of the pusher of a run, to ask what it may
70+ // read when an action of another repository is resolved for the run. Nil
71+ // reads nothing for a token (fail closed).
72+ Tokens ports.TokenStore
73+ // RemoteConcurrency bounds the git ls-remote processes at once (default
74+ // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call
75+ // (default DefaultCIResolveTimeout).
76+ RemoteConcurrency int
77+ ResolveTimeout time.Duration
6478 // Owner is the owner login of the github context.
6579 Owner string
6680 // PublicURL is the base URL of the instance (github.server_url).
@@ -95,6 +109,10 @@ func (c CIConfig) withDefaults() CIConfig {
95109 def(&c.TimeoutSlack, domain.CITimeoutSlack)
96110 def(&c.QueueTimeout, domain.CIQueueTimeout)
97111 def(&c.Retention, domain.CIRetentionDays*24*time.Hour)
112+ if c.RemoteConcurrency <= 0 {
113+ c.RemoteConcurrency = DefaultCIRemoteConcurrency
114+ }
115+ def(&c.ResolveTimeout, DefaultCIResolveTimeout)
98116 if c.TriggerQueue <= 0 {
99117 c.TriggerQueue = DefaultCITriggerQueue
100118 }
@@ -117,21 +135,22 @@ type CI struct {
117135 ctx context.Context
118136 wg sync.WaitGroup
119137
120- locks sync.Map // run id -> *sync.Mutex
121- assign sync.Mutex
122- jobs notifier // woken when a job changes: key is the job reference
123- pending notifier // woken when a job may be ready or a runner was removed
124- flows *wfCache
138+ remoteSlots chan struct{} // slots of the git ls-remote processes
139+ locks sync.Map // run id -> *sync.Mutex
140+ assign sync.Mutex
141+ jobs notifier // woken when a job changes: key is the job reference
142+ pending notifier // woken when a job may be ready or a runner was removed
143+ flows *wfCache
125144
126145 mu sync.Mutex
127146 heartbeats map[string]time.Time // job reference -> last heartbeat
128147 seen map[string]time.Time // runner id -> last seen
129148 polls map[string]pollRecord
130149 // archiveTokens are the secrets of the tokens given for the archives of
131- // ermite actions, per job reference then repository, so that a job that
132- // resolves the same repository twice gets one token (memory only: a
150+ // ermite actions, per job reference then "<repository>@<sha>", so that a job
151+ // that resolves the same commit twice gets one token (memory only: a
133152 // restart issues new ones).
134- archiveTokens map[string]map[domain.RepoName]string
153+ archiveTokens map[string]map[string]string
135154 streams int
136155 perClient map[string]int
137156 closed bool
@@ -164,9 +183,9 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
164183 run, stop := context.WithCancel(context.WithoutCancel(ctx))
165184 return &CI{
166185 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue),
167- ctx: run, stop: stop, flows: newWfCache(64),
186+ ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency),
168187 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
169- archiveTokens: map[string]map[domain.RepoName]string{},
188+ archiveTokens: map[string]map[string]string{},
170189 perClient: map[string]int{},
171190 }
172191 }
@@ -29,6 +29,11 @@ const (
29 DefaultCIKeepalive = 30 * time.Second29 DefaultCIKeepalive = 30 * time.Second
30 // DefaultCITriggerQueue is the size of the queue of pushes to plan.30 // DefaultCITriggerQueue is the size of the queue of pushes to plan.
31 DefaultCITriggerQueue = 25631 DefaultCITriggerQueue = 256
32+ // DefaultCIRemoteConcurrency is the git ls-remote processes ResolveActions
33+ // runs at once, whatever the number of jobs that resolve.
34+ DefaultCIRemoteConcurrency = 4
35+ // DefaultCIResolveTimeout bounds one ResolveActions call.
36+ DefaultCIResolveTimeout = 60 * time.Second
32 // MaxCIChecks is the most checks ListChecks returns.37 // MaxCIChecks is the most checks ListChecks returns.
33 MaxCIChecks = 100038 MaxCIChecks = 1000
34 // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log39 // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log
@@ -61,6 +66,15 @@ type CIConfig struct {
61 Remote ports.GitRemote66 Remote ports.GitRemote
62 // Archive serves the tarballs of the REST subset.67 // Archive serves the tarballs of the REST subset.
63 Archive ports.GitArchive68 Archive ports.GitArchive
69+ // Tokens finds the stored token of the pusher of a run, to ask what it may
70+ // read when an action of another repository is resolved for the run. Nil
71+ // reads nothing for a token (fail closed).
72+ Tokens ports.TokenStore
73+ // RemoteConcurrency bounds the git ls-remote processes at once (default
74+ // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call
75+ // (default DefaultCIResolveTimeout).
76+ RemoteConcurrency int
77+ ResolveTimeout time.Duration
64 // Owner is the owner login of the github context.78 // Owner is the owner login of the github context.
65 Owner string79 Owner string
66 // PublicURL is the base URL of the instance (github.server_url).80 // PublicURL is the base URL of the instance (github.server_url).
@@ -95,6 +109,10 @@ func (c CIConfig) withDefaults() CIConfig {
95 def(&c.TimeoutSlack, domain.CITimeoutSlack)109 def(&c.TimeoutSlack, domain.CITimeoutSlack)
96 def(&c.QueueTimeout, domain.CIQueueTimeout)110 def(&c.QueueTimeout, domain.CIQueueTimeout)
97 def(&c.Retention, domain.CIRetentionDays*24*time.Hour)111 def(&c.Retention, domain.CIRetentionDays*24*time.Hour)
112+ if c.RemoteConcurrency <= 0 {
113+ c.RemoteConcurrency = DefaultCIRemoteConcurrency
114+ }
115+ def(&c.ResolveTimeout, DefaultCIResolveTimeout)
98 if c.TriggerQueue <= 0 {116 if c.TriggerQueue <= 0 {
99 c.TriggerQueue = DefaultCITriggerQueue117 c.TriggerQueue = DefaultCITriggerQueue
100 }118 }
@@ -117,21 +135,22 @@ type CI struct {
117 ctx context.Context135 ctx context.Context
118 wg sync.WaitGroup136 wg sync.WaitGroup
119 137
120- locks sync.Map // run id -> *sync.Mutex138+ remoteSlots chan struct{} // slots of the git ls-remote processes
121- assign sync.Mutex139+ locks sync.Map // run id -> *sync.Mutex
122- jobs notifier // woken when a job changes: key is the job reference140+ assign sync.Mutex
123- pending notifier // woken when a job may be ready or a runner was removed141+ jobs notifier // woken when a job changes: key is the job reference
124- flows *wfCache142+ pending notifier // woken when a job may be ready or a runner was removed
143+ flows *wfCache
125 144
126 mu sync.Mutex145 mu sync.Mutex
127 heartbeats map[string]time.Time // job reference -> last heartbeat146 heartbeats map[string]time.Time // job reference -> last heartbeat
128 seen map[string]time.Time // runner id -> last seen147 seen map[string]time.Time // runner id -> last seen
129 polls map[string]pollRecord148 polls map[string]pollRecord
130 // archiveTokens are the secrets of the tokens given for the archives of149 // archiveTokens are the secrets of the tokens given for the archives of
131- // ermite actions, per job reference then repository, so that a job that150+ // ermite actions, per job reference then "<repository>@<sha>", so that a job
132- // resolves the same repository twice gets one token (memory only: a151+ // that resolves the same commit twice gets one token (memory only: a
133 // restart issues new ones).152 // restart issues new ones).
134- archiveTokens map[string]map[domain.RepoName]string153+ archiveTokens map[string]map[string]string
135 streams int154 streams int
136 perClient map[string]int155 perClient map[string]int
137 closed bool156 closed bool
@@ -164,9 +183,9 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
164 run, stop := context.WithCancel(context.WithoutCancel(ctx))183 run, stop := context.WithCancel(context.WithoutCancel(ctx))
165 return &CI{184 return &CI{
166 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue),185 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue),
167- ctx: run, stop: stop, flows: newWfCache(64),186+ ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency),
168 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},187 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
169- archiveTokens: map[string]map[domain.RepoName]string{},188+ archiveTokens: map[string]map[string]string{},
170 perClient: map[string]int{},189 perClient: map[string]int{},
171 }190 }
172 }191 }
M internal/usecase/ci_actions.go +135 -21
@@ -31,6 +31,18 @@ import (
3131 // reference that cannot be resolved is not an error of the call: its answer
3232 // carries GitHub's message.
3333 func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) {
34+ out, err := c.resolveActions(ctx, secret, jobID, uses)
35+ if err != nil && ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) {
36+ // Our own deadline, not the caller's: github.com (or the disk) was too
37+ // slow. The runner may retry, or fail the job.
38+ return nil, fmt.Errorf("%w: resolving the actions took more than %s", domain.ErrUnavailable, c.cfg.ResolveTimeout)
39+ }
40+ return out, err
41+}
42+
43+func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) {
44+ ctx, cancel := context.WithTimeout(ctx, c.cfg.ResolveTimeout)
45+ defer cancel()
3446 j, err := c.jobFor(ctx, secret, jobID)
3547 if err != nil {
3648 return nil, err
@@ -52,11 +64,17 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st
5264 parsed[i] = parseActionRef(u)
5365 }
5466
67+ src, err := c.cfg.Store.GetRunSource(ctx, j.RunID)
68+ if err != nil {
69+ return nil, err
70+ }
71+ by := c.onBehalfOf(src)
72+
5573 // Resolve what the attempt has not resolved yet, outside the lock of the
5674 // run: a ls-remote takes seconds and must not hold up the job's calls.
5775 found := map[string]domain.CIActionResolution{}
5876 fails := map[string]string{}
59- remote := newRemoteRefs(c.cfg.Remote)
77+ remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots)
6078 for _, p := range parsed {
6179 if p.err != "" {
6280 continue
@@ -73,7 +91,7 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st
7391 } else if !errors.Is(err, domain.ErrNotFound) {
7492 return nil, err
7593 }
76- res, msg, err := c.resolveAction(ctx, j, p, remote)
94+ res, msg, err := c.resolveAction(ctx, j, p, remote, by)
7795 if err != nil {
7896 return nil, err
7997 }
@@ -120,7 +138,7 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st
120138 if !ok {
121139 continue
122140 }
123- ra, err := c.answer(ctx, rs, own, p, res)
141+ ra, err := c.answer(ctx, rs, own, p, res, by)
124142 if err != nil {
125143 return err
126144 }
@@ -145,9 +163,12 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st
145163 const maxActionRefs = 100
146164
147165 // answer builds the answer for a resolved reference. The archive token of an
148-// ermite action is the one the job already holds for that repository, else a
149-// new one, written with the next flush.
150-func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution) (ports.ResolvedAction, error) {
166+// ermite action is bound to the commit of the answer: the one the job already
167+// holds for it, else a new one, written with the next flush. Whether the pusher
168+// may read the repository is asked again at each answer, not only when the
169+// commit was resolved: a token revoked since, or a repository made private,
170+// stops the answers.
171+func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution, by pusherAccess) (ports.ResolvedAction, error) {
151172 ra := ports.ResolvedAction{
152173 Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source,
153174 }
@@ -162,37 +183,88 @@ func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p action
162183 if err != nil {
163184 return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
164185 }
165- if _, err := c.cfg.Meta.Get(ctx, name); errors.Is(err, domain.ErrNotFound) {
186+ repo, err := c.cfg.Meta.Get(ctx, name)
187+ if errors.Is(err, domain.ErrNotFound) {
166188 return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
167189 } else if err != nil {
168190 return ports.ResolvedAction{}, err
169191 }
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+ }
170197 ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA
171- ra.Token = c.archiveToken(rs, j, name)
198+ ra.Token = c.archiveToken(rs, j, name, res.ResolvedSHA)
172199 }
173200 return ra, nil
174201 }
175202
176-// archiveToken returns the secret of the read token of the job for the
177-// archives of repo, issuing it when the job has none.
178-func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName) string {
179- ref := jobRef(*j)
203+// archiveToken returns the secret of the token of the job for the archive of
204+// the commit sha of repo, issuing it when the job has none. The token downloads
205+// that archive and nothing else (domain.RepoTokenPrincipal).
206+func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName, sha string) string {
207+ ref, key := jobRef(*j), repo.String()+"@"+sha
180208 c.mu.Lock()
181- tok, ok := c.archiveTokens[ref][repo]
209+ tok, ok := c.archiveTokens[ref][key]
182210 c.mu.Unlock()
183211 if ok {
184212 return tok
185213 }
186- tok = rs.issueRepoTokenFor(j, repo, true)
214+ tok = rs.issueRepoTokenFor(j, repo, sha)
187215 c.mu.Lock()
188216 if c.archiveTokens[ref] == nil {
189- c.archiveTokens[ref] = map[domain.RepoName]string{}
217+ c.archiveTokens[ref] = map[string]string{}
190218 }
191- c.archiveTokens[ref][repo] = tok
219+ c.archiveTokens[ref][key] = tok
192220 c.mu.Unlock()
193221 return tok
194222 }
195223
224+// pusherAccess is who the actions of a run are resolved on behalf of: the
225+// pusher recorded with the run.
226+type pusherAccess struct{ pusher domain.CIPusher }
227+
228+func (c *CI) onBehalfOf(src domain.CIRunSource) pusherAccess { return pusherAccess{src.Pusher} }
229+
230+// pusherCanRead tells whether the pusher of a run may read repo, with the rule
231+// of the forge (canRead) applied to the principal the pusher is now: the root
232+// token and the owner's session read everything; a stored token is looked up
233+// again, so a token revoked or expired since reads nothing, and its scopes and
234+// repository list apply; a run that records no pusher reads what an anonymous
235+// caller reads. The repository of the run itself needs no check: the job reads
236+// it with its own token.
237+func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.RepoName, repo domain.Repo) (bool, error) {
238+ if repo.Name == runRepo {
239+ return true, nil
240+ }
241+ var p domain.Principal
242+ switch by.pusher.Kind {
243+ case domain.CIPusherRoot:
244+ p = domain.Root()
245+ case domain.CIPusherOwner:
246+ p = domain.Owner()
247+ case domain.CIPusherToken:
248+ if c.cfg.Tokens == nil {
249+ return false, nil
250+ }
251+ tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID)
252+ if errors.Is(err, domain.ErrNotFound) {
253+ return false, nil
254+ }
255+ if err != nil {
256+ return false, err
257+ }
258+ if tok.Expired(c.now()) {
259+ return false, nil
260+ }
261+ p = domain.TokenPrincipal(tok)
262+ default:
263+ p = domain.Anonymous()
264+ }
265+ return canRead(p, repo), nil
266+}
267+
196268 // actionRef is a parsed "uses": owner/repo[/path]@ref.
197269 type actionRef struct {
198270 uses, owner, repo, path, ref string
@@ -221,11 +293,33 @@ func parseActionRef(uses string) actionRef {
221293 return a
222294 }
223295 }
296+ if !validActionName(segs[0]) || !validActionName(segs[1]) {
297+ a.err = errActionFormat(uses)
298+ return a
299+ }
224300 a.owner, a.repo, a.ref = segs[0], segs[1], ref
225301 a.path = strings.Join(segs[2:], "/")
226302 return a
227303 }
228304
305+// validActionName reports whether s can be an owner or a repository name on
306+// GitHub: letters, digits, '.', '_' and '-', 1 to 100 characters, not made of
307+// dots only ("." and ".." are path elements). Every source takes the same names, so that nothing but a name reaches a
308+// URL, a path or a command.
309+func validActionName(s string) bool {
310+ if s == "" || len(s) > 100 || strings.Trim(s, ".") == "" {
311+ return false
312+ }
313+ for _, r := range s {
314+ switch {
315+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
316+ default:
317+ return false
318+ }
319+ }
320+ return true
321+}
322+
229323 // --- GitHub's texts ---
230324 //
231325 // All the messages a reference can fail with are here. The first is the
@@ -247,11 +341,19 @@ func errUnresolvedRepo(a actionRef) string {
247341
248342 // resolveAction resolves one reference. msg is the message of a reference that
249343 // does not resolve; err is a failure of the call.
250-func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs) (res domain.CIActionResolution, msg string, err error) {
344+func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) {
251345 res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}
252- if strings.EqualFold(a.owner, c.cfg.Owner) {
346+ if domain.OwnerMatches(c.cfg.Owner, a.owner) {
253347 if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil {
254- if _, gerr := c.cfg.Meta.Get(ctx, name); gerr == 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+ }
255357 sha, found, err := c.resolveOnErmite(ctx, name, a.ref)
256358 if err != nil {
257359 return res, "", err
@@ -333,6 +435,7 @@ func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref stri
333435 // remoteRefs lists the refs of a remote once per repository within a call.
334436 type remoteRefs struct {
335437 remote ports.GitRemote
438+ slots chan struct{}
336439 seen map[string]remoteList
337440 }
338441
@@ -341,8 +444,8 @@ type remoteList struct {
341444 err error
342445 }
343446
344-func newRemoteRefs(r ports.GitRemote) *remoteRefs {
345- return &remoteRefs{remote: r, seen: map[string]remoteList{}}
447+func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs {
448+ return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}}
346449 }
347450
348451 func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
@@ -353,7 +456,18 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo
353456 if l, ok := r.seen[key]; ok {
354457 return l.refs, l.err
355458 }
459+ // At most a few ls-remote at once on the whole server, whatever the number
460+ // of jobs resolving: a process each, and github.com is slow to answer.
461+ select {
462+ case r.slots <- struct{}{}:
463+ defer func() { <-r.slots }()
464+ case <-ctx.Done():
465+ return nil, ctx.Err()
466+ }
356467 refs, err := r.remote.ListRefs(ctx, owner, repo)
468+ if ctx.Err() != nil {
469+ return nil, ctx.Err()
470+ }
357471 r.seen[key] = remoteList{refs, err}
358472 return refs, err
359473 }
@@ -31,6 +31,18 @@ import (
31 // reference that cannot be resolved is not an error of the call: its answer31 // reference that cannot be resolved is not an error of the call: its answer
32 // carries GitHub's message.32 // carries GitHub's message.
33 func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) {33 func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) {
34+ out, err := c.resolveActions(ctx, secret, jobID, uses)
35+ if err != nil && ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) {
36+ // Our own deadline, not the caller's: github.com (or the disk) was too
37+ // slow. The runner may retry, or fail the job.
38+ return nil, fmt.Errorf("%w: resolving the actions took more than %s", domain.ErrUnavailable, c.cfg.ResolveTimeout)
39+ }
40+ return out, err
41+}
42+
43+func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) {
44+ ctx, cancel := context.WithTimeout(ctx, c.cfg.ResolveTimeout)
45+ defer cancel()
34 j, err := c.jobFor(ctx, secret, jobID)46 j, err := c.jobFor(ctx, secret, jobID)
35 if err != nil {47 if err != nil {
36 return nil, err48 return nil, err
@@ -52,11 +64,17 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st
52 parsed[i] = parseActionRef(u)64 parsed[i] = parseActionRef(u)
53 }65 }
54 66
67+ src, err := c.cfg.Store.GetRunSource(ctx, j.RunID)
68+ if err != nil {
69+ return nil, err
70+ }
71+ by := c.onBehalfOf(src)
72+
55 // Resolve what the attempt has not resolved yet, outside the lock of the73 // Resolve what the attempt has not resolved yet, outside the lock of the
56 // run: a ls-remote takes seconds and must not hold up the job's calls.74 // run: a ls-remote takes seconds and must not hold up the job's calls.
57 found := map[string]domain.CIActionResolution{}75 found := map[string]domain.CIActionResolution{}
58 fails := map[string]string{}76 fails := map[string]string{}
59- remote := newRemoteRefs(c.cfg.Remote)77+ remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots)
60 for _, p := range parsed {78 for _, p := range parsed {
61 if p.err != "" {79 if p.err != "" {
62 continue80 continue
@@ -73,7 +91,7 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st
73 } else if !errors.Is(err, domain.ErrNotFound) {91 } else if !errors.Is(err, domain.ErrNotFound) {
74 return nil, err92 return nil, err
75 }93 }
76- res, msg, err := c.resolveAction(ctx, j, p, remote)94+ res, msg, err := c.resolveAction(ctx, j, p, remote, by)
77 if err != nil {95 if err != nil {
78 return nil, err96 return nil, err
79 }97 }
@@ -120,7 +138,7 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st
120 if !ok {138 if !ok {
121 continue139 continue
122 }140 }
123- ra, err := c.answer(ctx, rs, own, p, res)141+ ra, err := c.answer(ctx, rs, own, p, res, by)
124 if err != nil {142 if err != nil {
125 return err143 return err
126 }144 }
@@ -145,9 +163,12 @@ func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []st
145 const maxActionRefs = 100163 const maxActionRefs = 100
146 164
147 // answer builds the answer for a resolved reference. The archive token of an165 // answer builds the answer for a resolved reference. The archive token of an
148-// ermite action is the one the job already holds for that repository, else a166+// ermite action is bound to the commit of the answer: the one the job already
149-// new one, written with the next flush.167+// holds for it, else a new one, written with the next flush. Whether the pusher
150-func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution) (ports.ResolvedAction, error) {168+// may read the repository is asked again at each answer, not only when the
169+// commit was resolved: a token revoked since, or a repository made private,
170+// stops the answers.
171+func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution, by pusherAccess) (ports.ResolvedAction, error) {
151 ra := ports.ResolvedAction{172 ra := ports.ResolvedAction{
152 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,
153 }174 }
@@ -162,37 +183,88 @@ func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p action
162 if err != nil {183 if err != nil {
163 return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil184 return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
164 }185 }
165- if _, err := c.cfg.Meta.Get(ctx, name); errors.Is(err, domain.ErrNotFound) {186+ repo, err := c.cfg.Meta.Get(ctx, name)
187+ if errors.Is(err, domain.ErrNotFound) {
166 return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil188 return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
167 } else if err != nil {189 } else if err != nil {
168 return ports.ResolvedAction{}, err190 return ports.ResolvedAction{}, err
169 }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+ }
170 ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA197 ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA
171- ra.Token = c.archiveToken(rs, j, name)198+ ra.Token = c.archiveToken(rs, j, name, res.ResolvedSHA)
172 }199 }
173 return ra, nil200 return ra, nil
174 }201 }
175 202
176-// archiveToken returns the secret of the read token of the job for the203+// archiveToken returns the secret of the token of the job for the archive of
177-// archives of repo, issuing it when the job has none.204+// the commit sha of repo, issuing it when the job has none. The token downloads
178-func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName) string {205+// that archive and nothing else (domain.RepoTokenPrincipal).
179- ref := jobRef(*j)206+func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName, sha string) string {
207+ ref, key := jobRef(*j), repo.String()+"@"+sha
180 c.mu.Lock()208 c.mu.Lock()
181- tok, ok := c.archiveTokens[ref][repo]209+ tok, ok := c.archiveTokens[ref][key]
182 c.mu.Unlock()210 c.mu.Unlock()
183 if ok {211 if ok {
184 return tok212 return tok
185 }213 }
186- tok = rs.issueRepoTokenFor(j, repo, true)214+ tok = rs.issueRepoTokenFor(j, repo, sha)
187 c.mu.Lock()215 c.mu.Lock()
188 if c.archiveTokens[ref] == nil {216 if c.archiveTokens[ref] == nil {
189- c.archiveTokens[ref] = map[domain.RepoName]string{}217+ c.archiveTokens[ref] = map[string]string{}
190 }218 }
191- c.archiveTokens[ref][repo] = tok219+ c.archiveTokens[ref][key] = tok
192 c.mu.Unlock()220 c.mu.Unlock()
193 return tok221 return tok
194 }222 }
195 223
224+// pusherAccess is who the actions of a run are resolved on behalf of: the
225+// pusher recorded with the run.
226+type pusherAccess struct{ pusher domain.CIPusher }
227+
228+func (c *CI) onBehalfOf(src domain.CIRunSource) pusherAccess { return pusherAccess{src.Pusher} }
229+
230+// pusherCanRead tells whether the pusher of a run may read repo, with the rule
231+// of the forge (canRead) applied to the principal the pusher is now: the root
232+// token and the owner's session read everything; a stored token is looked up
233+// again, so a token revoked or expired since reads nothing, and its scopes and
234+// repository list apply; a run that records no pusher reads what an anonymous
235+// caller reads. The repository of the run itself needs no check: the job reads
236+// it with its own token.
237+func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.RepoName, repo domain.Repo) (bool, error) {
238+ if repo.Name == runRepo {
239+ return true, nil
240+ }
241+ var p domain.Principal
242+ switch by.pusher.Kind {
243+ case domain.CIPusherRoot:
244+ p = domain.Root()
245+ case domain.CIPusherOwner:
246+ p = domain.Owner()
247+ case domain.CIPusherToken:
248+ if c.cfg.Tokens == nil {
249+ return false, nil
250+ }
251+ tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID)
252+ if errors.Is(err, domain.ErrNotFound) {
253+ return false, nil
254+ }
255+ if err != nil {
256+ return false, err
257+ }
258+ if tok.Expired(c.now()) {
259+ return false, nil
260+ }
261+ p = domain.TokenPrincipal(tok)
262+ default:
263+ p = domain.Anonymous()
264+ }
265+ return canRead(p, repo), nil
266+}
267+
196 // actionRef is a parsed "uses": owner/repo[/path]@ref.268 // actionRef is a parsed "uses": owner/repo[/path]@ref.
197 type actionRef struct {269 type actionRef struct {
198 uses, owner, repo, path, ref string270 uses, owner, repo, path, ref string
@@ -221,11 +293,33 @@ func parseActionRef(uses string) actionRef {
221 return a293 return a
222 }294 }
223 }295 }
296+ if !validActionName(segs[0]) || !validActionName(segs[1]) {
297+ a.err = errActionFormat(uses)
298+ return a
299+ }
224 a.owner, a.repo, a.ref = segs[0], segs[1], ref300 a.owner, a.repo, a.ref = segs[0], segs[1], ref
225 a.path = strings.Join(segs[2:], "/")301 a.path = strings.Join(segs[2:], "/")
226 return a302 return a
227 }303 }
228 304
305+// validActionName reports whether s can be an owner or a repository name on
306+// GitHub: letters, digits, '.', '_' and '-', 1 to 100 characters, not made of
307+// dots only ("." and ".." are path elements). Every source takes the same names, so that nothing but a name reaches a
308+// URL, a path or a command.
309+func validActionName(s string) bool {
310+ if s == "" || len(s) > 100 || strings.Trim(s, ".") == "" {
311+ return false
312+ }
313+ for _, r := range s {
314+ switch {
315+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
316+ default:
317+ return false
318+ }
319+ }
320+ return true
321+}
322+
229 // --- GitHub's texts ---323 // --- GitHub's texts ---
230 //324 //
231 // All the messages a reference can fail with are here. The first is the325 // All the messages a reference can fail with are here. The first is the
@@ -247,11 +341,19 @@ func errUnresolvedRepo(a actionRef) string {
247 341
248 // resolveAction resolves one reference. msg is the message of a reference that342 // resolveAction resolves one reference. msg is the message of a reference that
249 // does not resolve; err is a failure of the call.343 // does not resolve; err is a failure of the call.
250-func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs) (res domain.CIActionResolution, msg string, err error) {344+func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) {
251 res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}345 res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}
252- if strings.EqualFold(a.owner, c.cfg.Owner) {346+ if domain.OwnerMatches(c.cfg.Owner, a.owner) {
253 if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil {347 if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil {
254- if _, gerr := c.cfg.Meta.Get(ctx, name); gerr == 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+ }
255 sha, found, err := c.resolveOnErmite(ctx, name, a.ref)357 sha, found, err := c.resolveOnErmite(ctx, name, a.ref)
256 if err != nil {358 if err != nil {
257 return res, "", err359 return res, "", err
@@ -333,6 +435,7 @@ func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref stri
333 // remoteRefs lists the refs of a remote once per repository within a call.435 // remoteRefs lists the refs of a remote once per repository within a call.
334 type remoteRefs struct {436 type remoteRefs struct {
335 remote ports.GitRemote437 remote ports.GitRemote
438+ slots chan struct{}
336 seen map[string]remoteList439 seen map[string]remoteList
337 }440 }
338 441
@@ -341,8 +444,8 @@ type remoteList struct {
341 err error444 err error
342 }445 }
343 446
344-func newRemoteRefs(r ports.GitRemote) *remoteRefs {447+func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs {
345- return &remoteRefs{remote: r, seen: map[string]remoteList{}}448+ return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}}
346 }449 }
347 450
348 func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {451 func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
@@ -353,7 +456,18 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo
353 if l, ok := r.seen[key]; ok {456 if l, ok := r.seen[key]; ok {
354 return l.refs, l.err457 return l.refs, l.err
355 }458 }
459+ // At most a few ls-remote at once on the whole server, whatever the number
460+ // of jobs resolving: a process each, and github.com is slow to answer.
461+ select {
462+ case r.slots <- struct{}{}:
463+ defer func() { <-r.slots }()
464+ case <-ctx.Done():
465+ return nil, ctx.Err()
466+ }
356 refs, err := r.remote.ListRefs(ctx, owner, repo)467 refs, err := r.remote.ListRefs(ctx, owner, repo)
468+ if ctx.Err() != nil {
469+ return nil, ctx.Err()
470+ }
357 r.seen[key] = remoteList{refs, err}471 r.seen[key] = remoteList{refs, err}
358 return refs, err472 return refs, err
359 }473 }
M internal/usecase/ci_actions_test.go +7 -0
@@ -3,6 +3,7 @@
33 package usecase
44
55 import (
6+ "strings"
67 "testing"
78
89 "github.com/stretchr/testify/assert"
@@ -28,6 +29,12 @@ func TestParseActionRef(t *testing.T) {
2829 {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"},
2930 {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"},
3031 {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"},
32+ {uses: "a/...@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/...@v1'"},
33+ {uses: "../..@" + strings.Repeat("a", 40), fail: "Expected format {org}/{repo}[/path]@ref. Actual '../..@" + strings.Repeat("a", 40) + "'"},
34+ {uses: "a%2f/b@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a%2f/b@v1'"},
35+ {uses: "a/b?x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b?x@v1'"},
36+ {uses: "a/b#x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b#x@v1'"},
37+ {uses: "-a/_b.c@v1", owner: "-a", repo: "_b.c", ref: "v1"},
3138 {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"},
3239 }
3340 for _, tt := range tests {
@@ -3,6 +3,7 @@
3 package usecase3 package usecase
4 4
5 import (5 import (
6+ "strings"
6 "testing"7 "testing"
7 8
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
@@ -28,6 +29,12 @@ func TestParseActionRef(t *testing.T) {
28 {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"},29 {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"},
29 {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"},30 {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"},
30 {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"},31 {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"},
32+ {uses: "a/...@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/...@v1'"},
33+ {uses: "../..@" + strings.Repeat("a", 40), fail: "Expected format {org}/{repo}[/path]@ref. Actual '../..@" + strings.Repeat("a", 40) + "'"},
34+ {uses: "a%2f/b@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a%2f/b@v1'"},
35+ {uses: "a/b?x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b?x@v1'"},
36+ {uses: "a/b#x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b#x@v1'"},
37+ {uses: "-a/_b.c@v1", owner: "-a", repo: "_b.c", ref: "v1"},
31 {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"},38 {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"},
32 }39 }
33 for _, tt := range tests {40 for _, tt := range tests {
M internal/usecase/ci_githubapi.go +61 -13
@@ -17,7 +17,8 @@ import (
1717 // on github.api_url. The access rule is the forge's: a repository is read by
1818 // anyone when public, and with repo:read when private, which the principal of a
1919 // job repository token holds on the repository of its run (and the archive
20-// tokens on theirs).
20+// tokens on theirs). The holder of an archive token reads none of it but its
21+// own archive.
2122
2223 var _ ports.GitHubAPIService = (*CI)(nil)
2324
@@ -25,7 +26,7 @@ var _ ports.GitHubAPIService = (*CI)(nil)
2526 // owner, a name that is not one and a repository the caller cannot read are all
2627 // not found.
2728 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+ if !domain.OwnerMatches(c.cfg.Owner, owner) {
2930 return domain.Repo{}, domain.ErrNotFound
3031 }
3132 rn, err := domain.ParseRepoName(strings.ToLower(name))
@@ -97,25 +98,72 @@ func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name
9798 return "sha1", nil
9899 }
99100
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
101+// archiveTarget finds the repository and the commit of an archive request and
102+// checks the access to it. A reader of the repository (the job's own token, any
103+// principal with repo:read, anyone for a public one) gets any ref. The holder of
104+// an archive token (domain.RepoTokenPrincipal) gets its commit of its
105+// repository and nothing else: the ref may be anything that resolves to exactly
106+// that commit, and every other answer is not found.
107+func (c *CI) archiveTarget(ctx context.Context, p domain.Principal, owner, name, ref string) (domain.Repo, domain.ObjectID, error) {
108+ var repo domain.Repo
109+ grantRepo, grantSha, granted := p.ArchiveGrant()
110+ if granted {
111+ if !domain.OwnerMatches(c.cfg.Owner, owner) {
112+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
113+ }
114+ rn, err := domain.ParseRepoName(strings.ToLower(name))
115+ if err != nil || rn != grantRepo {
116+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
117+ }
118+ if repo, err = lookupRepo(ctx, c.cfg.Meta, rn); err != nil {
119+ return domain.Repo{}, domain.ObjectID{}, err
120+ }
121+ } else {
122+ var err error
123+ if repo, err = c.githubRepo(ctx, p, owner, name); err != nil {
124+ return domain.Repo{}, domain.ObjectID{}, err
125+ }
108126 }
109127 rev, err := domain.ParseRevision(ref)
110128 if err != nil {
111- return domain.ErrNotFound
129+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
112130 }
113131 res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev)
132+ if err != nil {
133+ return domain.Repo{}, domain.ObjectID{}, err
134+ }
135+ if granted && res.CommitID.String() != grantSha {
136+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
137+ }
138+ return repo, res.CommitID, nil
139+}
140+
141+func (c *CI) archiveDir(repo domain.Repo, commit domain.ObjectID) string {
142+ return c.cfg.Owner + "-" + repo.Name.String() + "-" + commit.String()[:7]
143+}
144+
145+// TarballName answers a HEAD of the archive: the access and the ref are
146+// checked as for the archive, and the name of the file is returned, without
147+// running git archive.
148+func (c *CI) TarballName(ctx context.Context, p domain.Principal, owner, name, ref string) (string, error) {
149+ repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref)
150+ if err != nil {
151+ return "", err
152+ }
153+ return c.archiveDir(repo, commit) + ".tar.gz", nil
154+}
155+
156+// Tarball streams the archive of a ref as `git archive` makes it, in one
157+// response (GitHub answers 302 to codeload: ermite serves it itself). The files
158+// are in a directory <owner>-<repo>-<7 first characters of the commit>, as
159+// GitHub names it.
160+func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error {
161+ repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref)
114162 if err != nil {
115163 return err
116164 }
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") }})
165+ dir := c.archiveDir(repo, commit)
166+ return c.cfg.Archive.Archive(ctx, repo.Name, commit, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }})
119167 }
120168
121169 // lazyWriter calls begin at the first write, so that nothing is sent before git
@@ -17,7 +17,8 @@ import (
17 // on github.api_url. The access rule is the forge's: a repository is read by17 // 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 a18 // 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 archive19 // job repository token holds on the repository of its run (and the archive
20-// tokens on theirs).20+// tokens on theirs). The holder of an archive token reads none of it but its
21+// own archive.
21 22
22 var _ ports.GitHubAPIService = (*CI)(nil)23 var _ ports.GitHubAPIService = (*CI)(nil)
23 24
@@ -25,7 +26,7 @@ var _ ports.GitHubAPIService = (*CI)(nil)
25 // owner, a name that is not one and a repository the caller cannot read are all26 // owner, a name that is not one and a repository the caller cannot read are all
26 // not found.27 // not found.
27 func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) {28 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+ if !domain.OwnerMatches(c.cfg.Owner, owner) {
29 return domain.Repo{}, domain.ErrNotFound30 return domain.Repo{}, domain.ErrNotFound
30 }31 }
31 rn, err := domain.ParseRepoName(strings.ToLower(name))32 rn, err := domain.ParseRepoName(strings.ToLower(name))
@@ -97,25 +98,72 @@ func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name
97 return "sha1", nil98 return "sha1", nil
98 }99 }
99 100
100-// Tarball streams the archive of a ref as `git archive` makes it, in one101+// archiveTarget finds the repository and the commit of an archive request and
101-// response (GitHub answers 302 to codeload: ermite serves it itself). The files102+// checks the access to it. A reader of the repository (the job's own token, any
102-// are in a directory <owner>-<repo>-<7 first characters of the commit>, as103+// principal with repo:read, anyone for a public one) gets any ref. The holder of
103-// GitHub names it.104+// an archive token (domain.RepoTokenPrincipal) gets its commit of its
104-func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error {105+// repository and nothing else: the ref may be anything that resolves to exactly
105- repo, err := c.githubRepo(ctx, p, owner, name)106+// that commit, and every other answer is not found.
106- if err != nil {107+func (c *CI) archiveTarget(ctx context.Context, p domain.Principal, owner, name, ref string) (domain.Repo, domain.ObjectID, error) {
107- return err108+ var repo domain.Repo
109+ grantRepo, grantSha, granted := p.ArchiveGrant()
110+ if granted {
111+ if !domain.OwnerMatches(c.cfg.Owner, owner) {
112+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
113+ }
114+ rn, err := domain.ParseRepoName(strings.ToLower(name))
115+ if err != nil || rn != grantRepo {
116+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
117+ }
118+ if repo, err = lookupRepo(ctx, c.cfg.Meta, rn); err != nil {
119+ return domain.Repo{}, domain.ObjectID{}, err
120+ }
121+ } else {
122+ var err error
123+ if repo, err = c.githubRepo(ctx, p, owner, name); err != nil {
124+ return domain.Repo{}, domain.ObjectID{}, err
125+ }
108 }126 }
109 rev, err := domain.ParseRevision(ref)127 rev, err := domain.ParseRevision(ref)
110 if err != nil {128 if err != nil {
111- return domain.ErrNotFound129+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
112 }130 }
113 res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev)131 res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev)
132+ if err != nil {
133+ return domain.Repo{}, domain.ObjectID{}, err
134+ }
135+ if granted && res.CommitID.String() != grantSha {
136+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
137+ }
138+ return repo, res.CommitID, nil
139+}
140+
141+func (c *CI) archiveDir(repo domain.Repo, commit domain.ObjectID) string {
142+ return c.cfg.Owner + "-" + repo.Name.String() + "-" + commit.String()[:7]
143+}
144+
145+// TarballName answers a HEAD of the archive: the access and the ref are
146+// checked as for the archive, and the name of the file is returned, without
147+// running git archive.
148+func (c *CI) TarballName(ctx context.Context, p domain.Principal, owner, name, ref string) (string, error) {
149+ repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref)
150+ if err != nil {
151+ return "", err
152+ }
153+ return c.archiveDir(repo, commit) + ".tar.gz", nil
154+}
155+
156+// Tarball streams the archive of a ref as `git archive` makes it, in one
157+// response (GitHub answers 302 to codeload: ermite serves it itself). The files
158+// are in a directory <owner>-<repo>-<7 first characters of the commit>, as
159+// GitHub names it.
160+func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error {
161+ repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref)
114 if err != nil {162 if err != nil {
115 return err163 return err
116 }164 }
117- dir := c.cfg.Owner + "-" + repo.Name.String() + "-" + shortID(res.CommitID.String())[:7]165+ dir := c.archiveDir(repo, commit)
118- return c.cfg.Archive.Archive(ctx, repo.Name, res.CommitID, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }})166+ return c.cfg.Archive.Archive(ctx, repo.Name, commit, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }})
119 }167 }
120 168
121 // lazyWriter calls begin at the first write, so that nothing is sent before git169 // lazyWriter calls begin at the first write, so that nothing is sent before git
M internal/usecase/ci_repotoken.go +7 -5
@@ -36,12 +36,14 @@ func (c *CI) repoTokenExpiry(j domain.CIJob) (expires domain.CIRepoToken) {
3636
3737 // issueRepoToken adds a repository token for j to the next flush and returns
3838 // its secret. j must be assigned (its AssignedAt is set).
39-func (rs *runState) issueRepoToken(j *domain.CIJob, archive bool) string {
40- return rs.issueRepoTokenFor(j, rs.run.Repo, archive)
39+func (rs *runState) issueRepoToken(j *domain.CIJob) string {
40+ return rs.issueRepoTokenFor(j, rs.run.Repo, "")
4141 }
4242
43-// issueRepoTokenFor is issueRepoToken for a token that reads repo.
44-func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, archive bool) string {
43+// issueRepoTokenFor is issueRepoToken for a token that reads repo. A sha makes
44+// it the archive token of that commit of repo, which downloads that archive and
45+// nothing else.
46+func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, sha string) string {
4547 c := rs.c
4648 now := c.now()
4749 at := *j
@@ -51,7 +53,7 @@ func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, arc
5153 secret := newToken(domain.RepoTokenPrefix)
5254 t := c.repoTokenExpiry(at)
5355 t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo
54- t.RunID, t.RunAttempt, t.JobID, t.Archive, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, archive, now
56+ t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now
5557 rs.tokens = append(rs.tokens, t)
5658 return secret
5759 }
@@ -36,12 +36,14 @@ func (c *CI) repoTokenExpiry(j domain.CIJob) (expires domain.CIRepoToken) {
36 36
37 // issueRepoToken adds a repository token for j to the next flush and returns37 // issueRepoToken adds a repository token for j to the next flush and returns
38 // its secret. j must be assigned (its AssignedAt is set).38 // its secret. j must be assigned (its AssignedAt is set).
39-func (rs *runState) issueRepoToken(j *domain.CIJob, archive bool) string {39+func (rs *runState) issueRepoToken(j *domain.CIJob) string {
40- return rs.issueRepoTokenFor(j, rs.run.Repo, archive)40+ return rs.issueRepoTokenFor(j, rs.run.Repo, "")
41 }41 }
42 42
43-// issueRepoTokenFor is issueRepoToken for a token that reads repo.43+// issueRepoTokenFor is issueRepoToken for a token that reads repo. A sha makes
44-func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, archive bool) string {44+// it the archive token of that commit of repo, which downloads that archive and
45+// nothing else.
46+func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, sha string) string {
45 c := rs.c47 c := rs.c
46 now := c.now()48 now := c.now()
47 at := *j49 at := *j
@@ -51,7 +53,7 @@ func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, arc
51 secret := newToken(domain.RepoTokenPrefix)53 secret := newToken(domain.RepoTokenPrefix)
52 t := c.repoTokenExpiry(at)54 t := c.repoTokenExpiry(at)
53 t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo55 t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo
54- t.RunID, t.RunAttempt, t.JobID, t.Archive, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, archive, now56+ t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now
55 rs.tokens = append(rs.tokens, t)57 rs.tokens = append(rs.tokens, t)
56 return secret58 return secret
57 }59 }
M internal/usecase/ci_runner.go +1 -1
@@ -241,7 +241,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
241241 return errNoJob
242242 }
243243 token := newToken(domain.JobTokenPrefix)
244- ghToken := rs.issueRepoToken(j, false)
244+ ghToken := rs.issueRepoToken(j)
245245 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
246246 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}
247247 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation
@@ -241,7 +241,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
241 return errNoJob241 return errNoJob
242 }242 }
243 token := newToken(domain.JobTokenPrefix)243 token := newToken(domain.JobTokenPrefix)
244- ghToken := rs.issueRepoToken(j, false)244+ ghToken := rs.issueRepoToken(j)
245 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()245 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
246 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}246 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}
247 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation247 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation
M internal/usecase/ci_trigger.go +4 -3
@@ -36,6 +36,7 @@ type ciTrigger struct {
3636 repo domain.RepoName
3737 updates []domain.RefUpdate
3838 actor string
39+ pusher domain.CIPusher
3940 at time.Time
4041 log zerowrap.Logger
4142 }
@@ -53,7 +54,7 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
5354 if len(kept) == 0 {
5455 return
5556 }
56- t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)}
57+ t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), pusher: domain.PusherOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)}
5758 c.mu.Lock()
5859 closed := c.closed
5960 c.mu.Unlock()
@@ -384,7 +385,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
384385 CreatedAt: now, StartedAt: now, CompletedAt: now,
385386 }
386387 return ports.CIRunDraft{
387- Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"},
388+ Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}", Pusher: t.pusher},
388389 Checks: []domain.CICheck{{
389390 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,
390391 Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now,
@@ -436,7 +437,7 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
436437 run.Name, run.Title = runName, runName
437438 }
438439 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
439- Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,
440+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, Pusher: t.pusher,
440441 }}
441442 for i, j := range wf.Jobs {
442443 job := domain.CIJob{
@@ -36,6 +36,7 @@ type ciTrigger struct {
36 repo domain.RepoName36 repo domain.RepoName
37 updates []domain.RefUpdate37 updates []domain.RefUpdate
38 actor string38 actor string
39+ pusher domain.CIPusher
39 at time.Time40 at time.Time
40 log zerowrap.Logger41 log zerowrap.Logger
41 }42 }
@@ -53,7 +54,7 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
53 if len(kept) == 0 {54 if len(kept) == 0 {
54 return55 return
55 }56 }
56- t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)}57+ t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), pusher: domain.PusherOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)}
57 c.mu.Lock()58 c.mu.Lock()
58 closed := c.closed59 closed := c.closed
59 c.mu.Unlock()60 c.mu.Unlock()
@@ -384,7 +385,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
384 CreatedAt: now, StartedAt: now, CompletedAt: now,385 CreatedAt: now, StartedAt: now, CompletedAt: now,
385 }386 }
386 return ports.CIRunDraft{387 return ports.CIRunDraft{
387- Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"},388+ Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}", Pusher: t.pusher},
388 Checks: []domain.CICheck{{389 Checks: []domain.CICheck{{
389 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,390 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,
390 Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now,391 Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now,
@@ -436,7 +437,7 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
436 run.Name, run.Title = runName, runName437 run.Name, run.Title = runName, runName
437 }438 }
438 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{439 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
439- Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,440+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, Pusher: t.pusher,
440 }}441 }}
441 for i, j := range wf.Jobs {442 for i, j := range wf.Jobs {
442 job := domain.CIJob{443 job := domain.CIJob{
M proto/ermite/storage/ci.proto +11 -3
@@ -112,6 +112,11 @@ message CIRunSource {
112112 // Repository of the run: the key of the run record is run/<repo>/<id>, and
113113 // the runner side knows a run by its id only.
114114 string repo = 3;
115+ // Who pushed the commit: "root", "owner", "token" or empty (a run stored
116+ // before it was recorded, or pushed by nobody). pusher_token_id is the ID of
117+ // the stored token for "token". What a token may read is looked up live.
118+ string pusher_kind = 5;
119+ string pusher_token_id = 6;
115120 }
116121
117122 // CIStep is a step of a job.
@@ -223,9 +228,10 @@ message CIRunner {
223228
224229 // CIRepoToken is a job repository token (the "github.token" of a job, or the
225230 // token of one action archive), stored under "ghtoken/<id>". The secret is
226-// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and
227-// "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists
228-// the tokens of a job so that they are revoked together.
231+// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id,
232+// "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a
233+// job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the
234+// tokens that read a repository, so that they go with it.
229235 message CIRepoToken {
230236 string id = 1;
231237 bytes hash = 2;
@@ -239,6 +245,8 @@ message CIRepoToken {
239245 bool archive = 7;
240246 google.protobuf.Timestamp expires_at = 8;
241247 google.protobuf.Timestamp created_at = 9;
248+ // The commit an archive token downloads.
249+ string sha = 10;
242250 }
243251
244252 // CIActionResolution is the resolution of an action reference for a run
@@ -112,6 +112,11 @@ message CIRunSource {
112 // Repository of the run: the key of the run record is run/<repo>/<id>, and112 // Repository of the run: the key of the run record is run/<repo>/<id>, and
113 // the runner side knows a run by its id only.113 // the runner side knows a run by its id only.
114 string repo = 3;114 string repo = 3;
115+ // Who pushed the commit: "root", "owner", "token" or empty (a run stored
116+ // before it was recorded, or pushed by nobody). pusher_token_id is the ID of
117+ // the stored token for "token". What a token may read is looked up live.
118+ string pusher_kind = 5;
119+ string pusher_token_id = 6;
115 }120 }
116 121
117 // CIStep is a step of a job.122 // CIStep is a step of a job.
@@ -223,9 +228,10 @@ message CIRunner {
223 228
224 // CIRepoToken is a job repository token (the "github.token" of a job, or the229 // CIRepoToken is a job repository token (the "github.token" of a job, or the
225 // token of one action archive), stored under "ghtoken/<id>". The secret is230 // token of one action archive), stored under "ghtoken/<id>". The secret is
226-// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and231+// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id,
227-// "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists232+// "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a
228-// the tokens of a job so that they are revoked together.233+// job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the
234+// tokens that read a repository, so that they go with it.
229 message CIRepoToken {235 message CIRepoToken {
230 string id = 1;236 string id = 1;
231 bytes hash = 2;237 bytes hash = 2;
@@ -239,6 +245,8 @@ message CIRepoToken {
239 bool archive = 7;245 bool archive = 7;
240 google.protobuf.Timestamp expires_at = 8;246 google.protobuf.Timestamp expires_at = 8;
241 google.protobuf.Timestamp created_at = 9;247 google.protobuf.Timestamp created_at = 9;
248+ // The commit an archive token downloads.
249+ string sha = 10;
242 }250 }
243 251
244 // CIActionResolution is the resolution of an action reference for a run252 // CIActionResolution is the resolution of an action reference for a run

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 45

↑↓ moveenter openesc close