mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-12 22:38:01 +09:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51938de973 | ||
|
|
21fda8f5be | ||
|
|
9ab9c18919 | ||
|
|
e2a0a87ae0 | ||
|
|
92044649a0 | ||
|
|
94011d2850 | ||
|
|
fe252be0ae | ||
|
|
cca0c65a6c | ||
|
|
6387c8ba6e | ||
|
|
6eab271921 | ||
|
|
eab225f095 | ||
|
|
00a637295e | ||
|
|
4e64b3a65d | ||
|
|
b71adfe1ad | ||
|
|
e5c6669751 | ||
|
|
e0e10052e0 | ||
|
|
c461575af3 | ||
|
|
7fb9602961 | ||
|
|
a8e80ebc23 | ||
|
|
8ab5d31cf3 | ||
|
|
b2af380d66 | ||
|
|
d2603a8b4a | ||
|
|
9eac9bd032 | ||
|
|
784d88814f | ||
|
|
a62dfffbe7 | ||
|
|
035dd58664 | ||
|
|
0d9ce64f76 | ||
|
|
4a77fbce28 | ||
|
|
2b37732d5f | ||
|
|
2695b47887 | ||
|
|
9c685dedbb | ||
|
|
e9b3917042 | ||
|
|
d7bc52beea | ||
|
|
ccd38f9a70 | ||
|
|
f47e3d930d | ||
|
|
9972bee41f | ||
|
|
375e6ea038 | ||
|
|
31c435454b | ||
|
|
a54324e2b7 | ||
|
|
d141dc729c | ||
|
|
27fed5a83a | ||
|
|
6cdd02bdad | ||
|
|
cc4ee6387b | ||
|
|
4780cffe08 | ||
|
|
483fb19b37 | ||
|
|
560535a97f | ||
|
|
62c61aa8ce | ||
|
|
337fa5a950 | ||
|
|
ad84c14d53 | ||
|
|
65ea4079ce | ||
|
|
1cf54fed70 | ||
|
|
1ae686696e | ||
|
|
e2f0358368 | ||
|
|
d88bbfd0db | ||
|
|
5e494f9cad | ||
|
|
9731ad7c3c | ||
|
|
148d528814 | ||
|
|
1af5277aba | ||
|
|
6d41731184 | ||
|
|
cfc9f4c685 | ||
|
|
895d848ff4 | ||
|
|
7199547218 | ||
|
|
7cb4201f8e | ||
|
|
5f017302bf | ||
|
|
59c619660c | ||
|
|
8599289459 | ||
|
|
da9f0a3726 | ||
|
|
08fd59959e | ||
|
|
d60215c2a2 | ||
|
|
bf594690db | ||
|
|
5cb7ec9304 | ||
|
|
6e86c4cde8 | ||
|
|
c32af046a2 | ||
|
|
a98468da30 | ||
|
|
b969123b7f | ||
|
|
c6627af968 | ||
|
|
de4b8277e9 | ||
|
|
acbc75e2bd | ||
|
|
c3325be816 | ||
|
|
d4250bafd9 | ||
|
|
6815d1646c | ||
|
|
8ba4d4ebec | ||
|
|
b2794f96b9 | ||
|
|
b6aa2b61c6 | ||
|
|
74ad781db9 | ||
|
|
ed493f0684 | ||
|
|
af7ba2edef | ||
|
|
8b4252e7f6 | ||
|
|
500a09e044 | ||
|
|
bfebc4b0e1 | ||
|
|
d9534e7bfa | ||
|
|
532828cc82 | ||
|
|
8cf988f0a6 | ||
|
|
04a26f40a0 | ||
|
|
fc4eac390a | ||
|
|
64d559a8e6 | ||
|
|
03372836ab | ||
|
|
1af1e06ac4 | ||
|
|
e07a5de53f | ||
|
|
d4d81af583 | ||
|
|
6cd2c647ec | ||
|
|
37c8604105 | ||
|
|
a9814e789c | ||
|
|
ab10e37acf | ||
|
|
f7bc6b89c1 | ||
|
|
fabc5e6fcb | ||
|
|
a016d678db | ||
|
|
b6e409badd | ||
|
|
2734504cfc | ||
|
|
eee7967b81 | ||
|
|
1df8f91691 |
@@ -72,6 +72,18 @@ jobs:
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
|
||||
# configure-aws-credentials exports AWS_REGION job-wide and it wins over AWS_DEFAULT_REGION, so pin it here
|
||||
- name: upload binaries to cloudflare r2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_REGION: auto
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
||||
|
||||
nightly-container:
|
||||
runs-on: namespace-profile-gitea-release-docker
|
||||
|
||||
@@ -73,6 +73,18 @@ jobs:
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
|
||||
# configure-aws-credentials exports AWS_REGION job-wide and it wins over AWS_DEFAULT_REGION, so pin it here
|
||||
- name: upload binaries to cloudflare r2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_REGION: auto
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
||||
- name: Install GH CLI
|
||||
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
|
||||
with:
|
||||
|
||||
@@ -76,6 +76,18 @@ jobs:
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
|
||||
# configure-aws-credentials exports AWS_REGION job-wide and it wins over AWS_DEFAULT_REGION, so pin it here
|
||||
- name: upload binaries to cloudflare r2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_REGION: auto
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
||||
- name: Install GH CLI
|
||||
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
|
||||
with:
|
||||
|
||||
+94
-1
@@ -4,13 +4,81 @@ This changelog goes through the changes that have been made in each release
|
||||
without substantial changes to our git log; to see the highlights of what has
|
||||
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
|
||||
## [1.27.0-rc0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0-rc0) - 2026-06-28
|
||||
## [1.27.1](https://github.com/go-gitea/gitea/releases/tag/v1.27.1) - 2026-07-27
|
||||
|
||||
* SECURITY
|
||||
* fix(oauth2): enforce mandatory 2FA policy on OAuth2 authorize/grant endpoints (#38591) (#38606)
|
||||
|
||||
* API
|
||||
* fix(api): align Swagger schemas for UserSettings and TopicListResponse (#38590) (#38592)
|
||||
|
||||
* ENHANCEMENTS
|
||||
* enhance: improve diff contrast in light and dark themes (#37477) (#38574)
|
||||
|
||||
* BUGFIXES
|
||||
* fix: skip OIDC end-session after password login for OAuth2 users (#38439) (#38666)
|
||||
* fix: make Actions log parser support multiple line message encoding (#38659) (#38664)
|
||||
* fix(actions): use base branch ref for pull_request_target context (#38636) (#38657)
|
||||
* fix(actions): skip already-approved runs in `ApproveRuns` (#38653) (#38654)
|
||||
* fix: orgmode render include path (#38642) (#38645)
|
||||
* fix(actions): cancel tasks immediately when the runner stopped reporting (#38616) (#38644)
|
||||
* fix(issues): fix label bulk-load key and reduce log noise in LoadLabel (#38632) (#38643)
|
||||
* fix(actions): improve runner list status sorting, labels and task job links (#38586) (#38633)
|
||||
* fix(actions): correctness and hardening fixes (#38518) (#38631)
|
||||
* fix(repo): prevent double-write redirect collisions on dependency errors, fix ui (#38627) (#38628)
|
||||
* fix: delete repo-scoped rows of seven more tables when deleting a repository (#38534) (#38618)
|
||||
* fix(webhook): remove slack channel name check (#38608) (#38612)
|
||||
* fix: download dropdown menu clipped on the branches page (#38604) (#38609)
|
||||
* fix(project): prevent database mutations on invalid MoveIssues payload (#38600) (#38602)
|
||||
* fix(actions): make SingleWorkflow.Marshal round-trip multi-line run blocks (stop silent job stranding) (#38520) (#38599)
|
||||
* fix(file-tree): handle submodule links and missing view container (#38033) (#38589)
|
||||
* fix(actions): fail unexpandable reusable workflow callers and decouple the job emitter's cross-run processing (#38565) (#38587)
|
||||
* fix: keep serving valid ACME cert when renewal fails at startup (#38554) (#38583)
|
||||
* fix: branch protection user list (#38570) (#38584)
|
||||
* fix(pulls): respect diff.orderFile in diff file tree (#38566) (#38578)
|
||||
* fix(issue): make issue action (issue list batch operation) elements have correct attributes (#38575) (#38580)
|
||||
* fix(actions): support `matrix` when evaluating workflow `if` expression (#38474) (#38557)
|
||||
* fix(actions): align status icon span for Safari rendering (#38558) (#38562)
|
||||
* fix: revert git clone http redirection forbidden (#38530) (#38545)
|
||||
* fix: clean up orphaned user-keyed tables in deleteUser (#38511) (#38514)
|
||||
* fix(actions): coerce workflow_dispatch boolean inputs to native types (#38472) (#38521)
|
||||
* fix: make the merge box button red if some checks fail (#38508) (#38516)
|
||||
* fix(pull): sign the commit when updating a branch by merge (#38441) (#38499)
|
||||
* fix: make commit message merge correctly (#38490) (#38502)
|
||||
* fix(actions): explain why a blocked or waiting job has not started (#38476) (#38498)
|
||||
* fix(actions): make `cancelled()` work in job `if` evaluation (#38495) (#38497)
|
||||
* fix(actions): show retention info on hover for expired artifacts (#38477) (#38493)
|
||||
* fix(actions): group reusable-workflow matrix legs in the workflow graph (#38475) (#38492)
|
||||
* fix: full file highlighting for git diff with CR char (#38484) (#38491)
|
||||
* fix(packages): serve noarch Alpine index for any requested architecture (#38479) (#38486)
|
||||
* fix: 500 error when updating user visibility (#38480) (#38483)
|
||||
* fix(actions): make job list item fully clickable (#38462) (#38471)
|
||||
* fix: mail template for push event (#38467) (#38468)
|
||||
* fix: make "test push webhook" always work (#38425) (#38455)
|
||||
* fix(actions): prevent bulk actions from affecting all runners (#38453) (#38457)
|
||||
* fix(org): align follow button and wrap description (#38448) (#38454)
|
||||
* fix(actions): populate `github.event` for scheduled runs (#38446) (#38452)
|
||||
|
||||
* MISC
|
||||
* refactor: git patch apply (#38637) (#38638)
|
||||
|
||||
## [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0) - 2026-07-13
|
||||
|
||||
* BREAKING
|
||||
* Feat(actions)!: improve support for reusable workflows (#37478)
|
||||
* Use Content-Security-Policy: script nonce (#37232)
|
||||
|
||||
* SECURITY
|
||||
* Fix: various security fixes (#38406) (#38426)
|
||||
* Fix(security): harden access checks and migration validation (#38324) (#38400)
|
||||
* Fix: enforce public-only token scope and harden push options / locale parsing (#38323) (#38399)
|
||||
* Fix(pull): re-evaluate review official flag on target branch change (#38319) (#38402)
|
||||
* Fix(api): stop leaking private repo metadata after access revocation (#38321) (#38390)
|
||||
* Fix(lfs): require proof of possession for cross-repo objects (#38322) (#38389)
|
||||
* Fix(mirror): disable HTTP redirects on pull mirror sync (#38320) (#38367)
|
||||
* Fix: golang html template url escaping (#38363) (#38369)
|
||||
* Fix(release): validate web attachment renames against allowed types (#38314) (#38328)
|
||||
* Fix(release): gate draft release attachments on web download endpoints (#38318) (#38325)
|
||||
* Fix(deps): update module github.com/go-git/go-git/v5 to v5.19.1 [security] (#37786)
|
||||
* Fix(oauth): restrict introspection to the token's client (#38042)
|
||||
* Fix(api): don't expose private org membership via public_members (#38145)
|
||||
@@ -44,6 +112,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* Feat(ssh): auto generate additional ssh keys (#33974)
|
||||
|
||||
* ENHANCEMENTS
|
||||
* Enhance(actions): only create filtered-out workflow commit status for required contexts (#38371) (#38385)
|
||||
* Enhance: allow builtin default git config options to be overridden (#38172)
|
||||
* Enhance: allow MathML core elements (#38034)
|
||||
* Enhance(markup): improve issue title rendering (#37908)
|
||||
@@ -71,12 +140,34 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* Add API endpoint to reply to pull request review comments (#36683)
|
||||
|
||||
* PERFORMANCE
|
||||
* Perf(actions): debounce runner heartbeat writes and throttle task picks (#38281) (#38368)
|
||||
* Perf(web): sort the action_run query by a repo-scoped index when possible (#38155)
|
||||
* Perf: Various performance regression fixes (#38078)
|
||||
* Perf: extend action `c_u` index to include `created_unix` for faster dashboard feeds (#38076)
|
||||
* Batch-load related data in actions run, job, and task API endpoints (#37032)
|
||||
|
||||
* BUGFIXES
|
||||
* Fix(util): reject invalid characters between time-estimate units (#38416) (#38423)
|
||||
* Fix: represent a deleted assignee team as a Ghost team (#38413) (#38419)
|
||||
* Fix(turnstile): route CAPTCHA verification through the configured proxy (#38412) (#38420)
|
||||
* Fix: refresh pull request merge box when the commit status is pending (#38410) (#38411)
|
||||
* Fix: actions task state concurrent update (#38405) (#38409)
|
||||
* Fix(actions): keep workflow run trailing on one row with long branch names (#38382) (#38403)
|
||||
* Fix(web): use locale-aware date formatting for contribution calendar tooltips (#38398) (#38401)
|
||||
* Fix: co-author detection (#38392) (#38397)
|
||||
* Fix: incorrect co-author detection on commit page (#38386) (#38387)
|
||||
* Fix(ui): restore commits table column widths (#38379) (#38383)
|
||||
* Fix: minio init check (#38355) (#38361)
|
||||
* Fix: org project view assignee list (#38357) (#38360)
|
||||
* Fix(actions): release claimed task if context is cancelled during `FetchTask` (#38343) (#38347)
|
||||
* Fix(actions): make runner list pagination order deterministic (#38313) (#38327)
|
||||
* Fix: Improve since/until when counting commits for X-Total-Count (#38243) (#38304)
|
||||
* Fix(actions): prevent chevron overlap with log text when timestamps are enabled (#38227) (#38307)
|
||||
* Fix(workflows): branch protection status checks fail when workflow uses on: paths filter (#38237) (#38302)
|
||||
* Fix(oauth2): persist linkAccountData during auto-link 2FA flow (#38274) (#38295)
|
||||
* Fix(actions): allow Actions bot to push to protected branches (#38284) (#38293)
|
||||
* Fix(actions): include all aggregable run statuses in status filter (#38280) (#38287)
|
||||
* Fix(archiver): use serializable repo-archive queue payload (#38273) (#38283)
|
||||
* Fix: update npm dependencies, fix misc issues (#38257)
|
||||
* Fix(api): respect since/until when counting commits for X-Total-Count (#38204)
|
||||
* Fix: codemirror regressions (#38248)
|
||||
@@ -134,6 +225,8 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
* Refactor flash message and remove SanitizeHTML template func (#37179)
|
||||
|
||||
* TESTING
|
||||
* Test(e2e): fix race in pdf file render test (#38380) (#38381)
|
||||
* Test: compare key file contents instead of `FileInfo` in `TestInitKeys` (#38330) (#38331)
|
||||
* Test: speed up two tests (#37905)
|
||||
* Test: Fix random failure test (#37887)
|
||||
* Test: fix flaky `issue-comment` close test (#37880)
|
||||
|
||||
+15
-3
@@ -6,6 +6,7 @@ package cmd
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -93,10 +94,21 @@ func runACME(listenAddr string, m http.Handler) error {
|
||||
myACME := certmagic.NewACMEIssuer(magic, certmagic.DefaultACME)
|
||||
magic.Issuers = []certmagic.Issuer{myACME}
|
||||
|
||||
// this obtains certificates or renews them if necessary
|
||||
err := magic.ManageSync(graceful.GetManager().HammerContext(), []string{setting.Domain})
|
||||
// Obtain certificates or renew them if necessary. ManageSync fails closed on
|
||||
// renewal errors even when a still-valid certificate is already on disk, which
|
||||
// takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519).
|
||||
// Prefer keeping the existing cert and retrying renewals asynchronously.
|
||||
ctx := graceful.GetManager().ShutdownContext()
|
||||
err := magic.ManageSync(ctx, []string{setting.Domain})
|
||||
if err != nil {
|
||||
return err
|
||||
cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.Domain)
|
||||
if cacheErr != nil || cert.Expired() {
|
||||
return errors.Join(err, cacheErr)
|
||||
}
|
||||
log.Error("ACME certificate manage failed; continuing with existing certificate: %v", err)
|
||||
if err := magic.ManageAsync(ctx, []string{setting.Domain}); err != nil {
|
||||
log.Error("Failed to start async ACME management: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
tlsConfig := magic.TLSConfig()
|
||||
|
||||
@@ -536,6 +536,13 @@ INTERNAL_TOKEN =
|
||||
;; Leave it empty to apply the default policy, or set it to "unset" to disable Content-Security-Policy.
|
||||
;CONTENT_SECURITY_POLICY_GENERAL =
|
||||
|
||||
;; Webhook and oauth2 clients can only call allowed hosts for security reasons. Comma separated list, eg: external, 192.168.1.0/24, *.mydomain.com
|
||||
;; Built-in: loopback (for localhost), private (for LAN/intranet), external (for public hosts on internet), * (for all hosts)
|
||||
;; CIDR list: 1.2.3.0/8, 2001:db8::/32
|
||||
;; Wildcard hosts: *.mydomain.com, 192.168.100.*
|
||||
;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility.
|
||||
;ALLOWED_HOST_LIST = external
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
[camo]
|
||||
@@ -1754,13 +1761,6 @@ LEVEL = Info
|
||||
;; Deliver timeout in seconds
|
||||
;DELIVER_TIMEOUT = 5
|
||||
;;
|
||||
;; Webhook can only call allowed hosts for security reasons. Comma separated list, eg: external, 192.168.1.0/24, *.mydomain.com
|
||||
;; Built-in: loopback (for localhost), private (for LAN/intranet), external (for public hosts on internet), * (for all hosts)
|
||||
;; CIDR list: 1.2.3.0/8, 2001:db8::/32
|
||||
;; Wildcard hosts: *.mydomain.com, 192.168.100.*
|
||||
;; Since 1.15.7. Default to * for 1.15.x, external for 1.16 and later
|
||||
;ALLOWED_HOST_LIST = external
|
||||
;;
|
||||
;; Allow insecure certification
|
||||
;SKIP_TLS_VERIFY = false
|
||||
;;
|
||||
@@ -3008,6 +3008,10 @@ LEVEL = Info
|
||||
;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows
|
||||
;; Maximum number of attempts a single workflow run can have. Default value is 50.
|
||||
;MAX_RERUN_ATTEMPTS = 50
|
||||
;; Maximum number of runners that may run the task-assignment query concurrently, per Gitea instance.
|
||||
;; Caps this instance's DB load when many runners poll at once; excess runners retry on their next poll.
|
||||
;; In a multi-instance deployment the cluster-wide limit is this value times the number of instances. Default value is 16.
|
||||
;MAX_CONCURRENT_TASK_PICKS = 16
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -87,6 +87,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/quasoft/websspi v1.1.2
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
github.com/rhysd/actionlint v1.7.12
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
github.com/sassoftware/go-rpmutils v0.4.0
|
||||
@@ -244,7 +245,6 @@ require (
|
||||
github.com/prometheus/common v0.68.1 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rhysd/actionlint v1.7.12 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
|
||||
@@ -121,7 +121,7 @@ func (run *ActionRun) PrettyRef() string {
|
||||
return refName.ShortName()
|
||||
}
|
||||
|
||||
// RefTooltip return a tooltop of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
|
||||
// RefTooltip return a tooltip of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
|
||||
func (run *ActionRun) RefTooltip() string {
|
||||
payload, err := run.GetPullRequestEventPayload()
|
||||
if err == nil && payload != nil && payload.PullRequest != nil {
|
||||
|
||||
@@ -62,14 +62,16 @@ func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error)
|
||||
attempt.Run = run
|
||||
}
|
||||
|
||||
if attempt.TriggerUser == nil {
|
||||
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return attempt.LoadTriggerUser(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
// LoadTriggerUser loads the attempt's trigger user if not already loaded.
|
||||
func (attempt *ActionRunAttempt) LoadTriggerUser(ctx context.Context) (err error) {
|
||||
if attempt.TriggerUser != nil {
|
||||
return nil
|
||||
}
|
||||
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetRunAttemptByRepoAndID(ctx context.Context, repoID, attemptID int64) (*ActionRunAttempt, error) {
|
||||
|
||||
+83
-47
@@ -333,6 +333,14 @@ func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (A
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// DeleteDirectChildJobsByParent deletes the direct child jobs of a parent job.
|
||||
func DeleteDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) error {
|
||||
_, err := db.GetEngine(ctx).
|
||||
Where("run_id=? AND parent_job_id=?", parentJob.RunID, parentJob.ID).
|
||||
Delete(new(ActionRunJob))
|
||||
return err
|
||||
}
|
||||
|
||||
// CollectAllDescendantJobs returns every job in `allJobs` that lives under parent's subtree (recursively), excluding `parent` itself
|
||||
func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*ActionRunJob {
|
||||
parents := map[int64]bool{parent.ID: true}
|
||||
@@ -440,58 +448,74 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
||||
return affected, RefreshReusableCallerStatus(ctx, parent)
|
||||
}
|
||||
|
||||
{
|
||||
// Other goroutines may aggregate the status of the attempt/run and update it too.
|
||||
// So we need to load the current jobs before updating the aggregate state.
|
||||
if job.RunAttemptID > 0 {
|
||||
attempt, err := GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
attempt.Status = AggregateJobStatus(jobs)
|
||||
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
||||
attempt.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
|
||||
return 0, fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
|
||||
}
|
||||
} else {
|
||||
// TODO: Remove this fallback in the future.
|
||||
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
|
||||
// This path keeps those runs' status consistent when their jobs finish, including:
|
||||
// - jobs created before migration v331 and complete on the new version starts
|
||||
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
|
||||
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, job.RepoID, job.RunID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
run.Status = AggregateJobStatus(jobs)
|
||||
if run.Started.IsZero() && run.Status.IsRunning() {
|
||||
run.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if run.Stopped.IsZero() && run.Status.IsDone() {
|
||||
run.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
|
||||
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
|
||||
}
|
||||
}
|
||||
if err := refreshRunStatus(ctx, job.RepoID, job.RunID, job.RunAttemptID, StatusUnknown); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// refreshRunStatus recomputes the status of an attempt from the jobs currently stored and persists it.
|
||||
// The latest attempt propagates its status to its run, an older one only updates itself.
|
||||
// noJobsStatus settles an attempt without any job, which AggregateJobStatus cannot conclude on its own.
|
||||
func refreshRunStatus(ctx context.Context, repoID, runID, runAttemptID int64, noJobsStatus Status) error {
|
||||
// Other goroutines may aggregate the status of the attempt/run and update it too.
|
||||
// So we need to load the current jobs before updating the aggregate state.
|
||||
if runAttemptID > 0 {
|
||||
attempt, err := GetRunAttemptByRepoAndID(ctx, repoID, runAttemptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, runID, runAttemptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.Status = AggregateJobStatus(jobs)
|
||||
if len(jobs) == 0 {
|
||||
attempt.Status = noJobsStatus
|
||||
}
|
||||
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
||||
attempt.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
|
||||
return fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Remove this fallback in the future.
|
||||
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
|
||||
// This path keeps those runs' status consistent when their jobs finish, including:
|
||||
// - jobs created before migration v331 and complete on the new version starts
|
||||
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
|
||||
// - cancelling a legacy run whose jobs are all already done
|
||||
run, err := GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run.Status = AggregateJobStatus(jobs)
|
||||
if len(jobs) == 0 {
|
||||
run.Status = noJobsStatus
|
||||
}
|
||||
if run.Started.IsZero() && run.Status.IsRunning() {
|
||||
run.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if run.Stopped.IsZero() && run.Status.IsDone() {
|
||||
run.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
|
||||
return fmt.Errorf("update run %d: %w", run.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change.
|
||||
// No-op if caller is not a reusable caller.
|
||||
//
|
||||
@@ -652,6 +676,8 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
||||
return CancelJobs(ctx, jobsToCancel)
|
||||
}
|
||||
|
||||
// CancelJobs cancels every cancellable job it is given. It leaves the status of a run it
|
||||
// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one.
|
||||
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
|
||||
|
||||
@@ -676,6 +702,16 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
|
||||
return cancelledJobs, nil
|
||||
}
|
||||
|
||||
// SettleRunAfterCancel gives a run a final status when cancelling it updated no job at all.
|
||||
// A run's status is otherwise only ever written as a side effect of a job update, so a run whose
|
||||
// jobs are all done already, or that has no job at all, would stay unfinished forever.
|
||||
func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error {
|
||||
if run.Status.IsDone() {
|
||||
return nil
|
||||
}
|
||||
return refreshRunStatus(ctx, run.RepoID, run.ID, run.LatestAttemptID, StatusCancelled)
|
||||
}
|
||||
|
||||
// cancelOneJob cancels a single job and returns the post-cancel row
|
||||
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
|
||||
if job.Status.IsDone() {
|
||||
|
||||
@@ -99,6 +99,10 @@ type FindRunJobOptions struct {
|
||||
UpdatedBefore timeutil.TimeStamp
|
||||
ConcurrencyGroup string
|
||||
OrderBy db.SearchOrderBy
|
||||
// AccessibleRepoIDsSubQuery, when non-nil, restricts results to the repo IDs selected by the
|
||||
// subquery (the caller's accessible repos). A nil value means no restriction. Using a subquery
|
||||
// instead of a materialized ID slice avoids exceeding DB parameter limits for large owners.
|
||||
AccessibleRepoIDsSubQuery *builder.Builder
|
||||
}
|
||||
|
||||
var JobOrderByMap = map[string]map[string]db.SearchOrderBy{
|
||||
@@ -132,6 +136,9 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
|
||||
}
|
||||
cond = cond.And(builder.Eq{"`action_run_job`.concurrency_group": opts.ConcurrencyGroup})
|
||||
}
|
||||
if opts.AccessibleRepoIDsSubQuery != nil {
|
||||
cond = cond.And(builder.In("`action_run_job`.repo_id", opts.AccessibleRepoIDsSubQuery))
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
@@ -152,3 +159,12 @@ func (opts FindRunJobOptions) ToOrders() string {
|
||||
}
|
||||
|
||||
var _ db.FindOptionsOrder = FindRunJobOptions{}
|
||||
|
||||
// CountRunJobsByRunAndAttemptID counts the jobs belonging to the given run attempt.
|
||||
// It is used to enforce MaxJobNumPerRun when reusable-workflow expansion inserts new jobs.
|
||||
func CountRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (int64, error) {
|
||||
return db.Count[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(runAttemptID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -197,3 +198,91 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
|
||||
}
|
||||
|
||||
func TestSettleRunAfterCancel(t *testing.T) {
|
||||
// A run that cancelling updates no job in, because its jobs all reached a final status already
|
||||
// or because it has none at all. Its own row has to be settled explicitly, or the run can never
|
||||
// finish and can never be deleted either.
|
||||
|
||||
newStuckRun := func(t *testing.T, withAttempt, withJob bool) (*ActionRun, []*ActionRunJob) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "stuck-waiting",
|
||||
RepoID: 4,
|
||||
Index: 9801,
|
||||
OwnerID: 1,
|
||||
WorkflowID: "test.yaml",
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: "{}",
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
|
||||
var runAttemptID int64
|
||||
if withAttempt {
|
||||
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusWaiting}
|
||||
require.NoError(t, db.Insert(ctx, attempt))
|
||||
run.LatestAttemptID = attempt.ID
|
||||
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
|
||||
runAttemptID = attempt.ID
|
||||
}
|
||||
|
||||
if !withJob {
|
||||
return run, nil
|
||||
}
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: runAttemptID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "job1",
|
||||
JobID: "job1",
|
||||
Attempt: 1,
|
||||
Status: StatusSuccess,
|
||||
Stopped: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
return run, []*ActionRunJob{job}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
withAttempt bool
|
||||
withJob bool
|
||||
want Status
|
||||
}{
|
||||
{"done job", true, true, StatusSuccess},
|
||||
// Runs created before migration v331 have no attempt, their status lives on the run row itself.
|
||||
{"done job on a legacy run without attempt", false, true, StatusSuccess},
|
||||
// Aggregation cannot reach a final status without any job, so cancelling has to end the run itself.
|
||||
{"no job at all", true, false, StatusCancelled},
|
||||
{"no job at all on a legacy run without attempt", false, false, StatusCancelled},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob)
|
||||
|
||||
// mirrors what the CancelRun service does
|
||||
cancelled, err := CancelJobs(t.Context(), jobs)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly")
|
||||
require.NoError(t, SettleRunAfterCancel(t.Context(), run))
|
||||
|
||||
if tc.withAttempt {
|
||||
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: run.LatestAttemptID})
|
||||
assert.Equal(t, tc.want, gotAttempt.Status)
|
||||
}
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||
assert.Equal(t, tc.want, gotRun.Status)
|
||||
assert.NotZero(t, gotRun.Stopped)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ type FindRunOptions struct {
|
||||
Status []Status
|
||||
ConcurrencyGroup string
|
||||
CommitSHA string
|
||||
// AccessibleRepoIDsSubQuery, when non-nil, restricts results to the repo IDs selected by the
|
||||
// subquery (the caller's accessible repos). A nil value means no restriction. Using a subquery
|
||||
// instead of a materialized ID slice avoids exceeding DB parameter limits for large owners.
|
||||
AccessibleRepoIDsSubQuery *builder.Builder
|
||||
}
|
||||
|
||||
func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
@@ -101,6 +105,9 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
if opts.CommitSHA != "" {
|
||||
cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA})
|
||||
}
|
||||
if opts.AccessibleRepoIDsSubQuery != nil {
|
||||
cond = cond.And(builder.In("`action_run`.repo_id", opts.AccessibleRepoIDsSubQuery))
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
@@ -135,8 +142,8 @@ type StatusInfo struct {
|
||||
|
||||
// GetStatusInfoList returns a slice of StatusInfo
|
||||
func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo {
|
||||
// same as those in aggregateJobStatus
|
||||
allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning, StatusCancelling}
|
||||
// same as those in aggregateJobStatus (StatusUnknown excluded; it's the "shouldn't happen" fallback)
|
||||
allStatus := []Status{StatusSuccess, StatusFailure, StatusCancelled, StatusSkipped, StatusWaiting, StatusRunning, StatusBlocked, StatusCancelling}
|
||||
statusInfoList := make([]StatusInfo, 0, len(allStatus))
|
||||
for _, s := range allStatus {
|
||||
statusInfoList = append(statusInfoList, StatusInfo{
|
||||
|
||||
@@ -73,8 +73,11 @@ func TestGetStatusInfoList(t *testing.T) {
|
||||
assert.Equal(t, []StatusInfo{
|
||||
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
|
||||
{Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"},
|
||||
{Status: int(StatusCancelled), StatusName: StatusCancelled.String(), DisplayedStatus: "actions.status.cancelled"},
|
||||
{Status: int(StatusSkipped), StatusName: StatusSkipped.String(), DisplayedStatus: "actions.status.skipped"},
|
||||
{Status: int(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"},
|
||||
{Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"},
|
||||
{Status: int(StatusBlocked), StatusName: StatusBlocked.String(), DisplayedStatus: "actions.status.blocked"},
|
||||
{Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"},
|
||||
}, statusInfoList)
|
||||
}
|
||||
|
||||
@@ -75,8 +75,28 @@ type ActionRunner struct {
|
||||
const (
|
||||
RunnerOfflineTime = time.Minute
|
||||
RunnerIdleTime = 10 * time.Second
|
||||
// RunnerHeartbeatInterval is how often last_online is persisted on poll.
|
||||
// Must stay well below RunnerOfflineTime so runners don't flap to offline.
|
||||
RunnerHeartbeatInterval = 30 * time.Second
|
||||
// RunnerActiveInterval is how often last_active is persisted while a runner
|
||||
// streams task updates and logs. Must stay well below RunnerIdleTime so a
|
||||
// busy runner keeps showing ACTIVE without a DB write on every RPC.
|
||||
RunnerActiveInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// ShouldPersistLastOnline reports whether last_online is stale enough to be
|
||||
// worth writing back. Avoids a DB write on every runner poll.
|
||||
func ShouldPersistLastOnline(last timeutil.TimeStamp, now time.Time) bool {
|
||||
return now.Sub(last.AsTime()) >= RunnerHeartbeatInterval
|
||||
}
|
||||
|
||||
// ShouldPersistLastActive reports whether last_active is stale enough to be
|
||||
// worth writing back. Avoids a DB write on every UpdateTask/UpdateLog RPC while
|
||||
// a runner is actively streaming logs.
|
||||
func ShouldPersistLastActive(last timeutil.TimeStamp, now time.Time) bool {
|
||||
return now.Sub(last.AsTime()) >= RunnerActiveInterval
|
||||
}
|
||||
|
||||
// BelongsToOwnerName before calling, should guarantee that all attributes are loaded
|
||||
func (r *ActionRunner) BelongsToOwnerName() string {
|
||||
if r.RepoID != 0 {
|
||||
@@ -250,22 +270,41 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
|
||||
return cond
|
||||
}
|
||||
|
||||
// runnerStatusOrderExpr builds an ORDER BY fragment that ranks runners by their
|
||||
// computed status (see ActionRunner.Status): active (0), idle (1), offline (2).
|
||||
// The thresholds are evaluated against the current time, mirroring ToConds, so
|
||||
// sorting by status groups active and idle runners instead of interleaving them
|
||||
// by raw last_online.
|
||||
func runnerStatusOrderExpr() string {
|
||||
now := time.Now()
|
||||
offlineThreshold := now.Add(-RunnerOfflineTime).Unix()
|
||||
idleThreshold := now.Add(-RunnerIdleTime).Unix()
|
||||
return fmt.Sprintf("CASE WHEN last_online <= %d THEN 2 WHEN last_active <= %d THEN 1 ELSE 0 END", offlineThreshold, idleThreshold)
|
||||
}
|
||||
|
||||
func (opts FindRunnerOptions) ToOrders() string {
|
||||
// A unique tiebreaker (id) is appended so that runners sharing the same
|
||||
// status, last_online or name keep a deterministic order across paginated
|
||||
// queries, otherwise the same runner may appear on more than one page.
|
||||
statusRank := runnerStatusOrderExpr()
|
||||
switch opts.Sort {
|
||||
case "online":
|
||||
return "last_online DESC"
|
||||
// Rank by computed status first so idle runners are not interleaved with
|
||||
// active ones; disabled runners sink to the bottom of their status group
|
||||
// (is_disabled ASC), then last_online breaks ties within a group.
|
||||
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
|
||||
case "offline":
|
||||
return "last_online ASC"
|
||||
return statusRank + " DESC, is_disabled ASC, last_online ASC, id ASC"
|
||||
case "alphabetically":
|
||||
return "name ASC"
|
||||
return "name ASC, id ASC"
|
||||
case "reversealphabetically":
|
||||
return "name DESC"
|
||||
return "name DESC, id ASC"
|
||||
case "newest":
|
||||
return "id DESC"
|
||||
case "oldest":
|
||||
return "id ASC"
|
||||
}
|
||||
return "last_online DESC"
|
||||
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
|
||||
}
|
||||
|
||||
// GetRunnerByUUID returns a runner via uuid
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestShouldPersistLastOnline(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
last timeutil.TimeStamp
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh, skip write",
|
||||
last: timeutil.TimeStamp(now.Add(-5 * time.Second).Unix()),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exactly at interval, write",
|
||||
last: timeutil.TimeStamp(now.Add(-RunnerHeartbeatInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stale, write",
|
||||
last: timeutil.TimeStamp(now.Add(-2 * RunnerHeartbeatInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero (never seen), write",
|
||||
last: 0,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ShouldPersistLastOnline(tt.last, now))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPersistLastActive(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
last timeutil.TimeStamp
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh, skip write",
|
||||
last: timeutil.TimeStamp(now.Add(-1 * time.Second).Unix()),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exactly at interval, write",
|
||||
last: timeutil.TimeStamp(now.Add(-RunnerActiveInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stale, write",
|
||||
last: timeutil.TimeStamp(now.Add(-2 * RunnerActiveInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero (never seen), write",
|
||||
last: 0,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ShouldPersistLastActive(tt.last, now))
|
||||
})
|
||||
}
|
||||
}
|
||||
+102
-38
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -59,6 +60,12 @@ type ActionTask struct {
|
||||
Updated timeutil.TimeStamp `xorm:"updated index"`
|
||||
}
|
||||
|
||||
// taskReportTimeout is how long a task may go without contact from its runner before the
|
||||
// runner is assumed gone. Runners report state and stream logs every few seconds, both of
|
||||
// which refresh ActionTask.Updated. Shorter than setting.Actions.ZombieTaskTimeout because
|
||||
// it only decides whether the runner is reachable, not whether the task should be killed.
|
||||
const taskReportTimeout = time.Minute
|
||||
|
||||
var successfulTokenTaskCache *lru.Cache[string, any]
|
||||
|
||||
func init() {
|
||||
@@ -84,11 +91,15 @@ func (task *ActionTask) IsStopped() bool {
|
||||
return task.Stopped > 0
|
||||
}
|
||||
|
||||
func (task *ActionTask) GetRunLink() string {
|
||||
if task.Job == nil || task.Job.Run == nil {
|
||||
func (task *ActionTask) GetRunJobLink() string {
|
||||
// Run.Repo can be nil when the repository was deleted while task/run rows remain
|
||||
// (TaskList.LoadAttributes copies job.Repo into run.Repo, leaving it nil on a miss).
|
||||
// Run.Link() already returns "" in that case, so guard here to avoid emitting a
|
||||
// broken relative "/jobs/N" link from the Sprintf below.
|
||||
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
|
||||
return ""
|
||||
}
|
||||
return task.Job.Run.Link()
|
||||
return fmt.Sprintf("%s/jobs/%d", task.Job.Run.Link(), task.Job.ID)
|
||||
}
|
||||
|
||||
func (task *ActionTask) GetCommitLink() string {
|
||||
@@ -231,6 +242,11 @@ func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
||||
// another runner won the optimistic-lock race; it is never returned to callers.
|
||||
var errJobAlreadyClaimed = errors.New("job already claimed by another runner")
|
||||
|
||||
// pickTaskBatchSize bounds how many waiting jobs each CreateTaskForRunner query loads,
|
||||
// so a large backlog is not fetched into memory on every runner poll.
|
||||
// It is a var only so tests can shrink it to exercise pagination cheaply.
|
||||
var pickTaskBatchSize = 100
|
||||
|
||||
// CreateTaskForRunner finds a waiting job that matches the runner's labels and
|
||||
// atomically claims it. It iterates through all matching jobs so that a
|
||||
// concurrent claim by another runner (which would lose the optimistic lock on
|
||||
@@ -249,31 +265,51 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
|
||||
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
|
||||
}
|
||||
if jobCond.IsValid() {
|
||||
jobCond = builder.In("run_id", builder.Select("id").From("action_run").Where(jobCond))
|
||||
}
|
||||
|
||||
var jobs []*ActionRunJob
|
||||
if err := e.Where("task_id=? AND status=? AND is_reusable_caller=?", 0, StatusWaiting, false).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
|
||||
|
||||
// TODO: a more efficient way to filter labels
|
||||
log.Trace("runner labels: %v", runner.AgentLabels)
|
||||
for _, v := range jobs {
|
||||
if !runner.CanMatchLabels(v.RunsOn) {
|
||||
continue
|
||||
|
||||
// Page through the waiting jobs oldest-first instead of loading the whole backlog into memory on every poll.
|
||||
// Keyset pagination on (updated, id) is safe under concurrent claims:
|
||||
// updated only moves forward, so the advancing cursor never skips a still-waiting job even as claimed jobs drop out.
|
||||
var cursorUpdated timeutil.TimeStamp
|
||||
var cursorID int64
|
||||
for {
|
||||
cond := baseCond
|
||||
if cursorID > 0 {
|
||||
cond = cond.And(builder.Or(
|
||||
builder.Gt{"updated": cursorUpdated},
|
||||
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
|
||||
))
|
||||
}
|
||||
task, ok, err := claimJobForRunner(ctx, runner, v)
|
||||
if err != nil {
|
||||
|
||||
var jobs []*ActionRunJob
|
||||
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ok {
|
||||
return task, true, nil
|
||||
|
||||
for _, v := range jobs {
|
||||
if !runner.CanMatchLabels(v.RunsOn) {
|
||||
continue
|
||||
}
|
||||
task, ok, err := claimJobForRunner(ctx, runner, v)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ok {
|
||||
return task, true, nil
|
||||
}
|
||||
// Another runner claimed this job concurrently; try the next one.
|
||||
}
|
||||
// Another runner claimed this job concurrently; try the next one.
|
||||
|
||||
// A short page means no waiting jobs remain beyond it.
|
||||
if len(jobs) < pickTaskBatchSize {
|
||||
return nil, false, nil
|
||||
}
|
||||
last := jobs[len(jobs)-1]
|
||||
cursorUpdated, cursorID = last.Updated, last.ID
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// claimJobForRunner attempts to atomically claim job for runner inside its own
|
||||
@@ -413,6 +449,18 @@ func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func getRunIDByTaskID(ctx context.Context, taskID int64) (runID int64, _ error) {
|
||||
if has, err := db.GetEngine(ctx).Cols("action_run_job.run_id").
|
||||
Table("action_task").
|
||||
Join("INNER", "action_run_job", "action_run_job.id = action_task.job_id").
|
||||
Where(builder.Eq{"action_task.id": taskID}).Get(&runID); err != nil {
|
||||
return runID, err
|
||||
} else if !has {
|
||||
return runID, util.ErrNotExist
|
||||
}
|
||||
return runID, nil
|
||||
}
|
||||
|
||||
// UpdateTaskByState updates the task by the state.
|
||||
// It will always update the task if the state is not final, even there is no change.
|
||||
// So it will update ActionTask.Updated to avoid the task being judged as a zombie task.
|
||||
@@ -422,21 +470,26 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
stepStates[v.Id] = v
|
||||
}
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*ActionTask, error) {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
task := &ActionTask{}
|
||||
if has, err := e.ID(state.Id).Get(task); err != nil {
|
||||
return nil, err
|
||||
// Only one request can update the task because the final state needs to be calculated with all job states.
|
||||
// Otherwise, concurrent requests with transaction will make the SQL read stale job state and result in wrong final state.
|
||||
taskID := state.Id
|
||||
runID, err := getRunIDByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &ActionTask{}
|
||||
applyState := func(ctx context.Context) error {
|
||||
if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil {
|
||||
return err
|
||||
} else if !has {
|
||||
return nil, util.ErrNotExist
|
||||
return util.ErrNotExist
|
||||
} else if runnerID != task.RunnerID {
|
||||
return nil, errors.New("invalid runner for task")
|
||||
return errors.New("invalid runner for task")
|
||||
}
|
||||
|
||||
if task.Status.IsDone() {
|
||||
// the state is final, do nothing
|
||||
return task, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// state.Result is not unspecified means the task is finished
|
||||
@@ -449,7 +502,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
}
|
||||
task.Stopped = timeutil.TimeStamp(state.StoppedAt.AsTime().Unix())
|
||||
if err := UpdateTask(ctx, task, "status", "stopped"); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if _, err := UpdateRunJob(ctx, &ActionRunJob{
|
||||
ID: task.JobID,
|
||||
@@ -457,18 +510,18 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
Status: task.Status,
|
||||
Stopped: task.Stopped,
|
||||
}, nil, "status", "stopped"); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Force update ActionTask.Updated to avoid the task being judged as a zombie task
|
||||
task.Updated = timeutil.TimeStampNow()
|
||||
if err := UpdateTask(ctx, task, "updated"); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := task.LoadAttributes(ctx); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
for _, step := range task.Steps {
|
||||
@@ -485,13 +538,17 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
} else if step.Started != 0 {
|
||||
step.Status = StatusRunning
|
||||
}
|
||||
if _, err := e.ID(step.ID).Update(step); err != nil {
|
||||
return nil, err
|
||||
if _, err := db.GetEngine(ctx).ID(step.ID).Update(step); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return task, nil
|
||||
return nil
|
||||
}
|
||||
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
|
||||
// A half-written report leaves the task done with a running job, which no retry repairs.
|
||||
return db.WithTx(ctx, applyState)
|
||||
})
|
||||
return task, err
|
||||
}
|
||||
|
||||
func StopTask(ctx context.Context, taskID int64, status Status) error {
|
||||
@@ -520,6 +577,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
|
||||
status = StatusCancelled
|
||||
} else if !runner.HasCancellingSupport {
|
||||
status = StatusCancelled
|
||||
} else if task.Updated.AddDuration(taskReportTimeout) < now {
|
||||
// A runner that stopped reporting will never acknowledge the cancellation either,
|
||||
// so skip the handshake instead of waiting for the zombie task cleanup.
|
||||
status = StatusCancelled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,7 +595,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return UpdateTask(ctx, task, "status")
|
||||
// NoAutoTime keeps "updated" at the runner's last contact: re-cancelling an already
|
||||
// cancelling task must not defer the timeout above or the zombie task cleanup.
|
||||
_, err := e.ID(task.ID).Cols("status").NoAutoTime().Update(task)
|
||||
return err
|
||||
}
|
||||
|
||||
task.Status = status
|
||||
|
||||
+248
-171
@@ -4,11 +4,15 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -16,8 +20,24 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"xorm.io/xorm/contexts"
|
||||
)
|
||||
|
||||
func TestActionTask_GetRunJobLink(t *testing.T) {
|
||||
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
|
||||
run := &ActionRun{ID: 10, Repo: repo}
|
||||
job := &ActionRunJob{ID: 42, Run: run}
|
||||
|
||||
// a task with a loaded job links to that specific job, not just the run
|
||||
task := &ActionTask{Job: job}
|
||||
assert.Equal(t, run.Link()+"/jobs/42", task.GetRunJobLink())
|
||||
|
||||
// missing job, run or repo yields an empty link instead of a broken URL
|
||||
assert.Empty(t, (&ActionTask{}).GetRunJobLink())
|
||||
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42}}).GetRunJobLink())
|
||||
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42, Run: &ActionRun{ID: 10}}}).GetRunJobLink())
|
||||
}
|
||||
|
||||
func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -83,68 +103,11 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
|
||||
newRunningTask := func(t *testing.T) (*ActionTask, *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "cancelling-finalization-job",
|
||||
Attempt: 1,
|
||||
JobID: "cancelling-finalization-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-cancelling-supported",
|
||||
Name: "runner-cancelling-supported",
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
return task, job
|
||||
}
|
||||
|
||||
testResult := func(t *testing.T, result runnerv1.Result) {
|
||||
t.Helper()
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
task, job := newRunningTask(t)
|
||||
task, job := newRunningTaskForCancelling(t, "cancelling-finalization-job", true)
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
@@ -174,137 +137,92 @@ func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopTaskCancellingFallsBackForLegacyRunner(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// TestStopTaskCancellingFallsBackToCancelled covers the cases where the cancelling handshake can
|
||||
// never complete, so StopTask must cancel right away instead of waiting for the zombie task cleanup.
|
||||
func TestStopTaskCancellingFallsBackToCancelled(t *testing.T) {
|
||||
assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "legacy-cancelling-job",
|
||||
Attempt: 1,
|
||||
JobID: "legacy-cancelling-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
// A runner too old to know the cancelling state can only be stopped by a final status.
|
||||
t.Run("legacy runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "legacy-cancelling-job", false)
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-legacy-no-cancelling",
|
||||
Name: "runner-legacy-no-cancelling",
|
||||
HasCancellingSupport: false,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
// The runner is gone, e.g. an ephemeral runner was cleaned up, so nobody can acknowledge.
|
||||
t.Run("missing runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "missing-runner-cancelling-job", true)
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
_, err := db.DeleteByID[ActionRunner](t.Context(), task.RunnerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
// The runner went silent, e.g. it gave up while Gitea was restarting, so it never picks up the request.
|
||||
t.Run("silent runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "silent-runner-cancelling-job", true)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
// NoAutoTime because the point of the test is an "updated" older than xorm would write
|
||||
task.Updated = timeutil.TimeStampNow().AddDuration(-2 * taskReportTimeout)
|
||||
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
|
||||
// A runner coming back still learns the outcome from the UpdateTask response,
|
||||
// and its late result does not overwrite the cancellation.
|
||||
late, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{
|
||||
Id: task.ID,
|
||||
Result: runnerv1.Result_RESULT_SUCCESS,
|
||||
StoppedAt: timestamppb.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusCancelled, late.Status)
|
||||
assert.Equal(t, runnerv1.Result_RESULT_CANCELLED, late.Status.AsResult())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopTaskCancellingFallsBackForMissingRunner(t *testing.T) {
|
||||
// TestStopTaskCancellingKeepsReportTime makes sure persisting the cancelling status does not refresh
|
||||
// "updated": re-cancelling would otherwise defer both the fallback to cancelled and the zombie task
|
||||
// cleanup, no matter how long the runner has been silent.
|
||||
func TestStopTaskCancellingKeepsReportTime(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, _ := newRunningTaskForCancelling(t, "repeated-cancelling-job", true)
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "missing-runner-cancelling-job",
|
||||
Attempt: 1,
|
||||
JobID: "missing-runner-cancelling-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-cleaned-up-before-cancel",
|
||||
Name: "runner-cleaned-up-before-cancel",
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.DeleteByID[ActionRunner](t.Context(), runner.ID)
|
||||
// NoAutoTime because the point of the test is an "updated" older than xorm would write
|
||||
lastReport := timeutil.TimeStampNow().AddDuration(-taskReportTimeout / 2)
|
||||
task.Updated = lastReport
|
||||
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
// the runner reported recently enough, so the task waits for it to acknowledge
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
assert.Equal(t, StatusCancelling, taskAfterStop.Status)
|
||||
assert.Equal(t, lastReport, taskAfterStop.Updated)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
// cancelling it again does not reset the clock either
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
taskAfterSecondStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelling, taskAfterSecondStop.Status)
|
||||
assert.Equal(t, lastReport, taskAfterSecondStop.Updated)
|
||||
}
|
||||
|
||||
// TestReleaseTaskForRunner verifies that releasing a freshly-claimed task returns
|
||||
@@ -371,3 +289,162 @@ func TestReleaseTaskForRunner(t *testing.T) {
|
||||
unittest.AssertNotExistsBean(t, &ActionTask{ID: task.ID})
|
||||
unittest.AssertNotExistsBean(t, &ActionTaskStep{TaskID: task.ID})
|
||||
}
|
||||
|
||||
// TestCreateTaskForRunnerPagination verifies that a job sitting beyond the first page is still claimed
|
||||
func TestCreateTaskForRunnerPagination(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
defer func(orig int) { pickTaskBatchSize = orig }(pickTaskBatchSize)
|
||||
pickTaskBatchSize = 2
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "pagination-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 9903,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
// Five waiting jobs the runner cannot run, then one it can.
|
||||
// With a page size of 2 the matching job only appears on the third page.
|
||||
for i := range 5 {
|
||||
mismatch := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "mismatch-" + string(rune('a'+i)),
|
||||
Attempt: 1,
|
||||
JobID: "mismatch-" + string(rune('a'+i)),
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"windows-latest"},
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), mismatch))
|
||||
}
|
||||
|
||||
target := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "target-job",
|
||||
Attempt: 1,
|
||||
JobID: "target-job",
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
WorkflowPayload: []byte("on: push\njobs:\n target-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), target))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "pagination-runner-uuid",
|
||||
Name: "pagination-runner",
|
||||
AgentLabels: []string{"ubuntu-latest"},
|
||||
}
|
||||
runner.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, task)
|
||||
|
||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
|
||||
assert.Equal(t, StatusRunning, claimed.Status)
|
||||
assert.Equal(t, task.ID, claimed.TaskID)
|
||||
}
|
||||
|
||||
type failFirstStepWrite struct{ fired atomic.Bool }
|
||||
|
||||
func (h *failFirstStepWrite) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
|
||||
if !h.fired.Load() && strings.HasPrefix(c.SQL, "UPDATE") && strings.Contains(c.SQL, "action_task_step") {
|
||||
h.fired.Store(true)
|
||||
return nil, errors.New("interrupted")
|
||||
}
|
||||
return c.Ctx, nil
|
||||
}
|
||||
|
||||
func (*failFirstStepWrite) AfterProcess(*contexts.ContextHook) error { return nil }
|
||||
|
||||
// TestUpdateTaskByStateIsAtomic checks that an interrupted report writes nothing: a surviving task or
|
||||
// job write would hit the "state is final" early return, which no retry or cleanup repairs.
|
||||
func TestUpdateTaskByStateIsAtomic(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "atomic-report-job", true)
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionTaskStep{TaskID: task.ID, RepoID: task.RepoID, Status: StatusRunning}))
|
||||
unittest.GetXORMEngine().AddHook(&failFirstStepWrite{})
|
||||
finalState := &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_SUCCESS, StoppedAt: timestamppb.Now()}
|
||||
_, err := UpdateTaskByState(t.Context(), task.RunnerID, finalState)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID}).Status)
|
||||
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
|
||||
_, err = UpdateTaskByState(t.Context(), task.RunnerID, finalState)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusSuccess, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
|
||||
}
|
||||
|
||||
// newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner,
|
||||
// which is the state every cancellation test starts from.
|
||||
func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*ActionTask, *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: name,
|
||||
Attempt: 1,
|
||||
JobID: name,
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: name,
|
||||
Name: name,
|
||||
HasCancellingSupport: hasCancellingSupport,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
RunnerID: runner.ID,
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
return task, job
|
||||
}
|
||||
|
||||
@@ -36,7 +36,18 @@ import (
|
||||
|
||||
const ssh2keyStart = "---- BEGIN SSH2 PUBLIC KEY ----"
|
||||
|
||||
const (
|
||||
// the longest RSA key ssh-keygen allows to generate is 16384 bits (2048 bytes), we still relax the limit a little here
|
||||
maxKeyBinaryBytes = 4096
|
||||
maxKeyContentBase64Bytes = maxKeyBinaryBytes * 4 / 3
|
||||
maxKeyContentExtraBytes = 4 * 1024 // header, footer, comment
|
||||
maxKeyContentBytes = maxKeyContentBase64Bytes + maxKeyContentExtraBytes
|
||||
)
|
||||
|
||||
func extractTypeFromBase64Key(key string) (string, error) {
|
||||
if len(key) > maxKeyContentBase64Bytes {
|
||||
return "", util.NewInvalidArgumentErrorf("SSH public key base64 is too long")
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil || len(b) < 4 {
|
||||
return "", fmt.Errorf("invalid key format: %w", err)
|
||||
@@ -52,6 +63,10 @@ func extractTypeFromBase64Key(key string) (string, error) {
|
||||
|
||||
// parseKeyString parses any key string in OpenSSH or SSH2 format to clean OpenSSH string (RFC4253).
|
||||
func parseKeyString(content string) (string, error) {
|
||||
if len(content) > maxKeyContentBytes {
|
||||
return "", util.NewInvalidArgumentErrorf("SSH public key content is too long")
|
||||
}
|
||||
|
||||
// remove whitespace at start and end
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
@@ -63,6 +78,8 @@ func parseKeyString(content string) (string, error) {
|
||||
// Transform all legal line endings to a single "\n".
|
||||
content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(content))
|
||||
lines := strings.Split(content, "\n")
|
||||
continuationLine := false
|
||||
|
||||
@@ -74,9 +91,10 @@ func parseKeyString(content string) (string, error) {
|
||||
if continuationLine || strings.ContainsAny(line, ":-") {
|
||||
continuationLine = strings.HasSuffix(line, "\\")
|
||||
} else {
|
||||
keyContent += line
|
||||
b.WriteString(line)
|
||||
}
|
||||
}
|
||||
keyContent = b.String()
|
||||
|
||||
t, err := extractTypeFromBase64Key(keyContent)
|
||||
if err != nil {
|
||||
|
||||
@@ -473,10 +473,20 @@ func runErr(t *testing.T, stdin []byte, args ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_PublicKeysAreExternallyManaged(t *testing.T) {
|
||||
func TestPublicKeysAreExternallyManaged(t *testing.T) {
|
||||
key1 := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1})
|
||||
externals, err := PublicKeysAreExternallyManaged(t.Context(), []*PublicKey{key1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, externals, 1)
|
||||
assert.False(t, externals[0])
|
||||
}
|
||||
|
||||
// TestCheckPublicKeyStringOversized tests if oversized SSH2 public key strings are rejected before triggering costly operations.
|
||||
func TestCheckPublicKeyStringOversized(t *testing.T) {
|
||||
_, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1))
|
||||
assert.ErrorContains(t, err, "SSH public key content is too long")
|
||||
|
||||
content := "---- BEGIN SSH2 PUBLIC KEY ----\n" + strings.Repeat("a", maxKeyContentBase64Bytes+1) + "\n--- END SSH2 PUBLIC KEY ----"
|
||||
_, err = parseKeyString(content)
|
||||
assert.ErrorContains(t, err, "SSH public key base64 is too long")
|
||||
}
|
||||
|
||||
@@ -304,6 +304,36 @@ func (s AccessTokenScope) PublicOnly() (bool, error) {
|
||||
return bitmap.hasScope(AccessTokenScopePublicOnly)
|
||||
}
|
||||
|
||||
// CanCreateChildScope reports whether a request authenticated by this (parent) scope may mint a token
|
||||
// carrying the child scope. It rejects any grantable scope the parent does not hold, closing the
|
||||
// scope-escalation path. public-only is a restriction rather than a grantable permission, so it is
|
||||
// ignored here (a child may always be public-only); EnforcePublicOnlyFrom handles carrying it down.
|
||||
func (s AccessTokenScope) CanCreateChildScope(child AccessTokenScope) (bool, error) {
|
||||
requested := child.StringSlice()
|
||||
scopes := make([]AccessTokenScope, 0, len(requested))
|
||||
for _, sc := range requested {
|
||||
childScope := AccessTokenScope(sc)
|
||||
if childScope == AccessTokenScopePublicOnly {
|
||||
continue
|
||||
}
|
||||
scopes = append(scopes, childScope)
|
||||
}
|
||||
return s.HasScope(scopes...)
|
||||
}
|
||||
|
||||
// EnforcePublicOnlyFrom adds the public-only restriction to s when the authorizing parent scope is
|
||||
// public-only, so a public-only token cannot mint a child token that drops the restriction.
|
||||
func (s AccessTokenScope) EnforcePublicOnlyFrom(parent AccessTokenScope) (AccessTokenScope, error) {
|
||||
publicOnly, err := parent.PublicOnly()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !publicOnly {
|
||||
return s, nil
|
||||
}
|
||||
return AccessTokenScope(string(s) + "," + string(AccessTokenScopePublicOnly)).Normalize()
|
||||
}
|
||||
|
||||
// HasScope returns true if the string has the given scope
|
||||
func (s AccessTokenScope) HasScope(scopes ...AccessTokenScope) (bool, error) {
|
||||
bitmap, err := s.parse()
|
||||
|
||||
@@ -89,3 +89,26 @@ func TestAccessTokenScope_HasScope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTokenScope_EnforcePublicOnlyFrom(t *testing.T) {
|
||||
tests := []struct {
|
||||
in AccessTokenScope
|
||||
parent AccessTokenScope
|
||||
out AccessTokenScope
|
||||
}{
|
||||
// public-only parent forces the restriction onto the minted scope
|
||||
{"write:user", "write:user,public-only", "public-only,write:user"},
|
||||
// already public-only stays public-only
|
||||
{"public-only,read:user", "public-only", "public-only,read:user"},
|
||||
// non-public-only parent leaves the scope untouched
|
||||
{"write:user", "write:user", "write:user"},
|
||||
{"all", "all", "all"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(string(test.parent)+"->"+string(test.in), func(t *testing.T) {
|
||||
got, err := test.in.EnforcePublicOnlyFrom(test.parent)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, test.out, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ func updateApprovalWhitelist(ctx context.Context, repo *repo_model.Repository, c
|
||||
return currentWhitelist, nil
|
||||
}
|
||||
|
||||
prUserIDs, err := access_model.GetUserIDsWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
|
||||
prUserIDs, err := access_model.GetUserIDsWithAnyUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
@@ -33,11 +34,18 @@ func BuildAvatarStackData(ctx context.Context, allParticipants []*git.CommitIden
|
||||
ret := &AvatarStackData{
|
||||
Participants: make([]*CommitParticipant, 0, len(allParticipants)),
|
||||
}
|
||||
uniqueUserIDs := make(container.Set[int64])
|
||||
for _, p := range allParticipants {
|
||||
var giteaUser *user.User
|
||||
if emailUserMap != nil {
|
||||
giteaUser = emailUserMap.GetByEmail(p.Email)
|
||||
}
|
||||
if giteaUser != nil {
|
||||
// identities without a Gitea account can only be compared by their git identity
|
||||
if !uniqueUserIDs.Add(giteaUser.ID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
ret.Participants = append(ret.Participants, &CommitParticipant{GiteaUser: giteaUser, GitIdentity: p})
|
||||
}
|
||||
return ret
|
||||
|
||||
@@ -39,8 +39,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
|
||||
emailSet := make(container.Set[string])
|
||||
for _, c := range gitCommits {
|
||||
emailSet.Add(c.Author.Email)
|
||||
emailSet.Add(c.Committer.Email)
|
||||
for _, p := range c.AllParticipantIdentities() {
|
||||
for _, p := range c.AllAuthorIdentities() {
|
||||
emailSet.Add(p.Email)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +54,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
|
||||
uc := &UserCommit{
|
||||
AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
|
||||
GitCommit: c,
|
||||
AvatarStackData: BuildAvatarStackData(ctx, c.AllParticipantIdentities(), emailUserMap),
|
||||
AvatarStackData: BuildAvatarStackData(ctx, c.AllAuthorIdentities(), emailUserMap),
|
||||
}
|
||||
uc.AvatarStackData.SearchByEmailLink = searchByEmailLink
|
||||
userCommits = append(userCommits, uc)
|
||||
|
||||
+30
-32
@@ -27,6 +27,7 @@ import (
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/references"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
@@ -543,6 +544,9 @@ func (c *Comment) GetSanitizedContentHTML() template.HTML {
|
||||
|
||||
// LoadLabel if comment.Type is CommentTypeLabel, then load Label
|
||||
func (c *Comment) LoadLabel(ctx context.Context) error {
|
||||
if c.LabelID == 0 {
|
||||
return nil
|
||||
}
|
||||
var label Label
|
||||
has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label)
|
||||
if err != nil {
|
||||
@@ -550,8 +554,8 @@ func (c *Comment) LoadLabel(ctx context.Context) error {
|
||||
} else if has {
|
||||
c.Label = &label
|
||||
} else {
|
||||
// Ignore Label is deleted, but not clear this table
|
||||
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID)
|
||||
// label was deleted but comment rows referencing it were not cleaned up
|
||||
log.Debug("Comment %d references deleted label %d", c.ID, c.LabelID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -626,11 +630,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
|
||||
return nil
|
||||
}
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
issue, err := GetIssueByID(ctx, c.IssueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
|
||||
}
|
||||
for i := range attachments {
|
||||
if err := validateAttachmentForIssue(ctx, issue, attachments[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
attachments[i].IssueID = c.IssueID
|
||||
attachments[i].CommentID = c.ID
|
||||
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
|
||||
@@ -643,36 +654,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
|
||||
}
|
||||
|
||||
// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees
|
||||
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) (err error) {
|
||||
if c.AssigneeID > 0 && c.Assignee == nil {
|
||||
c.Assignee, err = user_model.GetUserByID(ctx, c.AssigneeID)
|
||||
_, c.Assignee, err = user_model.GetPossibleUserByID(ctx, c.AssigneeID)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
return err
|
||||
}
|
||||
c.Assignee = user_model.NewGhostUser()
|
||||
}
|
||||
} else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
|
||||
if err = c.LoadIssue(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Issue.LoadRepo(ctx); err != nil {
|
||||
}
|
||||
if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
|
||||
_, c.AssigneeTeam, err = organization.GetPossibleTeamByID(ctx, c.AssigneeTeamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Issue.Repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Issue.Repo.Owner.IsOrganization() {
|
||||
c.AssigneeTeam, err = organization.GetTeamByID(ctx, c.AssigneeTeamID)
|
||||
if err != nil && !organization.IsErrTeamNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -795,8 +788,7 @@ func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML {
|
||||
}
|
||||
|
||||
func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML {
|
||||
if c.AssigneeID > 0 {
|
||||
// it guarantees LoadAssigneeUserAndTeam has been called, and c.Assignee is Ghost user but not nil if the user doesn't exist
|
||||
if c.Assignee != nil {
|
||||
if c.RemovedAssignee {
|
||||
if c.PosterID == c.AssigneeID {
|
||||
return locale.Tr("repo.issues.review.remove_review_request_self", createdStr)
|
||||
@@ -805,14 +797,20 @@ func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdSt
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", c.Assignee.GetDisplayName(), createdStr)
|
||||
}
|
||||
teamName := "Ghost Team"
|
||||
if c.AssigneeTeam != nil {
|
||||
teamName = c.AssigneeTeam.Name
|
||||
if c.RemovedAssignee {
|
||||
return locale.Tr("repo.issues.review.remove_review_request", c.AssigneeTeam.Name, createdStr)
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", c.AssigneeTeam.Name, createdStr)
|
||||
}
|
||||
|
||||
// impossible fallback
|
||||
assigneePrompt := fmt.Sprintf("(AssigneeID=%d, AssigneeTeamID=%d)", c.AssigneeID, c.AssigneeTeam.ID)
|
||||
setting.PanicInDevOrTesting("unknown timeline pull request review event comment: id=%d, %s", c.ID, assigneePrompt)
|
||||
if c.RemovedAssignee {
|
||||
return locale.Tr("repo.issues.review.remove_review_request", teamName, createdStr)
|
||||
return locale.Tr("repo.issues.review.remove_review_request", assigneePrompt, createdStr)
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", teamName, createdStr)
|
||||
return locale.Tr("repo.issues.review.add_review_request", assigneePrompt, createdStr)
|
||||
}
|
||||
|
||||
// CreateComment creates comment with context
|
||||
|
||||
@@ -81,7 +81,7 @@ func (comments CommentList) loadLabels(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
comment.Label = commentLabels[comment.ID]
|
||||
comment.Label = commentLabels[comment.LabelID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,12 +45,27 @@ func TestCreateComment(t *testing.T) {
|
||||
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
|
||||
}
|
||||
|
||||
func TestLoadAssigneeUserAndTeam_DeletedTeamBecomesGhostTeam(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 15})
|
||||
comment := &issues_model.Comment{
|
||||
Type: issues_model.CommentTypeAssignees,
|
||||
IssueID: issue.ID,
|
||||
AssigneeTeamID: 999999, // non-existing team ID
|
||||
}
|
||||
assert.NoError(t, comment.LoadAssigneeUserAndTeam(t.Context()))
|
||||
assert.NotNil(t, comment.AssigneeTeam)
|
||||
assert.EqualValues(t, -1, comment.AssigneeTeam.ID)
|
||||
}
|
||||
|
||||
func Test_UpdateCommentAttachment(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
|
||||
attachment := repo_model.Attachment{
|
||||
Name: "test.txt",
|
||||
RepoID: issue.RepoID, // must match the comment's repo, else the cross-repo guard rejects it
|
||||
Name: "test.txt",
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), &attachment))
|
||||
|
||||
|
||||
@@ -263,14 +263,46 @@ func AddDeletePRBranchComment(ctx context.Context, doer *user_model.User, repo *
|
||||
return err
|
||||
}
|
||||
|
||||
// validateAttachmentForIssue rejects a foreign or already-linked attachment before it is linked to
|
||||
// issue: a known UUID could otherwise re-link (and expose) another repo's private attachment. A
|
||||
// legacy attachment predating repo_id-on-upload is adopted into the issue's repo.
|
||||
func validateAttachmentForIssue(ctx context.Context, issue *Issue, attachment *repo_model.Attachment) error {
|
||||
if attachment.RepoID == 0 && attachment.CreatedUnix < repo_model.LegacyAttachmentMissingRepoIDCutoff {
|
||||
attachment.RepoID = issue.RepoID
|
||||
if err := repo_model.UpdateAttachmentByUUID(ctx, attachment, "repo_id"); err != nil {
|
||||
return fmt.Errorf("update attachment repo_id [id: %d]: %w", attachment.ID, err)
|
||||
}
|
||||
}
|
||||
if attachment.RepoID != issue.RepoID {
|
||||
return util.NewPermissionDeniedErrorf("attachment belongs to a different repository")
|
||||
}
|
||||
if attachment.IssueID != 0 && attachment.IssueID != issue.ID {
|
||||
return util.NewPermissionDeniedErrorf("attachment is already linked to another issue")
|
||||
}
|
||||
if attachment.ReleaseID != 0 {
|
||||
return util.NewPermissionDeniedErrorf("attachment is already linked to a release")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateIssueAttachments update attachments by UUIDs for the issue
|
||||
func UpdateIssueAttachments(ctx context.Context, issueID int64, uuids []string) (err error) {
|
||||
if len(uuids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
issue, err := GetIssueByID(ctx, issueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
|
||||
}
|
||||
for i := range attachments {
|
||||
if err := validateAttachmentForIssue(ctx, issue, attachments[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
attachments[i].IssueID = issueID
|
||||
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
|
||||
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateIssueAttachmentsCrossRepo(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// attachment id 2 belongs to repo 2 / issue 4; issue 1 lives in repo 1
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
foreign := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 2})
|
||||
require.NotEqual(t, issue1.RepoID, foreign.RepoID)
|
||||
|
||||
// re-linking a foreign repo's attachment by UUID must be rejected
|
||||
err := issues_model.UpdateIssueAttachments(t.Context(), issue1.ID, []string{foreign.UUID})
|
||||
assert.ErrorIs(t, err, util.ErrPermissionDenied)
|
||||
|
||||
// the foreign attachment must be left untouched
|
||||
reloaded := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 2})
|
||||
assert.Equal(t, foreign.IssueID, reloaded.IssueID)
|
||||
}
|
||||
+13
-6
@@ -6,6 +6,7 @@ package issues
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -27,12 +28,6 @@ type ErrRepoLabelNotExist struct {
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
// IsErrRepoLabelNotExist checks if an error is a RepoErrLabelNotExist.
|
||||
func IsErrRepoLabelNotExist(err error) bool {
|
||||
_, ok := err.(ErrRepoLabelNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRepoLabelNotExist) Error() string {
|
||||
return fmt.Sprintf("label does not exist [label_id: %d, repo_id: %d]", err.LabelID, err.RepoID)
|
||||
}
|
||||
@@ -312,6 +307,18 @@ func GetLabelInRepoByName(ctx context.Context, repoID int64, labelName string) (
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// GetLabelInRepoOrOrgByID returns the label with labelID scoped to the repo, falling back to the
|
||||
// repo's owning organization when ownerIsOrg is set. It returns ErrRepoLabelNotExist /
|
||||
// ErrOrgLabelNotExist when the label is in neither scope, so a foreign-but-existing label ID is
|
||||
// indistinguishable from a nonexistent one (no cross-repo enumeration oracle).
|
||||
func GetLabelInRepoOrOrgByID(ctx context.Context, repoID, ownerID int64, ownerIsOrg bool, labelID int64) (*Label, error) {
|
||||
label, err := GetLabelInRepoByID(ctx, repoID, labelID)
|
||||
if err != nil && errors.Is(err, util.ErrNotExist) && ownerIsOrg {
|
||||
return GetLabelInOrgByID(ctx, ownerID, labelID)
|
||||
}
|
||||
return label, err
|
||||
}
|
||||
|
||||
// GetLabelInRepoByID returns a label by ID in given repository.
|
||||
func GetLabelInRepoByID(ctx context.Context, repoID, labelID int64) (*Label, error) {
|
||||
if labelID <= 0 || repoID <= 0 {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -94,10 +95,10 @@ func TestGetLabelInRepoByName(t *testing.T) {
|
||||
assert.Equal(t, "label1", label.Name)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByName(t.Context(), 1, "")
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByName(t.Context(), unittest.NonexistentID, "nonexistent")
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestGetLabelInRepoByNames(t *testing.T) {
|
||||
@@ -131,10 +132,10 @@ func TestGetLabelInRepoByID(t *testing.T) {
|
||||
assert.EqualValues(t, 1, label.ID)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByID(t.Context(), 1, -1)
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByID(t.Context(), unittest.NonexistentID, unittest.NonexistentID)
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestGetLabelsInRepoByIDs(t *testing.T) {
|
||||
|
||||
@@ -324,6 +324,59 @@ func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organizatio
|
||||
return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
|
||||
}
|
||||
|
||||
// RecalculateReviewsOfficial re-evaluates the "official" flag of the latest approve
|
||||
// and reject reviews of an issue against its pull request's current base branch.
|
||||
// It must be called whenever the target branch changes, otherwise an approval that
|
||||
// was official on the previous (possibly unprotected) branch would keep satisfying
|
||||
// the new branch's protection rules.
|
||||
func RecalculateReviewsOfficial(ctx context.Context, issue *Issue) error {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clearing and restoring the official flags must happen atomically, otherwise a
|
||||
// failure in between would leave the reviews without any official flag set.
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// Only the latest approve/reject review of each reviewer counts as official, so
|
||||
// clear the flag on all of them first and restore it only where it still applies.
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("issue_id = ?", issue.ID).
|
||||
In("type", ReviewTypeApprove, ReviewTypeReject).
|
||||
Cols("official").
|
||||
Update(&Review{Official: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reviews, err := FindLatestReviews(ctx, FindReviewOptions{
|
||||
Types: []ReviewType{ReviewTypeApprove, ReviewTypeReject},
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, review := range reviews {
|
||||
if err := review.LoadReviewer(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if review.Reviewer == nil {
|
||||
continue
|
||||
}
|
||||
official, err := IsOfficialReviewer(ctx, issue, review.Reviewer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if official {
|
||||
if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(&Review{Official: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateReview creates a new review based on opts
|
||||
func CreateReview(ctx context.Context, opts CreateReviewOptions) (*Review, error) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*Review, error) {
|
||||
|
||||
@@ -6,6 +6,8 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
@@ -386,3 +388,45 @@ func TestAddReviewRequest(t *testing.T) {
|
||||
assert.NotNil(t, comment.CommentMetaData)
|
||||
assert.Equal(t, issues_model.SpecialDoerNameCodeOwners, comment.CommentMetaData.SpecialDoerName)
|
||||
}
|
||||
|
||||
func TestRecalculateReviewsOfficial(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// PR #2 targets repo1's "master" branch. Simulate an approval that became
|
||||
// official while the PR targeted an unprotected branch.
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
review, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
|
||||
Type: issues_model.ReviewTypeApprove,
|
||||
Issue: issue,
|
||||
Reviewer: reviewer,
|
||||
Official: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Protect the (now current) target branch with an approvals whitelist that
|
||||
// does not include the reviewer, mirroring a retarget onto a protected branch.
|
||||
rule := &git_model.ProtectedBranch{
|
||||
RepoID: issue.RepoID,
|
||||
RuleName: "master",
|
||||
EnableApprovalsWhitelist: true,
|
||||
ApprovalsWhitelistUserIDs: []int64{2},
|
||||
RequiredApprovals: 1,
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), rule))
|
||||
|
||||
// Re-evaluating must strip the stale official flag, otherwise the approval
|
||||
// would still satisfy the protected branch's required approvals.
|
||||
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
|
||||
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
|
||||
assert.False(t, review.Official)
|
||||
|
||||
// Once the reviewer is whitelisted, re-evaluating restores the official flag.
|
||||
rule.ApprovalsWhitelistUserIDs = []int64{2, reviewer.ID}
|
||||
_, err = db.GetEngine(t.Context()).ID(rule.ID).Cols("approvals_whitelist_user_i_ds").Update(rule)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
|
||||
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
|
||||
assert.True(t, review.Official)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -92,6 +93,15 @@ func (t *Team) IsPublic() bool { return t.Visibility.IsPublic() }
|
||||
func (t *Team) IsLimited() bool { return t.Visibility.IsLimited() }
|
||||
func (t *Team) IsPrivate() bool { return t.Visibility.IsPrivate() }
|
||||
|
||||
const (
|
||||
ghostTeamID = -1
|
||||
ghostTeamName = "(deleted team)"
|
||||
)
|
||||
|
||||
func newGhostTeam() *Team {
|
||||
return &Team{ID: ghostTeamID, Name: ghostTeamName, LowerName: ghostTeamName}
|
||||
}
|
||||
|
||||
// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read
|
||||
// the team's metadata, based on the team's visibility tier and the parent org's
|
||||
// visibility. Privileged callers (site admins, org owners, team members) are
|
||||
@@ -270,6 +280,17 @@ func GetTeamByID(ctx context.Context, teamID int64) (*Team, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func GetPossibleTeamByID(ctx context.Context, teamID int64) (int64, *Team, error) {
|
||||
t, err := GetTeamByID(ctx, teamID)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
t = newGhostTeam()
|
||||
return t.ID, t, nil
|
||||
} else if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return t.ID, t, nil
|
||||
}
|
||||
|
||||
// IncrTeamRepoNum increases the number of repos for the given team by 1
|
||||
func IncrTeamRepoNum(ctx context.Context, teamID int64) error {
|
||||
_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team))
|
||||
|
||||
@@ -599,8 +599,8 @@ func HasAnyUnitAccess(ctx context.Context, userID int64, repo *repo_model.Reposi
|
||||
return perm.HasAnyUnitAccess(), nil
|
||||
}
|
||||
|
||||
func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (users []*user_model.User, err error) {
|
||||
userIDs, err := GetUserIDsWithUnitAccess(ctx, repo, mode, unitType)
|
||||
func GetUsersWithAnyUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type, moreUnitTypes ...unit.Type) (users []*user_model.User, err error) {
|
||||
userIDs, err := GetUserIDsWithAnyUnitAccess(ctx, repo, mode, unitType, moreUnitTypes...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -613,7 +613,7 @@ func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mo
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (container.Set[int64], error) {
|
||||
func GetUserIDsWithAnyUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type, moreUnitTypes ...unit.Type) (container.Set[int64], error) {
|
||||
userIDs := container.Set[int64]{}
|
||||
e := db.GetEngine(ctx)
|
||||
accesses := make([]*Access, 0, 10)
|
||||
@@ -630,7 +630,7 @@ func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository,
|
||||
if !repo.Owner.IsOrganization() {
|
||||
userIDs.Add(repo.Owner.ID)
|
||||
} else {
|
||||
teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType)
|
||||
teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType, moreUnitTypes...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -226,12 +226,12 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
|
||||
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode])
|
||||
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
|
||||
|
||||
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues)
|
||||
users, err := GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, user.ID, users[0].ID)
|
||||
|
||||
users, err = GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
users, err = GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, users)
|
||||
})
|
||||
@@ -245,7 +245,7 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
|
||||
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode])
|
||||
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeIssues])
|
||||
|
||||
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
users, err := GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, user.ID, users[0].ID)
|
||||
|
||||
@@ -7,41 +7,45 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
type commitChecker struct {
|
||||
ctx context.Context
|
||||
commitCache map[string]bool
|
||||
gitRepoFacade gitrepo.Repository
|
||||
ctx context.Context
|
||||
commitCache map[string]bool
|
||||
repoOptional *repo_model.Repository
|
||||
|
||||
gitRepo *git.Repository
|
||||
gitRepoCloser io.Closer
|
||||
}
|
||||
|
||||
func newCommitChecker(ctx context.Context, gitRepo gitrepo.Repository) *commitChecker {
|
||||
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), gitRepoFacade: gitRepo}
|
||||
func newCommitChecker(ctx context.Context, repo *repo_model.Repository) *commitChecker {
|
||||
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), repoOptional: repo}
|
||||
}
|
||||
|
||||
func (c *commitChecker) Close() error {
|
||||
if c != nil && c.gitRepoCloser != nil {
|
||||
if c.gitRepoCloser != nil {
|
||||
return c.gitRepoCloser.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commitChecker) IsCommitIDExisting(commitID string) bool {
|
||||
if c.repoOptional == nil {
|
||||
return false
|
||||
}
|
||||
exist, inCache := c.commitCache[commitID]
|
||||
if inCache {
|
||||
return exist
|
||||
}
|
||||
|
||||
if c.gitRepo == nil {
|
||||
r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.gitRepoFacade)
|
||||
r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.repoOptional)
|
||||
if err != nil {
|
||||
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.gitRepoFacade), err)
|
||||
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.repoOptional), err)
|
||||
return false
|
||||
}
|
||||
c.gitRepo, c.gitRepoCloser = r, closer
|
||||
|
||||
@@ -51,10 +51,10 @@ func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repositor
|
||||
helper := &RepoComment{opts: util.OptionalArg(opts)}
|
||||
rctx := markup.NewRenderContext(ctx)
|
||||
helper.ctx = rctx
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
var metas map[string]string
|
||||
if repo != nil {
|
||||
helper.repoLink = repo.Link()
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
metas = repo.ComposeCommentMetas(ctx)
|
||||
} else {
|
||||
// repo can be nil when rendering a commit message in user's dashboard feedback whose repository has been deleted
|
||||
|
||||
@@ -35,11 +35,11 @@ func (r *RepoFile) ResolveLink(link, preferLinkType string) (finalLink string) {
|
||||
case markup.LinkTypeRoot:
|
||||
finalLink = r.ctx.ResolveLinkRoot(link)
|
||||
case markup.LinkTypeRaw:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
|
||||
case markup.LinkTypeMedia:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
|
||||
default:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
|
||||
}
|
||||
return finalLink
|
||||
}
|
||||
@@ -58,9 +58,9 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository,
|
||||
helper := &RepoFile{opts: util.OptionalArg(opts)}
|
||||
rctx := markup.NewRenderContext(ctx)
|
||||
helper.ctx = rctx
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
if repo != nil {
|
||||
helper.repoLink = repo.Link()
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
rctx = rctx.WithMetas(repo.ComposeRepoFileMetas(ctx))
|
||||
} else {
|
||||
// this is almost dead code, only to pass the incorrect tests
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestRepoFile(t *testing.T) {
|
||||
t.Run("WithCurrentRefSubURLByTag", func(t *testing.T) {
|
||||
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
||||
CurrentRefSubURL: "/commit/1234",
|
||||
CurrentTreePath: "my-dir",
|
||||
CurrentTreePath: "my dir",
|
||||
}).
|
||||
WithMarkupType(markdown.MarkupName)
|
||||
rendered, err := testRenderString(rctx, `
|
||||
@@ -76,8 +76,8 @@ func TestRepoFile(t *testing.T) {
|
||||
<video src="LINK">
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my-dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my-dir/LINK"/></a>
|
||||
<video src="/user2/repo1/media/commit/1234/my-dir/LINK">
|
||||
assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my%20dir/LINK"/></a>
|
||||
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
|
||||
</video>`, rendered)
|
||||
})
|
||||
}
|
||||
@@ -89,8 +89,8 @@ func TestRepoFileOrgMode(t *testing.T) {
|
||||
t.Run("Links", func(t *testing.T) {
|
||||
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
||||
CurrentRefSubURL: "/commit/1234",
|
||||
CurrentTreePath: "my-dir",
|
||||
}).WithRelativePath("my-dir/a.org")
|
||||
CurrentTreePath: "my dir",
|
||||
}).WithRelativePath("my dir/a.org")
|
||||
|
||||
rendered, err := testRenderString(rctx, `
|
||||
[[https://google.com/]]
|
||||
@@ -99,7 +99,7 @@ func TestRepoFileOrgMode(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<p>
|
||||
<a href="https://google.com/" rel="nofollow">https://google.com/</a>
|
||||
<a href="/user2/repo1/src/commit/1234/my-dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p>
|
||||
<a href="/user2/repo1/src/commit/1234/my%20dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p>
|
||||
`, rendered)
|
||||
})
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ func (r *RepoWiki) ResolveLink(link, preferLinkType string) (finalLink string) {
|
||||
case markup.LinkTypeRoot:
|
||||
finalLink = r.ctx.ResolveLinkRoot(link)
|
||||
case markup.LinkTypeMedia, markup.LinkTypeRaw:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), r.opts.currentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
|
||||
default:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), r.opts.currentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
|
||||
}
|
||||
return finalLink
|
||||
}
|
||||
@@ -57,9 +57,9 @@ type RepoWikiOptions struct {
|
||||
func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, opts ...RepoWikiOptions) *markup.RenderContext {
|
||||
helper := &RepoWiki{opts: util.OptionalArg(opts)}
|
||||
rctx := markup.NewRenderContext(ctx).WithMarkupType(markdown.MarkupName)
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
if repo != nil {
|
||||
helper.repoLink = repo.Link()
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
rctx = rctx.WithMetas(repo.ComposeWikiMetas(ctx))
|
||||
} else {
|
||||
// this is almost dead code, only to pass the incorrect tests
|
||||
|
||||
@@ -50,14 +50,16 @@ func TestRepoWiki(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("PathInTag", func(t *testing.T) {
|
||||
rctx := NewRenderContextRepoWiki(t.Context(), repo1).WithMarkupType(markdown.MarkupName)
|
||||
rctx := NewRenderContextRepoWiki(t.Context(), repo1, RepoWikiOptions{
|
||||
currentTreePath: "my dir",
|
||||
}).WithMarkupType(markdown.MarkupName)
|
||||
rendered, err := testRenderString(rctx, `
|
||||
<img src="LINK">
|
||||
<video src="LINK">
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<a href="/user2/repo1/wiki/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/LINK"/></a>
|
||||
<video src="/user2/repo1/wiki/raw/LINK">
|
||||
assert.Equal(t, `<a href="/user2/repo1/wiki/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/my%20dir/LINK"/></a>
|
||||
<video src="/user2/repo1/wiki/raw/my%20dir/LINK">
|
||||
</video>`, rendered)
|
||||
})
|
||||
}
|
||||
|
||||
+23
-8
@@ -31,18 +31,28 @@ func GetOrgRepositoryIDs(ctx context.Context, orgID int64) (repoIDs []int64, _ e
|
||||
type SearchTeamRepoOptions struct {
|
||||
db.ListOptions
|
||||
TeamID int64
|
||||
// PublicOnly restricts the result (and count) to non-private repositories.
|
||||
PublicOnly bool
|
||||
}
|
||||
|
||||
func (opts *SearchTeamRepoOptions) toCond() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.TeamID > 0 {
|
||||
cond = cond.And(builder.In("id",
|
||||
builder.Select("repo_id").
|
||||
From("team_repo").
|
||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||
))
|
||||
}
|
||||
if opts.PublicOnly {
|
||||
cond = cond.And(builder.Eq{"is_private": false})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// GetTeamRepositories returns paginated repositories in team of organization.
|
||||
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
|
||||
sess := db.GetEngine(ctx)
|
||||
if opts.TeamID > 0 {
|
||||
sess = sess.In("id",
|
||||
builder.Select("repo_id").
|
||||
From("team_repo").
|
||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||
)
|
||||
}
|
||||
sess := db.GetEngine(ctx).Where(opts.toCond())
|
||||
if opts.PageSize > 0 {
|
||||
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
|
||||
}
|
||||
@@ -51,6 +61,11 @@ func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (Repo
|
||||
Find(&repos)
|
||||
}
|
||||
|
||||
// CountTeamRepositories returns the number of repositories in team of organization matching opts.
|
||||
func CountTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (int64, error) {
|
||||
return db.GetEngine(ctx).Where(opts.toCond()).Count(new(Repository))
|
||||
}
|
||||
|
||||
// AccessibleReposEnvironment operations involving the repositories that are
|
||||
// accessible to a particular user
|
||||
type AccessibleReposEnvironment interface {
|
||||
|
||||
@@ -310,11 +310,17 @@ func userOrgTeamRepoBuilder(userID int64) *builder.Builder {
|
||||
}
|
||||
|
||||
// userOrgTeamUnitRepoBuilder returns repo ids where user's teams can access the special unit.
|
||||
// A team grants the unit either through an explicit team_unit row (access_mode > none) or by being an
|
||||
// admin/owner team (team.authorize >= admin), which grants every unit regardless of team_unit rows —
|
||||
// mirroring the HasAdminAccess() short-circuit in access.GetIndividualUserRepoPermission.
|
||||
func userOrgTeamUnitRepoBuilder(userID int64, unitType unit.Type) *builder.Builder {
|
||||
return userOrgTeamRepoBuilder(userID).
|
||||
Join("INNER", "team_unit", "`team_unit`.team_id = `team_repo`.team_id").
|
||||
Where(builder.Eq{"`team_unit`.`type`": unitType}).
|
||||
And(builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)})
|
||||
Join("INNER", "team", "`team`.id = `team_repo`.team_id").
|
||||
Join("LEFT", "team_unit", builder.Expr("`team_unit`.team_id = `team_repo`.team_id AND `team_unit`.`type` = ?", unitType)).
|
||||
Where(builder.Or(
|
||||
builder.Gte{"`team`.authorize": int(perm.AccessModeAdmin)},
|
||||
builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)},
|
||||
))
|
||||
}
|
||||
|
||||
// userOrgTeamUnitRepoCond returns a condition to select repo ids where user's teams can access the special unit.
|
||||
@@ -326,7 +332,7 @@ func userOrgTeamUnitRepoCond(idStr string, userID int64, unitType unit.Type) bui
|
||||
func UserOrgUnitRepoCond(idStr string, userID, orgID int64, unitType unit.Type) builder.Cond {
|
||||
return builder.In(idStr,
|
||||
userOrgTeamUnitRepoBuilder(userID, unitType).
|
||||
And(builder.Eq{"`team_unit`.org_id": orgID}),
|
||||
And(builder.Eq{"`team`.org_id": orgID}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -755,6 +761,40 @@ func FindUserCodeAccessibleOwnerRepoIDs(ctx context.Context, ownerID int64, user
|
||||
))
|
||||
}
|
||||
|
||||
// PublicRepoUnderPublicOwnerCond restricts to public repos whose owner is publicly visible: the
|
||||
// "genuinely public" set a public-only token or an anonymous caller may see (a public repo under a
|
||||
// limited/private owner is not publicly reachable and must be excluded).
|
||||
func PublicRepoUnderPublicOwnerCond() builder.Cond {
|
||||
return builder.And(
|
||||
builder.Eq{"`repository`.is_private": false},
|
||||
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePublic})),
|
||||
)
|
||||
}
|
||||
|
||||
// UserActionsAccessibleOwnerRepoCond selects the repos owned by ownerID whose Actions `user` may read.
|
||||
// It is used to list an org/user's Actions runs and jobs (see the callers in routers/api/v1/shared).
|
||||
// - owner_id = ownerID: only that owner's repos.
|
||||
// - AccessibleRepositoryCondition(user, TypeActions): only repos whose Actions the user can read
|
||||
// (admin/owner teams are handled inside it; a site admin is not, callers must skip the filter for one).
|
||||
// - publicOnly (a public-only token): additionally limit to public repos under a public owner.
|
||||
func UserActionsAccessibleOwnerRepoCond(ownerID int64, user *user_model.User, publicOnly bool) builder.Cond {
|
||||
cond := builder.NewCond().And(
|
||||
builder.Eq{"`repository`.owner_id": ownerID},
|
||||
AccessibleRepositoryCondition(user, unit.TypeActions),
|
||||
)
|
||||
if publicOnly {
|
||||
cond = cond.And(PublicRepoUnderPublicOwnerCond())
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// FindUserActionsAccessibleOwnerRepoIDsSubQuery returns a subquery selecting the repository IDs the user
|
||||
// can see for the given owner. Callers embed it in an `IN (...)` condition so that a large owner does not
|
||||
// materialize every repo ID into the SQL statement, which could exceed database parameter limits.
|
||||
func FindUserActionsAccessibleOwnerRepoIDsSubQuery(ownerID int64, user *user_model.User, publicOnly bool) *builder.Builder {
|
||||
return builder.Select("id").From("repository").Where(UserActionsAccessibleOwnerRepoCond(ownerID, user, publicOnly))
|
||||
}
|
||||
|
||||
// GetUserRepositories returns a list of repositories of given user.
|
||||
func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (RepositoryList, int64, error) {
|
||||
if len(opts.OrderBy) == 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
@@ -466,3 +467,50 @@ func TestSearchRepositoryByTopicName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserActionsAccessibleOwnerRepoIDs(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// user2 is on org3's owner team, so it can access org3's private repo3 (which has the actions unit)
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// org3 is a public org owning repo3 (private) and repo32 (public), both with the actions unit
|
||||
const orgID = 3
|
||||
|
||||
all, err := repo_model.SearchRepositoryIDsByCondition(t.Context(), repo_model.UserActionsAccessibleOwnerRepoCond(orgID, user, false))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, all, int64(3), "without public-only the private repo's actions are listed")
|
||||
|
||||
publicOnly, err := repo_model.SearchRepositoryIDsByCondition(t.Context(), repo_model.UserActionsAccessibleOwnerRepoCond(orgID, user, true))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, publicOnly, int64(3), "a public-only token must not list a private repo's actions")
|
||||
assert.Contains(t, publicOnly, int64(32), "a public repo under a public owner stays listed")
|
||||
}
|
||||
|
||||
// TestUserOrgUnitRepoCondTeamAuthorize pins the team.authorize behavior of userOrgTeamUnitRepoBuilder
|
||||
// (exercised through UserOrgUnitRepoCond): an admin/owner team grants every unit even without an explicit
|
||||
// team_unit row, while a non-admin team only grants a unit it has an explicit row for. This guards both
|
||||
// directions — hiding repos from admin-team members, and over-broadening a plain team's access.
|
||||
func TestUserOrgUnitRepoCondTeamAuthorize(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
accessibleRepoIDs := func(userID, orgID int64, unitType unit.Type) []int64 {
|
||||
ids, err := repo_model.SearchRepositoryIDsByCondition(t.Context(),
|
||||
repo_model.UserOrgUnitRepoCond("`repository`.id", userID, orgID, unitType))
|
||||
require.NoError(t, err)
|
||||
return ids
|
||||
}
|
||||
|
||||
// Case A: user18 is only on org17's owner team (team5, authorize=owner), linked to the private repo24
|
||||
// but with no Actions team_unit row. The owner authorize must still grant it, mirroring the runtime
|
||||
// HasAdminAccess() short-circuit in access.GetIndividualUserRepoPermission.
|
||||
assert.Contains(t, accessibleRepoIDs(18, 17, unit.TypeActions), int64(24),
|
||||
"an owner team grants a unit it has no explicit team_unit row for")
|
||||
|
||||
// Cases B and C share one subject so the team_unit row is the only difference: user4 is only on org3's
|
||||
// write team (team2, authorize=write, non-admin), linked to the private repo3. team2 has an explicit
|
||||
// Projects row but none for Actions.
|
||||
assert.Contains(t, accessibleRepoIDs(4, 3, unit.TypeProjects), int64(3),
|
||||
"a non-admin team grants a unit it has an explicit team_unit row for")
|
||||
assert.NotContains(t, accessibleRepoIDs(4, 3, unit.TypeActions), int64(3),
|
||||
"a non-admin team must NOT grant a unit it has no team_unit row for")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ type StarredReposOptions struct {
|
||||
StarrerID int64
|
||||
RepoOwnerID int64
|
||||
IncludePrivate bool
|
||||
// Actor is the user the private repositories are gated on: a private repo is only
|
||||
// returned when Actor still has access to it, even if it was starred while access was granted.
|
||||
Actor *user_model.User
|
||||
}
|
||||
|
||||
func (opts *StarredReposOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
@@ -39,10 +42,12 @@ func (opts *StarredReposOptions) ToConds() builder.Cond {
|
||||
"repository.owner_id": opts.RepoOwnerID,
|
||||
})
|
||||
}
|
||||
if !opts.IncludePrivate {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.is_private": false,
|
||||
})
|
||||
if opts.IncludePrivate {
|
||||
// only include private repos the actor can still access, so metadata does not leak after access revocation
|
||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||
} else {
|
||||
// a public repo under a limited/private owner is not publicly reachable, so exclude it too
|
||||
cond = cond.And(PublicRepoUnderPublicOwnerCond())
|
||||
}
|
||||
return cond
|
||||
}
|
||||
@@ -66,6 +71,9 @@ type WatchedReposOptions struct {
|
||||
WatcherID int64
|
||||
RepoOwnerID int64
|
||||
IncludePrivate bool
|
||||
// Actor is the user the private repositories are gated on: a private repo is only
|
||||
// returned when Actor still has access to it, even if it was watched while access was granted.
|
||||
Actor *user_model.User
|
||||
}
|
||||
|
||||
func (opts *WatchedReposOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
@@ -83,10 +91,12 @@ func (opts *WatchedReposOptions) ToConds() builder.Cond {
|
||||
"repository.owner_id": opts.RepoOwnerID,
|
||||
})
|
||||
}
|
||||
if !opts.IncludePrivate {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.is_private": false,
|
||||
})
|
||||
if opts.IncludePrivate {
|
||||
// only include private repos the actor can still access, so metadata does not leak after access revocation
|
||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||
} else {
|
||||
// a public repo under a limited/private owner is not publicly reachable, so exclude it too
|
||||
cond = cond.And(PublicRepoUnderPublicOwnerCond())
|
||||
}
|
||||
return cond.And(builder.Neq{
|
||||
"watch.mode": WatchModeDont,
|
||||
|
||||
@@ -84,3 +84,40 @@ func testUserRepoGetIssuePostersWithSearch(t *testing.T) {
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, "user2", users[0].Name)
|
||||
}
|
||||
|
||||
func TestStarredWatchedReposExcludeNonPublicOwners(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const viewerID = 2
|
||||
// repo1: public repo under a public owner; repo38: public repo under a limited org (not publicly reachable)
|
||||
const publicOwnerRepo, limitedOwnerRepo = 1, 38
|
||||
|
||||
require.NoError(t, db.Insert(t.Context(), &repo_model.Star{UID: viewerID, RepoID: publicOwnerRepo}))
|
||||
require.NoError(t, db.Insert(t.Context(), &repo_model.Star{UID: viewerID, RepoID: limitedOwnerRepo}))
|
||||
require.NoError(t, db.Insert(t.Context(), &repo_model.Watch{UserID: viewerID, RepoID: publicOwnerRepo, Mode: repo_model.WatchModeNormal}))
|
||||
require.NoError(t, db.Insert(t.Context(), &repo_model.Watch{UserID: viewerID, RepoID: limitedOwnerRepo, Mode: repo_model.WatchModeNormal}))
|
||||
|
||||
listOpts := db.ListOptions{Page: 1, PageSize: 50}
|
||||
|
||||
starred, err := repo_model.GetStarredRepos(t.Context(), &repo_model.StarredReposOptions{
|
||||
ListOptions: listOpts, StarrerID: viewerID, IncludePrivate: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, repoIDs(starred), int64(limitedOwnerRepo), "a public repo under a limited owner must be hidden from a public star listing")
|
||||
assert.Contains(t, repoIDs(starred), int64(publicOwnerRepo), "a public repo under a public owner stays visible")
|
||||
|
||||
watched, _, err := repo_model.GetWatchedRepos(t.Context(), &repo_model.WatchedReposOptions{
|
||||
ListOptions: listOpts, WatcherID: viewerID, IncludePrivate: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, repoIDs(watched), int64(limitedOwnerRepo), "a public repo under a limited owner must be hidden from a public watch listing")
|
||||
assert.Contains(t, repoIDs(watched), int64(publicOwnerRepo), "a public repo under a public owner stays visible")
|
||||
}
|
||||
|
||||
func repoIDs(repos []*repo_model.Repository) []int64 {
|
||||
ids := make([]int64, len(repos))
|
||||
for i, r := range repos {
|
||||
ids[i] = r.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ package jobparser
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
@@ -23,12 +26,6 @@ func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvalu
|
||||
return &ExpressionEvaluator{interpreter: interpreter}
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
||||
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
|
||||
|
||||
return evaluated, err
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
||||
var in string
|
||||
if err := node.Decode(&in); err != nil {
|
||||
@@ -37,17 +34,17 @@ func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return nil
|
||||
}
|
||||
expr, _ := rewriteSubExpression(in, false)
|
||||
res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
res, err := ee.evaluateScalar(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return node.Encode(res)
|
||||
}
|
||||
|
||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
||||
var insertDirective = regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
|
||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
||||
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||
for i := 0; i < len(node.Content)/2; {
|
||||
k := node.Content[i*2]
|
||||
v := node.Content[i*2+1]
|
||||
@@ -102,88 +99,170 @@ func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) Interpolate(in string) string {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return in
|
||||
}
|
||||
|
||||
expr, _ := rewriteSubExpression(in, true)
|
||||
evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
|
||||
func (ee ExpressionEvaluator) interpolate(in string) (string, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
value, ok := evaluated.(string)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func escapeFormatString(in string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
||||
}
|
||||
|
||||
func rewriteSubExpression(in string, forceFormat bool) (string, error) {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
if len(parts) == 1 && !parts[0].isExpr {
|
||||
return in, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(in))
|
||||
for _, part := range parts {
|
||||
if !part.isExpr {
|
||||
out.WriteString(part.text)
|
||||
continue
|
||||
}
|
||||
evaluated, err := ee.interpreter.Evaluate(part.text, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.WriteString(coerceToString(evaluated))
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
strPattern := regexp.MustCompile("(?:''|[^'])*'")
|
||||
pos := 0
|
||||
exprStart := -1
|
||||
strStart := -1
|
||||
var results []string
|
||||
var formatOut strings.Builder
|
||||
for pos < len(in) {
|
||||
if strStart > -1 {
|
||||
matches := strPattern.FindStringIndex(in[pos:])
|
||||
if matches == nil {
|
||||
return "", errors.New("unclosed string")
|
||||
}
|
||||
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
|
||||
func (ee ExpressionEvaluator) evaluateScalar(in string) (any, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(parts) == 1 && parts[0].isExpr {
|
||||
return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone)
|
||||
}
|
||||
return ee.interpolate(in)
|
||||
}
|
||||
|
||||
strStart = -1
|
||||
pos += matches[1]
|
||||
} else if exprStart > -1 {
|
||||
exprEnd := strings.Index(in[pos:], "}}")
|
||||
strStart = strings.Index(in[pos:], "'")
|
||||
// evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content
|
||||
// interpolates to a string, so the success() default applies to it separately.
|
||||
func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
evaluated, err := ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckSuccess)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(evaluated), nil
|
||||
}
|
||||
|
||||
if exprEnd > -1 && strStart > -1 {
|
||||
if exprEnd < strStart {
|
||||
strStart = -1
|
||||
} else {
|
||||
exprEnd = -1
|
||||
}
|
||||
}
|
||||
|
||||
if exprEnd > -1 {
|
||||
fmt.Fprintf(&formatOut, "{%d}", len(results))
|
||||
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
|
||||
pos += exprEnd + 2
|
||||
exprStart = -1
|
||||
} else if strStart > -1 {
|
||||
pos += strStart + 1
|
||||
} else {
|
||||
panic("unclosed expression.")
|
||||
}
|
||||
} else {
|
||||
exprStart = strings.Index(in[pos:], "${{")
|
||||
if exprStart != -1 {
|
||||
formatOut.WriteString(escapeFormatString(in[pos : pos+exprStart]))
|
||||
exprStart = pos + exprStart + 3
|
||||
pos = exprStart
|
||||
} else {
|
||||
formatOut.WriteString(escapeFormatString(in[pos:]))
|
||||
pos = len(in)
|
||||
}
|
||||
// mixed content is a string, so the success() default applies to it separately
|
||||
if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") {
|
||||
status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exprparser.IsTruthy(status) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
interpolated, err := ee.interpolate(in)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(interpolated), nil
|
||||
}
|
||||
|
||||
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat {
|
||||
return in, nil
|
||||
// coerceToString converts an evaluated expression value to a string the way GitHub does,
|
||||
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
|
||||
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
|
||||
func coerceToString(v any) string {
|
||||
value, ok := v.(reflect.Value)
|
||||
if !ok {
|
||||
value = reflect.ValueOf(v)
|
||||
}
|
||||
|
||||
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut.String(), "'", "''"), strings.Join(results, ", "))
|
||||
return out, nil
|
||||
switch value.Kind() {
|
||||
case reflect.Invalid:
|
||||
return ""
|
||||
|
||||
case reflect.Bool:
|
||||
return strconv.FormatBool(value.Bool())
|
||||
|
||||
case reflect.String:
|
||||
return value.String()
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return strconv.FormatInt(value.Int(), 10)
|
||||
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return strconv.FormatUint(value.Uint(), 10)
|
||||
|
||||
case reflect.Float32, reflect.Float64:
|
||||
if math.IsInf(value.Float(), 1) {
|
||||
return "Infinity"
|
||||
} else if math.IsInf(value.Float(), -1) {
|
||||
return "-Infinity"
|
||||
}
|
||||
return fmt.Sprintf("%.15G", value.Float())
|
||||
|
||||
case reflect.Slice, reflect.Array:
|
||||
return "Array"
|
||||
|
||||
// contexts such as `github` are pointers to structs, so they stringify as objects too
|
||||
case reflect.Map, reflect.Struct:
|
||||
return "Object"
|
||||
|
||||
case reflect.Interface, reflect.Pointer:
|
||||
if value.IsNil() {
|
||||
return ""
|
||||
}
|
||||
return coerceToString(value.Elem())
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
|
||||
type exprPart struct {
|
||||
text string
|
||||
isExpr bool
|
||||
}
|
||||
|
||||
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
|
||||
// complete expression literal.
|
||||
func splitSubExpressions(in string) ([]exprPart, error) {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return []exprPart{{text: in}}, nil
|
||||
}
|
||||
|
||||
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
|
||||
for {
|
||||
start := strings.Index(in, "${{")
|
||||
if start < 0 {
|
||||
if in != "" {
|
||||
parts = append(parts, exprPart{text: in})
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
if start > 0 {
|
||||
parts = append(parts, exprPart{text: in[:start]})
|
||||
}
|
||||
rest := in[start+len("${{"):]
|
||||
end := indexExprEnd(rest)
|
||||
if end < 0 {
|
||||
return nil, errors.New("unclosed expression")
|
||||
}
|
||||
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
|
||||
in = rest[end+len("}}"):]
|
||||
}
|
||||
}
|
||||
|
||||
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
|
||||
// state, so a `}}` inside a string does not end it.
|
||||
func indexExprEnd(in string) int {
|
||||
inString := false
|
||||
for i := range len(in) {
|
||||
switch {
|
||||
case in[i] == '\'':
|
||||
inString = !inString
|
||||
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -57,9 +57,14 @@ func NewInterpeter(
|
||||
}
|
||||
|
||||
ee := &exprparser.EvaluationEnvironment{
|
||||
Github: gitCtx,
|
||||
Env: nil, // no need
|
||||
Job: nil, // no need
|
||||
Github: gitCtx,
|
||||
Env: nil, // no need
|
||||
// Job must be non-nil because cancelled() dereferences Job.Status unconditionally.
|
||||
// See: https://gitea.com/gitea/runner/src/commit/ad967330a8788c9b8ab723abbc1a86d53c3bc5e6/act/exprparser/functions.go#L299
|
||||
// TODO: The empty JobContext.Status is right for now because Gitea never checks `if` condition when the workflow run is cancelled.
|
||||
// This is an implementation gap in Gitea Actions. When a workflow run is cancelled, Gitea should check the job's `if` condition,
|
||||
// and if the condition is met (e.g. `if: ${{ cancelled() }}` ), the job should be executed rather than cancelled.
|
||||
Job: &model.JobContext{},
|
||||
Steps: nil, // no need
|
||||
Runner: nil, // no need
|
||||
Secrets: nil, // no need
|
||||
|
||||
@@ -6,11 +6,13 @@ package jobparser
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/rhysd/actionlint"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -48,7 +50,9 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
}
|
||||
|
||||
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}))
|
||||
workflow.RunName = evaluator.Interpolate(workflow.RunName)
|
||||
if workflow.RunName, err = evaluator.interpolate(workflow.RunName); err != nil {
|
||||
return nil, fmt.Errorf("interpolate run-name: %w", err)
|
||||
}
|
||||
|
||||
for i, id := range ids {
|
||||
job := jobs[i]
|
||||
@@ -63,10 +67,14 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
}
|
||||
job.Strategy.RawMatrix = encodeMatrix(matrix)
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs))
|
||||
job.Name = nameWithMatrix(job.Name, matrix, evaluator)
|
||||
if job.Name, err = nameWithMatrix(job.Name, matrix, evaluator); err != nil {
|
||||
return nil, fmt.Errorf("interpolate name for job %q: %w", id, err)
|
||||
}
|
||||
runsOn := origin.GetJob(id).RunsOn()
|
||||
for i, v := range runsOn {
|
||||
runsOn[i] = evaluator.Interpolate(v)
|
||||
if runsOn[i], err = evaluator.interpolate(v); err != nil {
|
||||
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
job.RawRunsOn = encodeRunsOn(runsOn)
|
||||
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
|
||||
@@ -150,16 +158,45 @@ func encodeRunsOn(runsOn []string) yaml.Node {
|
||||
return node
|
||||
}
|
||||
|
||||
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) string {
|
||||
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) (string, error) {
|
||||
if len(m) == 0 {
|
||||
return name
|
||||
return name, nil
|
||||
}
|
||||
|
||||
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
|
||||
return name + " " + matrixName(m)
|
||||
return name + " " + matrixName(m), nil
|
||||
}
|
||||
|
||||
return evaluator.Interpolate(name)
|
||||
return evaluator.interpolate(name)
|
||||
}
|
||||
|
||||
// expressionCallsFunction reports whether any ${{ }} expression in value calls one of the functions.
|
||||
func expressionCallsFunction(value string, names ...string) bool {
|
||||
parts, err := splitSubExpressions(value)
|
||||
if err != nil {
|
||||
return true // unparseable here, let the expansion report it against the real values
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !part.isExpr {
|
||||
continue
|
||||
}
|
||||
// The lexer needs the closing `}}` that the scanner strips.
|
||||
expr, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
|
||||
if err != nil {
|
||||
return true // unparseable here, let the expansion report it against the real values
|
||||
}
|
||||
found := false
|
||||
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
|
||||
call, ok := node.(*actionlint.FuncCallNode)
|
||||
if entering && ok && slices.Contains(names, strings.ToLower(call.Callee)) {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
if found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matrixName(m map[string]any) string {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
@@ -107,3 +108,42 @@ func TestParse(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInterpolatesRunName(t *testing.T) {
|
||||
workflow := func(runName string) []byte {
|
||||
return []byte("name: t\nrun-name: \"" + runName + "\"\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: [{run: echo}]\n")
|
||||
}
|
||||
|
||||
for _, tt := range []struct{ name, runName, want string }{
|
||||
{"bool", "${{ true }}", "true"},
|
||||
{"int", "${{ 1 }}", "1"},
|
||||
{"float", "${{ 1.0 }}", "1"},
|
||||
{"null", "${{ null }}", ""},
|
||||
{"object", `${{ fromJSON('{\"a\":1}') }}`, "Object"},
|
||||
{"array", "${{ fromJSON('[1,2]') }}", "Array"},
|
||||
{"context", "${{ github }}", "Object"},
|
||||
{"surrounding literals", "run ${{ 1 }} now", "run 1 now"},
|
||||
{"two expressions", "${{ 1 }}-${{ true }}", "1-true"},
|
||||
{"closing brace inside a string", "${{ 'a}}b' }}", "a}}b"},
|
||||
{"incomplete expression stays literal", "${{ 1", "${{ 1"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Parse(workflow(tt.runName), WithGitContext(&model.GithubContext{EventName: "push"}))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, tt.want, result[0].RunName)
|
||||
})
|
||||
}
|
||||
|
||||
// a malformed part must not restructure the surrounding expression
|
||||
for _, runName := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 'a' }} ${{ b", "${{ 'a }}"} {
|
||||
_, err := Parse(workflow(runName), WithGitContext(&model.GithubContext{EventName: "push"}))
|
||||
assert.ErrorContains(t, err, "interpolate run-name")
|
||||
}
|
||||
|
||||
// callers such as commit status parse without a git context, leaving `github` a nil pointer
|
||||
result, err := Parse(workflow("${{ github }}"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
assert.Empty(t, result[0].RunName)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
@@ -75,7 +74,22 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
|
||||
}
|
||||
|
||||
func (w *SingleWorkflow) Marshal() ([]byte, error) {
|
||||
return yaml.Marshal(w)
|
||||
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
|
||||
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
|
||||
// `run:` step that begins with blank lines) with a wrong explicit indentation
|
||||
// indicator (`run: |4`) that then fails to re-parse, which silently strands
|
||||
// the job during concurrency evaluation. Keeping both encoders at indent 2
|
||||
// makes the serialized single workflow round-trip.
|
||||
var buf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
@@ -490,16 +504,20 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
|
||||
RawMatrix: job.Strategy.RawMatrix,
|
||||
},
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, toGitContext(gitCtx), results, vars, inputs))
|
||||
expr, err := rewriteSubExpression(job.If.Value, false)
|
||||
// Each per-matrix job carries its single matrix combination in RawMatrix so resolve it and pass it in;
|
||||
// otherwise `matrix.*` references in `if:` evaluate to null.
|
||||
// GetMatrixes always returns at least one element (an empty map for a job without a matrix),
|
||||
// so only a non-empty combination should populate `matrix.*`, leaving it nil otherwise.
|
||||
var matrix map[string]any
|
||||
matrixes, err := actJob.GetMatrixes()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess)
|
||||
if err != nil {
|
||||
return false, err
|
||||
if len(matrixes) > 0 && len(matrixes[0]) > 0 {
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
return exprparser.IsTruthy(result), nil
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
return evaluator.evaluateCondition(job.If.Value)
|
||||
}
|
||||
|
||||
// parseMappingNode parse a mapping node and preserve order.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -464,3 +465,110 @@ func TestParseMappingNode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateJobIfExpressionMatrix(t *testing.T) {
|
||||
ifExprs := []string{
|
||||
`${{ contains(fromJSON('["linux","windows"]'), matrix.target) }}`,
|
||||
`${{ contains('["linux","windows"]', matrix.target) }}`,
|
||||
}
|
||||
|
||||
want := map[string]bool{
|
||||
"build (linux)": true,
|
||||
"build (windows)": true,
|
||||
"build (macos)": false,
|
||||
}
|
||||
|
||||
for _, ifExpr := range ifExprs {
|
||||
t.Run(ifExpr, func(t *testing.T) {
|
||||
content := fmt.Sprintf(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: %s
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [linux, windows, macos]
|
||||
steps:
|
||||
- run: echo ${{ matrix.target }}
|
||||
`, ifExpr)
|
||||
|
||||
swfs, err := Parse([]byte(content))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, swfs, 3)
|
||||
|
||||
got := make(map[string]bool, len(swfs))
|
||||
for _, swf := range swfs {
|
||||
id, job := swf.Job()
|
||||
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil)
|
||||
require.NoError(t, err)
|
||||
got[job.Name] = shouldRun
|
||||
}
|
||||
assert.Equal(t, want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateJobIfExpression(t *testing.T) {
|
||||
kases := []struct {
|
||||
name string
|
||||
ifCond string
|
||||
needResult string
|
||||
expected bool
|
||||
}{
|
||||
{name: "empty need success", ifCond: "${{ 1 == 1 }}", needResult: "success", expected: true},
|
||||
{name: "always", ifCond: "${{ always() }}", needResult: "failure", expected: true},
|
||||
{name: "failure true", ifCond: "${{ failure() }}", needResult: "failure", expected: true},
|
||||
{name: "failure false", ifCond: "${{ failure() }}", needResult: "success", expected: false},
|
||||
{name: "success true", ifCond: "${{ success() }}", needResult: "success", expected: true},
|
||||
// cancelled() is always false on the server: a cancelled run never evaluates a blocked job's `if:`
|
||||
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
|
||||
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
|
||||
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
|
||||
// a condition is an expression with or without `${{ }}`, literal text around one makes it a string
|
||||
{name: "bare expression", ifCond: "always()", needResult: "failure", expected: true},
|
||||
{name: "literal text keeps the success() default", ifCond: "x ${{ 1 }}", needResult: "failure", expected: false},
|
||||
{name: "literal text around a status function drops it", ifCond: "x ${{ always() }}", needResult: "failure", expected: true},
|
||||
}
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.name, func(t *testing.T) {
|
||||
content := strings.ReplaceAll(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo job1
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [job1]
|
||||
if: IF_COND
|
||||
steps:
|
||||
- run: echo job2
|
||||
`, "IF_COND", kase.ifCond)
|
||||
|
||||
workflows, err := Parse([]byte(content))
|
||||
require.NoError(t, err)
|
||||
|
||||
var job2 *Job
|
||||
for _, wf := range workflows {
|
||||
if id, job := wf.Job(); id == "job2" {
|
||||
job2 = job
|
||||
}
|
||||
}
|
||||
require.NotNil(t, job2)
|
||||
|
||||
// mirrors findJobNeedsAndFillJobResults: the needs' results plus a self entry carrying Needs
|
||||
results := map[string]*JobResult{
|
||||
"job1": {Result: kase.needResult},
|
||||
"job2": {Needs: []string{"job1"}},
|
||||
}
|
||||
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, kase.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A step whose `run:` block starts with blank lines must still survive the
|
||||
// Parse -> SingleWorkflow.Marshal -> Parse round-trip. Previously Marshal used a
|
||||
// different indentation than SetJob, which made the encoder emit the block scalar
|
||||
// with a wrong explicit indentation indicator (`run: |4`) that no longer parsed;
|
||||
// the job then stayed silently blocked during concurrency evaluation.
|
||||
func TestSingleWorkflowRoundTripRunBlockLeadingBlankLines(t *testing.T) {
|
||||
const wf = `name: demo
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_name:
|
||||
type: string
|
||||
required: true
|
||||
jobs:
|
||||
build:
|
||||
name: build
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.app_name }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: ${{ inputs.app_name != '' }}
|
||||
name: packages
|
||||
run: |
|
||||
|
||||
|
||||
echo start
|
||||
echo done
|
||||
`
|
||||
sws, err := Parse([]byte(wf))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sws, 1)
|
||||
|
||||
// pin the original run block as the baseline
|
||||
_, origJob := sws[0].Job()
|
||||
require.Len(t, origJob.Steps, 1)
|
||||
const wantRun = "\n\necho start\necho done\n"
|
||||
require.Equal(t, wantRun, origJob.Steps[0].Run)
|
||||
|
||||
payload, err := sws[0].Marshal()
|
||||
require.NoError(t, err)
|
||||
|
||||
// the serialized single workflow must be parseable again -- this is what the
|
||||
// server does in EvaluateJobConcurrencyFillModel -> ParseJob. Before the fix
|
||||
// Marshal emitted `run: |4`, which failed here and left the job blocked.
|
||||
roundTripped, err := Parse(payload)
|
||||
require.NoError(t, err, "serialized single workflow must round-trip; got payload:\n%s", payload)
|
||||
require.Len(t, roundTripped, 1)
|
||||
|
||||
// the round-trip must preserve the run block byte-for-byte
|
||||
_, gotJob := roundTripped[0].Job()
|
||||
require.Len(t, gotJob.Steps, 1)
|
||||
require.Equal(t, wantRun, gotJob.Steps[0].Run, "round-trip must preserve run content; got payload:\n%s", payload)
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]a
|
||||
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
|
||||
switch typ {
|
||||
case InputTypeString:
|
||||
return toString(v), nil
|
||||
return coerceToString(v), nil
|
||||
case InputTypeBoolean:
|
||||
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
|
||||
if b, ok := v.(bool); ok {
|
||||
@@ -361,11 +361,11 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
|
||||
Vars: vars,
|
||||
Inputs: inputs,
|
||||
}
|
||||
interpreter := exprparser.NewInterpeter(env, exprparser.Config{})
|
||||
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(env, exprparser.Config{}))
|
||||
|
||||
out := make(map[string]string, len(spec.Outputs))
|
||||
for name, o := range spec.Outputs {
|
||||
v, err := evaluateWorkflowCallOutputValue(interpreter, o.Value)
|
||||
v, err := evaluator.interpolate(o.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
|
||||
}
|
||||
@@ -373,29 +373,3 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func evaluateWorkflowCallOutputValue(interpreter exprparser.Interpreter, value string) (string, error) {
|
||||
if !strings.Contains(value, "${{") || !strings.Contains(value, "}}") {
|
||||
return value, nil
|
||||
}
|
||||
expr, err := rewriteSubExpression(value, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
evaluated, err := interpreter.Evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return toString(evaluated), nil
|
||||
}
|
||||
|
||||
func toString(v any) string {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return s
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,29 +55,34 @@ func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, er
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// MatchScopedWorkflows evaluates already-parsed scoped workflows against one consuming event, returning those whose `on:` matches.
|
||||
// MatchScopedWorkflows evaluates already-parsed scoped workflows against one consuming event.
|
||||
// It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered).
|
||||
func MatchScopedWorkflows(
|
||||
parsed []*ParsedScopedWorkflow,
|
||||
consumerGitRepo *git.Repository,
|
||||
consumerCommit *git.Commit,
|
||||
triggedEvent webhook_module.HookEventType,
|
||||
payload api.Payloader,
|
||||
) []*DetectedWorkflow {
|
||||
workflows := make([]*DetectedWorkflow, 0, len(parsed))
|
||||
) (matched, filtered []*DetectedWorkflow) {
|
||||
for _, p := range parsed {
|
||||
for _, evt := range p.Events {
|
||||
if evt.IsSchedule() {
|
||||
// schedule is a non-target for scoped workflows
|
||||
continue
|
||||
}
|
||||
if detectMatched(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
||||
workflows = append(workflows, &DetectedWorkflow{
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
})
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
}
|
||||
switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
matched = append(matched, dwf)
|
||||
case detectFilteredOut:
|
||||
filtered = append(filtered, dwf)
|
||||
case detectNotApplicable:
|
||||
}
|
||||
}
|
||||
}
|
||||
return workflows
|
||||
return matched, filtered
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ type DetectedWorkflow struct {
|
||||
Content []byte
|
||||
}
|
||||
|
||||
type detectResult int
|
||||
|
||||
const (
|
||||
detectMatched detectResult = iota // event matched; run normally
|
||||
detectNotApplicable // event/type doesn't apply; create nothing
|
||||
detectFilteredOut // matched but excluded by a branch/paths filter; posts a skipped commit status when the context is a required check
|
||||
)
|
||||
|
||||
func init() {
|
||||
model.OnDecodeNodeError = func(node yaml.Node, out any, err error) {
|
||||
// Log the error instead of panic or fatal.
|
||||
@@ -172,18 +180,16 @@ func DetectWorkflows(
|
||||
triggedEvent webhook_module.HookEventType,
|
||||
payload api.Payloader,
|
||||
detectSchedule bool,
|
||||
) ([]*DetectedWorkflow, []*DetectedWorkflow, error) {
|
||||
) (workflows, schedules, filtered []*DetectedWorkflow, err error) {
|
||||
_, entries, err := ListWorkflows(commit)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
workflows := make([]*DetectedWorkflow, 0, len(entries))
|
||||
schedules := make([]*DetectedWorkflow, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
content, err := GetContentFromEntry(entry)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// one workflow may have multiple events
|
||||
@@ -203,18 +209,24 @@ func DetectWorkflows(
|
||||
}
|
||||
schedules = append(schedules, dwf)
|
||||
}
|
||||
} else if detectMatched(gitRepo, commit, triggedEvent, payload, evt) {
|
||||
} else {
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
}
|
||||
workflows = append(workflows, dwf)
|
||||
switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
workflows = append(workflows, dwf)
|
||||
case detectFilteredOut:
|
||||
filtered = append(filtered, dwf)
|
||||
case detectNotApplicable:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return workflows, schedules, nil
|
||||
return workflows, schedules, filtered, nil
|
||||
}
|
||||
|
||||
func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*DetectedWorkflow, error) {
|
||||
@@ -252,9 +264,9 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
|
||||
return wfs, nil
|
||||
}
|
||||
|
||||
func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) bool {
|
||||
func detectWorkflowMatch(gitRepo *git.Repository, commit *git.Commit, triggedEvent webhook_module.HookEventType, payload api.Payloader, evt *jobparser.Event) detectResult {
|
||||
if !canGithubEventMatch(evt.Name, triggedEvent) {
|
||||
return false
|
||||
return detectNotApplicable
|
||||
}
|
||||
|
||||
switch triggedEvent {
|
||||
@@ -268,7 +280,7 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
|
||||
log.Warn("Ignore unsupported %s event arguments %v", triggedEvent, evt.Acts())
|
||||
}
|
||||
// no special filter parameters for these events, just return true if name matched
|
||||
return true
|
||||
return detectMatched
|
||||
|
||||
case // push
|
||||
webhook_module.HookEventPush:
|
||||
@@ -279,14 +291,20 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
|
||||
webhook_module.HookEventIssueAssign,
|
||||
webhook_module.HookEventIssueLabel,
|
||||
webhook_module.HookEventIssueMilestone:
|
||||
return matchIssuesEvent(payload.(*api.IssuePayload), evt)
|
||||
if matchIssuesEvent(payload.(*api.IssuePayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // issue_comment
|
||||
webhook_module.HookEventIssueComment,
|
||||
// `pull_request_comment` is same as `issue_comment`
|
||||
// See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_comment-use-issue_comment
|
||||
webhook_module.HookEventPullRequestComment:
|
||||
return matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt)
|
||||
if matchIssueCommentEvent(payload.(*api.IssueCommentPayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // pull_request
|
||||
webhook_module.HookEventPullRequest,
|
||||
@@ -300,34 +318,49 @@ func detectMatched(gitRepo *git.Repository, commit *git.Commit, triggedEvent web
|
||||
case // pull_request_review
|
||||
webhook_module.HookEventPullRequestReviewApproved,
|
||||
webhook_module.HookEventPullRequestReviewRejected:
|
||||
return matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt)
|
||||
if matchPullRequestReviewEvent(payload.(*api.PullRequestPayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // pull_request_review_comment
|
||||
webhook_module.HookEventPullRequestReviewComment:
|
||||
return matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt)
|
||||
if matchPullRequestReviewCommentEvent(payload.(*api.PullRequestPayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // release
|
||||
webhook_module.HookEventRelease:
|
||||
return matchReleaseEvent(payload.(*api.ReleasePayload), evt)
|
||||
if matchReleaseEvent(payload.(*api.ReleasePayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // registry_package
|
||||
webhook_module.HookEventPackage:
|
||||
return matchPackageEvent(payload.(*api.PackagePayload), evt)
|
||||
if matchPackageEvent(payload.(*api.PackagePayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
case // workflow_run
|
||||
webhook_module.HookEventWorkflowRun:
|
||||
return matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt)
|
||||
if matchWorkflowRunEvent(payload.(*api.WorkflowRunPayload), evt) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectNotApplicable
|
||||
|
||||
default:
|
||||
log.Warn("unsupported event %q", triggedEvent)
|
||||
return false
|
||||
return detectNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) bool {
|
||||
func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobparser.Event) detectResult {
|
||||
// with no special filter parameters
|
||||
if len(evt.Acts()) == 0 {
|
||||
return true
|
||||
return detectMatched
|
||||
}
|
||||
|
||||
matchTimes := 0
|
||||
@@ -393,14 +426,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
|
||||
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
|
||||
if err != nil {
|
||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
|
||||
} else {
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
return detectNotApplicable
|
||||
}
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
case "paths-ignore":
|
||||
if refName.IsTag() {
|
||||
@@ -410,14 +443,14 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
|
||||
filesChanged, err := commit.GetFilesChangedSinceCommit(pushPayload.Before)
|
||||
if err != nil {
|
||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", commit.ID.String(), err)
|
||||
} else {
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
return detectNotApplicable
|
||||
}
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
default:
|
||||
log.Warn("push event unsupported condition %q", cond)
|
||||
@@ -427,7 +460,10 @@ func matchPushEvent(commit *git.Commit, pushPayload *api.PushPayload, evt *jobpa
|
||||
if hasBranchFilter && hasTagFilter {
|
||||
matchTimes++
|
||||
}
|
||||
return matchTimes == len(evt.Acts())
|
||||
if matchTimes == len(evt.Acts()) {
|
||||
return detectMatched
|
||||
}
|
||||
return detectFilteredOut
|
||||
}
|
||||
|
||||
func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool {
|
||||
@@ -478,7 +514,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
|
||||
return matchTimes == len(evt.Acts())
|
||||
}
|
||||
|
||||
func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) bool {
|
||||
func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayload *api.PullRequestPayload, evt *jobparser.Event) detectResult {
|
||||
acts := evt.Acts()
|
||||
activityTypeMatched := false
|
||||
matchTimes := 0
|
||||
@@ -525,7 +561,7 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
|
||||
headCommit, err = gitRepo.GetCommit(prPayload.PullRequest.Head.Sha)
|
||||
if err != nil {
|
||||
log.Error("GetCommit [ref: %s]: %v", prPayload.PullRequest.Head.Sha, err)
|
||||
return false
|
||||
return detectNotApplicable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,33 +593,39 @@ func matchPullRequestEvent(gitRepo *git.Repository, commit *git.Commit, prPayloa
|
||||
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
|
||||
if err != nil {
|
||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
|
||||
} else {
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
return detectNotApplicable
|
||||
}
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Skip(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
case "paths-ignore":
|
||||
filesChanged, err := headCommit.GetFilesChangedSinceCommit(prPayload.PullRequest.MergeBase)
|
||||
if err != nil {
|
||||
log.Error("GetFilesChangedSinceCommit [commit_sha1: %s]: %v", headCommit.ID.String(), err)
|
||||
} else {
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
return detectNotApplicable
|
||||
}
|
||||
patterns, err := workflowpattern.CompilePatterns(vals...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !workflowpattern.Filter(patterns, filesChanged) {
|
||||
matchTimes++
|
||||
}
|
||||
default:
|
||||
log.Warn("pull request event unsupported condition %q", cond)
|
||||
}
|
||||
}
|
||||
return activityTypeMatched && matchTimes == len(evt.Acts())
|
||||
if !activityTypeMatched {
|
||||
return detectNotApplicable
|
||||
}
|
||||
if matchTimes != len(evt.Acts()) {
|
||||
return detectFilteredOut
|
||||
}
|
||||
return detectMatched
|
||||
}
|
||||
|
||||
func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *jobparser.Event) bool {
|
||||
|
||||
@@ -101,49 +101,49 @@ func TestDetectMatched(t *testing.T) {
|
||||
triggedEvent webhook_module.HookEventType
|
||||
payload api.Payloader
|
||||
yamlOn string
|
||||
expected bool
|
||||
expected detectResult
|
||||
}{
|
||||
{
|
||||
desc: "HookEventCreate(create) matches GithubEventCreate(create)",
|
||||
triggedEvent: webhook_module.HookEventCreate,
|
||||
payload: nil,
|
||||
yamlOn: "on: create",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)",
|
||||
triggedEvent: webhook_module.HookEventIssues,
|
||||
payload: &api.IssuePayload{Action: api.HookIssueOpened},
|
||||
yamlOn: "on: issues",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)",
|
||||
triggedEvent: webhook_module.HookEventIssues,
|
||||
payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
|
||||
yamlOn: "on: issues",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)",
|
||||
triggedEvent: webhook_module.HookEventPullRequestSync,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
|
||||
yamlOn: "on: pull_request",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
|
||||
triggedEvent: webhook_module.HookEventPullRequest,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
||||
yamlOn: "on: pull_request",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
|
||||
triggedEvent: webhook_module.HookEventPullRequest,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueClosed},
|
||||
yamlOn: "on: pull_request",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches",
|
||||
@@ -155,56 +155,56 @@ func TestDetectMatched(t *testing.T) {
|
||||
},
|
||||
},
|
||||
yamlOn: "on:\n pull_request:\n branches: [main]",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type",
|
||||
triggedEvent: webhook_module.HookEventPullRequest,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
||||
yamlOn: "on:\n pull_request:\n types: [labeled]",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)",
|
||||
triggedEvent: webhook_module.HookEventPullRequestReviewComment,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
||||
yamlOn: "on:\n pull_request_review_comment:\n types: [created]",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match GithubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)",
|
||||
triggedEvent: webhook_module.HookEventPullRequestReviewRejected,
|
||||
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
||||
yamlOn: "on:\n pull_request_review:\n types: [dismissed]",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type",
|
||||
triggedEvent: webhook_module.HookEventRelease,
|
||||
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
|
||||
yamlOn: "on:\n release:\n types: [published]",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
|
||||
triggedEvent: webhook_module.HookEventPackage,
|
||||
payload: &api.PackagePayload{Action: api.HookPackageCreated},
|
||||
yamlOn: "on:\n registry_package:\n types: [updated]",
|
||||
expected: false,
|
||||
expected: detectNotApplicable,
|
||||
},
|
||||
{
|
||||
desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)",
|
||||
triggedEvent: webhook_module.HookEventWiki,
|
||||
payload: nil,
|
||||
yamlOn: "on: gollum",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)",
|
||||
triggedEvent: webhook_module.HookEventSchedule,
|
||||
payload: nil,
|
||||
yamlOn: "on: schedule",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "push to tag matches workflow with paths condition (should skip paths check)",
|
||||
@@ -222,7 +222,19 @@ func TestDetectMatched(t *testing.T) {
|
||||
},
|
||||
commit: nil,
|
||||
yamlOn: "on:\n push:\n paths:\n - src/**",
|
||||
expected: true,
|
||||
expected: detectMatched,
|
||||
},
|
||||
{
|
||||
desc: "push branch filter excludes -> filtered out",
|
||||
triggedEvent: webhook_module.HookEventPush,
|
||||
payload: &api.PushPayload{
|
||||
Ref: "refs/heads/feature/x",
|
||||
Before: "0000000",
|
||||
Commits: []*api.PayloadCommit{{ID: "abc", Added: []string{"a.go"}, Message: "x"}},
|
||||
},
|
||||
commit: nil,
|
||||
yamlOn: "on:\n push:\n branches: [main]",
|
||||
expected: detectFilteredOut,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -231,7 +243,7 @@ func TestDetectMatched(t *testing.T) {
|
||||
evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn))
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, evts, 1)
|
||||
assert.Equal(t, tc.expected, detectMatched(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0]))
|
||||
assert.Equal(t, tc.expected, detectWorkflowMatch(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0]))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
package openid
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/hostmatcher"
|
||||
"gitea.dev/modules/proxy"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/yohcop/openid-go"
|
||||
)
|
||||
|
||||
@@ -19,11 +25,23 @@ import (
|
||||
var (
|
||||
nonceStore = openid.NewSimpleNonceStore()
|
||||
discoveryCache = newTimedDiscoveryCache(24 * time.Hour)
|
||||
|
||||
// openIDInstance does discovery/verification via an SSRF-protected client, so a user-supplied
|
||||
// OpenID identifier can't reach internal/loopback/reserved addresses. It honors the operator's
|
||||
// [security] ALLOWED_HOST_LIST (empty defaults to "external"), matching the avatar/webhook/migration
|
||||
// clients, and validates the proxy path too. Lazy: reads proxy/settings once.
|
||||
openIDInstance = sync.OnceValue(func() *openid.OpenID {
|
||||
allowList := hostmatcher.ParseHostMatchList("security.ALLOWED_HOST_LIST", setting.Security.AllowedHostList)
|
||||
return openid.NewOpenID(&http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: hostmatcher.NewHTTPTransport("openid", allowList, nil, proxy.Proxy(), setting.Proxy.ProxyURLFixed, nil),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Verify handles response from OpenID provider
|
||||
func Verify(fullURL string) (id string, err error) {
|
||||
return openid.Verify(fullURL, discoveryCache, nonceStore)
|
||||
return openIDInstance().Verify(fullURL, discoveryCache, nonceStore)
|
||||
}
|
||||
|
||||
// Normalize normalizes an OpenID URI
|
||||
@@ -33,5 +51,5 @@ func Normalize(url string) (id string, err error) {
|
||||
|
||||
// RedirectURL redirects browser
|
||||
func RedirectURL(id, callbackURL, realm string) (string, error) {
|
||||
return openid.RedirectURL(id, callbackURL, realm)
|
||||
return openIDInstance().RedirectURL(id, callbackURL, realm)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package openid
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenIDDiscoveryBlocksInternalHost(t *testing.T) {
|
||||
var reached atomic.Bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// RedirectURL performs server-side discovery of the identifier URL; a loopback URL
|
||||
// must be refused at dial time instead of reaching the internal server
|
||||
_, err := RedirectURL(srv.URL, "http://example.com/callback", "http://example.com/")
|
||||
require.Error(t, err)
|
||||
assert.False(t, reached.Load(), "OpenID discovery must not reach an internal/loopback host")
|
||||
}
|
||||
@@ -28,13 +28,10 @@ func Init() {
|
||||
|
||||
WebAuthn = &webauthn.WebAuthn{
|
||||
Config: &webauthn.Config{
|
||||
RPDisplayName: setting.AppName,
|
||||
RPID: setting.Domain,
|
||||
RPOrigins: []string{appURL},
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
UserVerification: protocol.VerificationDiscouraged,
|
||||
},
|
||||
AttestationPreference: protocol.PreferDirectAttestation,
|
||||
RPDisplayName: setting.AppName,
|
||||
RPID: setting.Domain,
|
||||
RPOrigins: []string{appURL},
|
||||
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,22 @@ import (
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// CoAuthoredByTrailer is the canonical token for the `Co-authored-by:` git trailer.
|
||||
const CoAuthoredByTrailer = "Co-authored-by"
|
||||
|
||||
const (
|
||||
commitIdentityRoleAuthor = 1
|
||||
commitIdentityRoleCommitter = 2
|
||||
commitIdentityRoleCoAuthor = 3
|
||||
)
|
||||
|
||||
type CommitIdentity struct {
|
||||
Name string
|
||||
Email string
|
||||
role int
|
||||
}
|
||||
|
||||
// CommitMessageTrailerValues keys are all in lower-case
|
||||
@@ -33,7 +39,7 @@ type CommitMessage struct {
|
||||
|
||||
trailerValues CommitMessageTrailerValues
|
||||
|
||||
allParticipants []*CommitIdentity
|
||||
allAuthors []*CommitIdentity
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageUTF8() string {
|
||||
@@ -69,8 +75,12 @@ func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
|
||||
}
|
||||
|
||||
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp {
|
||||
// the sep is either something like "\n---\n" or "\n\n" in the body, or at the start of the body like "---\n"
|
||||
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n-{3,}\n+|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*\n*)$`)
|
||||
// ref: https://git-scm.com/docs/git-interpret-trailers
|
||||
// TODO: the regexp is not able to perfectly parse the all kinds of trailers
|
||||
// It was just copied from legacy code, it is not exactly the same as how Git parses the trailer and not quite right in some cases.
|
||||
// For the key characters: it follows RFC 822 field name syntax (or RFC 2822/RFC 5322): printable ASCII characters between 33 and 126 except the colon (:),
|
||||
// but maybe we don't want to make it that complicated, so here we only support some common "symbol-like" characters.
|
||||
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n+-{3,}\n+|\n{2,})(?P<trailer>(?:[A-Za-z0-9][-\w]*:[^\n]*(\n\s+[^\n]*)*\n?)*\n*)$`)
|
||||
})
|
||||
|
||||
// CommitMessageSplitTrailer tries to split the message by the trailer separator
|
||||
@@ -85,6 +95,41 @@ func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
|
||||
return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")]
|
||||
}
|
||||
|
||||
// CommitMessageMerge merges two commit messages with their trailers
|
||||
func CommitMessageMerge(m1, m2 string) string {
|
||||
c1, s1, t1 := CommitMessageSplitTrailer(m1)
|
||||
c2, s2, t2 := CommitMessageSplitTrailer(m2)
|
||||
c1, t1 = strings.TrimSpace(c1), strings.TrimSpace(t1)
|
||||
c2, t2 = strings.TrimSpace(c2), strings.TrimSpace(t2)
|
||||
out := strings.Builder{}
|
||||
if c1 != "" && c2 != "" {
|
||||
out.WriteString(c1)
|
||||
out.WriteString("\n\n")
|
||||
out.WriteString(c2)
|
||||
} else if c1 != "" {
|
||||
out.WriteString(c1)
|
||||
} else if c2 != "" {
|
||||
out.WriteString(c2)
|
||||
}
|
||||
if t1 != "" || t2 != "" {
|
||||
sep := util.Iif(t1 == "", s2, s1)
|
||||
sep = util.IfZero(sep, "\n\n")
|
||||
if c1 != "" || c2 != "" {
|
||||
out.WriteString(sep)
|
||||
}
|
||||
if t1 != "" {
|
||||
out.WriteString(t1)
|
||||
}
|
||||
if t1 != "" && t2 != "" {
|
||||
out.WriteString("\n")
|
||||
}
|
||||
if t2 != "" {
|
||||
out.WriteString(t2)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||
ret := CommitMessageTrailerValues{}
|
||||
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
|
||||
@@ -99,35 +144,50 @@ func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||
return ret
|
||||
}
|
||||
|
||||
// AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author
|
||||
func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
|
||||
if c.allParticipants != nil {
|
||||
return c.allParticipants
|
||||
// AllAuthorIdentities returns all the author and co-authors in the commit. Committer is not included:
|
||||
// * Author & Co-author: they changed the code (attribution)
|
||||
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
|
||||
func (c *Commit) AllAuthorIdentities() []*CommitIdentity {
|
||||
if c.allAuthors != nil {
|
||||
return c.allAuthors
|
||||
}
|
||||
|
||||
exclude := container.Set[string]{}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: c.Author.Name, Email: c.Author.Email})
|
||||
exclude.Add(strings.ToLower(c.Author.Email))
|
||||
|
||||
addParticipant := func(name, email string) {
|
||||
trailerCoAuthors := c.MessageTrailer()["co-authored-by"]
|
||||
c.allAuthors = make([]*CommitIdentity, 0, 1+len(trailerCoAuthors))
|
||||
exclude := map[string]int{}
|
||||
addAuthor := func(name, email string, role int) {
|
||||
if name == "" && email == "" {
|
||||
return
|
||||
}
|
||||
emailLower := strings.ToLower(email)
|
||||
if emailLower != "" && exclude.Contains(emailLower) {
|
||||
key := strings.ToLower(email)
|
||||
if key == "" {
|
||||
key = strings.ToLower(name)
|
||||
}
|
||||
if existingRole := exclude[key]; key != "" && existingRole != 0 {
|
||||
return
|
||||
}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email})
|
||||
exclude.Add(emailLower)
|
||||
c.allAuthors = append(c.allAuthors, &CommitIdentity{Name: name, Email: email, role: role})
|
||||
exclude[key] = role
|
||||
}
|
||||
addParticipant(c.Committer.Name, c.Committer.Email)
|
||||
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
|
||||
|
||||
addAuthor(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
|
||||
for _, coAuthorValue := range trailerCoAuthors {
|
||||
addr, err := mail.ParseAddress(coAuthorValue)
|
||||
coAuthorName, coAuthorEmail := coAuthorValue, ""
|
||||
if err == nil {
|
||||
addParticipant(addr.Name, addr.Address)
|
||||
} else {
|
||||
addParticipant(coAuthorValue, "")
|
||||
coAuthorName, coAuthorEmail = addr.Name, addr.Address
|
||||
}
|
||||
addAuthor(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
|
||||
}
|
||||
return c.allParticipants
|
||||
return c.allAuthors
|
||||
}
|
||||
|
||||
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
|
||||
all := c.AllAuthorIdentities()
|
||||
if len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
if all[0].role == commitIdentityRoleAuthor {
|
||||
return all[1:]
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
@@ -26,10 +26,12 @@ func TestCommitMessageTrailer(t *testing.T) {
|
||||
{"a", "a", "", ""},
|
||||
{"a\n\nk", "a\n\nk", "", ""},
|
||||
{"a\n\nk:v", "a", "\n\n", "k:v"},
|
||||
{"a\n\nk:v\n next-line", "a", "\n\n", "k:v\n next-line"},
|
||||
{"a\n\nk:v\n next-line\nother: v", "a", "\n\n", "k:v\n next-line\nother: v"},
|
||||
{"a\n\nk:v\n\n", "a", "\n\n", "k:v\n\n"},
|
||||
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
|
||||
{"a\n---\nk:v", "a", "\n---\n", "k:v"},
|
||||
{"a\n\n---\n\nk:v", "a\n", "\n---\n\n", "k:v"},
|
||||
{"a\n---\nk:v", "a", "\n---\n", "k:v"}, // TODO: should we support such case? No empty line between "---" and the trailer
|
||||
{"a\n\n---\n\nk:v", "a", "\n\n---\n\n", "k:v"},
|
||||
|
||||
{"k: v", "", "", "k: v"},
|
||||
{"\nk:v", "", "\n", "k:v"},
|
||||
@@ -47,36 +49,114 @@ func TestCommitMessageTrailer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitMessageAllParticipantIdentities(t *testing.T) {
|
||||
func TestCommitMessageParticipants(t *testing.T) {
|
||||
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
|
||||
idt := func(n, e string) *CommitIdentity { return &CommitIdentity{Name: n, Email: e} }
|
||||
cases := []struct {
|
||||
commit *Commit
|
||||
participant []*CommitIdentity
|
||||
}{
|
||||
{
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com"), idt("c", "c@m.com"), idt("", "x@m.com")},
|
||||
},
|
||||
{
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com")},
|
||||
},
|
||||
{
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("", ""),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com"), idt("Full Name", "X@M.com")},
|
||||
},
|
||||
idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} }
|
||||
roleAuthor, _, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
|
||||
type testCase struct {
|
||||
name string
|
||||
commit *Commit
|
||||
identities []*CommitIdentity
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.participant, c.commit.AllParticipantIdentities())
|
||||
|
||||
t.Run("AllAuthors", func(t *testing.T) {
|
||||
cases := []testCase{
|
||||
{
|
||||
"CommitterExcluded",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: Full Name <x@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "x@m.com", roleCoAuthor)},
|
||||
},
|
||||
{
|
||||
"AuthorIsCoAuthor",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: other-name <a@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
|
||||
},
|
||||
{
|
||||
"EmptyAuthor", // synthesized commits (push feed) may have no author signature at all
|
||||
&Commit{
|
||||
Author: sig("", ""), Committer: sig("", ""),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
|
||||
},
|
||||
// but if the commit message contains co-authors, the co-authors are still parsed for "all authors"
|
||||
// if it is a problem, the caller should fix the problem (provide correct "author")
|
||||
[]*CommitIdentity{idt("c", "c@m.com", roleCoAuthor)},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.identities, c.commit.AllAuthorIdentities(), "case: %s", c.name)
|
||||
}
|
||||
})
|
||||
t.Run("CoAuthors", func(t *testing.T) {
|
||||
cases := []testCase{
|
||||
{
|
||||
"GenuineCoAuthor",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor)},
|
||||
},
|
||||
{
|
||||
"CoAuthorIsCommitter",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("c", "c@m.com", roleCoAuthor)},
|
||||
},
|
||||
{
|
||||
"CoAuthorIsAuthor",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: a <a@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{},
|
||||
},
|
||||
{
|
||||
"CoAuthorNameOnlyAndDuplicate",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: b\nCo-authored-by: b\nCo-authored-by: c"},
|
||||
},
|
||||
[]*CommitIdentity{idt("b", "", roleCoAuthor), idt("c", "", roleCoAuthor)},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.identities, c.commit.CoAuthorIdentities(), "case: %s", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommitMessageMerge(t *testing.T) {
|
||||
cases := []struct {
|
||||
m1, m2 string
|
||||
out string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"msg1", "", "msg1"},
|
||||
{"", "msg2", "msg2"},
|
||||
{"msg1", "msg2", "msg1\n\nmsg2"},
|
||||
{"k1: a", "", "k1: a"},
|
||||
{"", "k2: b", "k2: b"},
|
||||
{"k1: a", "k2: b", "k1: a\nk2: b"},
|
||||
{"msg1", "k2: b", "msg1\n\nk2: b"},
|
||||
{"k1: a", "msg2", "msg2\n\nk1: a"},
|
||||
{"msg1\n\nk1: a", "msg2", "msg1\n\nmsg2\n\nk1: a"},
|
||||
{"msg1\n----\nk1: a", "msg2", "msg1\n\nmsg2\n----\nk1: a"},
|
||||
{"msg1\n\n----\n\nk1: a", "msg2", "msg1\n\nmsg2\n\n----\n\nk1: a"},
|
||||
{"msg1", "msg2\n----\nk2: b", "msg1\n\nmsg2\n----\nk2: b"},
|
||||
{"msg1", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk2: b"},
|
||||
{"msg1\n\nk1: a", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk1: a\nk2: b"},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
out := CommitMessageMerge(c.m1, c.m2)
|
||||
assert.Equal(t, c.out, out, "idx=%d, m1=%q m2=%q", i, c.m1, c.m2)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -91,7 +91,7 @@ func syncGitConfig(ctx context.Context) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// By default partial clones are disabled, enable them from git v2.22
|
||||
// By default, partial clones are disabled, enable them from git v2.22
|
||||
if !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") {
|
||||
if err = configSet(ctx, "uploadpack.allowfilter", "true"); err != nil {
|
||||
return err
|
||||
@@ -114,9 +114,23 @@ func syncGitConfig(ctx context.Context) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
GlobalConfig = &GlobalConfigStruct{}
|
||||
// HINT: GIT-DIFF-TREE-UI-CONFIG: Git's bug: git-diff-tree loads config with /* no "diff" UI options */ (since 20 years ago).
|
||||
// https://github.com/git/git/blame/5d2e7709234afea1b6ddb25cd4f60d3d5fb3c200/builtin/diff-tree.c#L127
|
||||
// Although document and manual say that "git-diff-tree" supports "diff.orderfile" option, but it is not actually supported.
|
||||
// So we need to apply the diff.orderfile explicitly in our code.
|
||||
GlobalConfig.DiffOrderFile, _ = configGet(ctx, "diff.orderfile")
|
||||
return nil
|
||||
}
|
||||
|
||||
func configGet(ctx context.Context, key string) (string, error) {
|
||||
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx)
|
||||
if err != nil && !gitcmd.IsErrorExitCode(err, 1) {
|
||||
return "", fmt.Errorf("failed to get git config %s, err: %w", key, err)
|
||||
}
|
||||
return strings.TrimRight(stdout, "\r\n"), nil
|
||||
}
|
||||
|
||||
func configSet(ctx context.Context, key, value string) error {
|
||||
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").
|
||||
AddDynamicArguments(key).
|
||||
|
||||
+8
-1
@@ -36,7 +36,14 @@ type Features struct {
|
||||
SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base"
|
||||
}
|
||||
|
||||
var defaultFeatures *Features
|
||||
type GlobalConfigStruct struct {
|
||||
DiffOrderFile string
|
||||
}
|
||||
|
||||
var (
|
||||
defaultFeatures *Features
|
||||
GlobalConfig *GlobalConfigStruct
|
||||
)
|
||||
|
||||
func (f *Features) CheckVersionAtLeast(atLeast string) bool {
|
||||
return f.gitVersion.Compare(version.Must(version.NewVersion(atLeast))) >= 0
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package git
|
||||
|
||||
import "gitea.dev/modules/git/gitcmd"
|
||||
|
||||
func HandleGitCmdHTTPRedirection(cmd *gitcmd.Command, targets ...string) {
|
||||
// Protect from SSRF vector (e.g. migrating from an attacker URL).
|
||||
// cmd.AddConfig("http.followRedirects", "false")
|
||||
// However, we can't do so at the moment:
|
||||
// this fails due to 301: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}
|
||||
// this succeeds: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}.git
|
||||
// FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future
|
||||
}
|
||||
+1
-3
@@ -121,9 +121,7 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand().AddArguments("clone")
|
||||
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
|
||||
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
|
||||
cmd.AddArguments("-c", "http.followRedirects=false")
|
||||
HandleGitCmdHTTPRedirection(cmd, from, to)
|
||||
if opts.SkipTLSVerify {
|
||||
cmd.AddArguments("-c", "http.sslVerify=false")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestRepoIsEmpty(t *testing.T) {
|
||||
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
|
||||
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
|
||||
func TestCloneRefusesRedirects(t *testing.T) {
|
||||
t.Skip("FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future")
|
||||
var targetHit atomic.Bool
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
targetHit.Store(true)
|
||||
|
||||
@@ -21,19 +21,6 @@ func TestCommitsCount(t *testing.T) {
|
||||
assert.Equal(t, int64(3), commitsCount)
|
||||
}
|
||||
|
||||
func TestCommitsCountWithoutBase(t *testing.T) {
|
||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||
|
||||
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
|
||||
CommitsCountOptions{
|
||||
Not: "master",
|
||||
Revision: []string{"branch1"},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), commitsCount)
|
||||
}
|
||||
|
||||
func TestCommitsCountWithSinceUntil(t *testing.T) {
|
||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||
revision := []string{"8006ff9adbf0cb94da7dad9e537e53817f9fa5c0"}
|
||||
@@ -65,6 +52,19 @@ func TestCommitsCountWithSinceUntil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitsCountWithoutBase(t *testing.T) {
|
||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||
|
||||
commitsCount, err := CommitsCount(t.Context(), bareRepo1,
|
||||
CommitsCountOptions{
|
||||
Not: "master",
|
||||
Revision: []string{"branch1"},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), commitsCount)
|
||||
}
|
||||
|
||||
func TestGetLatestCommitTime(t *testing.T) {
|
||||
bareRepo1 := &mockRepository{path: "repo1_bare"}
|
||||
lct, err := GetLatestCommitTime(t.Context(), bareRepo1)
|
||||
|
||||
@@ -5,8 +5,10 @@ package hostmatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -63,3 +65,17 @@ func NewDialContext(usage string, allowList, blockList *HostMatchList, proxy *ur
|
||||
return dialer.DialContext(ctx, network, addrOrHost)
|
||||
}
|
||||
}
|
||||
|
||||
// NewHTTPTransport builds an http.Transport that validates the request target against the allow/block
|
||||
// lists on the direct-dial path (DialContext). When an HTTP proxy is configured the proxy resolves and
|
||||
// dials the target itself, so restricting the proxied target is the proxy server's responsibility, not
|
||||
// Gitea's. proxyFunc selects the proxy URL per request (the http.Transport.Proxy selector, e.g.
|
||||
// proxy.Proxy()); proxyURLFixed is the fixed proxy address the dialer must always permit; tlsConfig may
|
||||
// be nil. blockList may be nil for callers that only maintain an allow-list.
|
||||
func NewHTTPTransport(usage string, allowList, blockList *HostMatchList, proxyFunc func(*http.Request) (*url.URL, error), proxyURLFixed *url.URL, tlsConfig *tls.Config) *http.Transport {
|
||||
return &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
Proxy: proxyFunc,
|
||||
DialContext: NewDialContext(usage, allowList, blockList, proxyURLFixed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ func createRequest(ctx context.Context, method, url string, headers map[string]s
|
||||
}
|
||||
|
||||
// performRequest sends a request, optionally performs a callback on the request and returns the response.
|
||||
// If the status code is 200, the response is returned, and it will contain a non-nil Body.
|
||||
// If the status code is in the 2xx range, the response is returned, and it will contain a non-nil Body.
|
||||
// Otherwise, it will return an error, and the Body will be nil or closed.
|
||||
func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
log.Trace("performRequest: %s", req.URL)
|
||||
@@ -264,7 +264,7 @@ func performRequest(ctx context.Context, client *http.Client, req *http.Request)
|
||||
return res, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
defer res.Body.Close()
|
||||
return res, handleErrorResponse(res)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,15 @@ func TestBasicTransferAdapter(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Upload created", func(t *testing.T) {
|
||||
client := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(""))}
|
||||
})}
|
||||
adapter := &BasicTransferAdapter{client: client}
|
||||
err := adapter.Upload(t.Context(), &Link{Href: "https://upload-created-request.io"}, p, strings.NewReader("dummy"))
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Verify", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
link *Link
|
||||
|
||||
Vendored
+1
-8
@@ -21,19 +21,12 @@ type frontendRenderer struct {
|
||||
patterns []string
|
||||
}
|
||||
|
||||
var (
|
||||
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
|
||||
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||
)
|
||||
var _ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||
|
||||
func (p *frontendRenderer) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) NeedPostProcess() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) FileNamePatterns() []string {
|
||||
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
|
||||
// There are some approaches to make it more accurate, but they are all complicated:
|
||||
|
||||
@@ -29,9 +29,8 @@ func init() {
|
||||
type renderer struct{}
|
||||
|
||||
var (
|
||||
_ markup.Renderer = (*renderer)(nil)
|
||||
_ markup.PostProcessRenderer = (*renderer)(nil)
|
||||
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
||||
_ markup.Renderer = (*renderer)(nil)
|
||||
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
||||
)
|
||||
|
||||
type mimeHandler struct {
|
||||
@@ -96,8 +95,6 @@ func (renderer) Name() string {
|
||||
return "jupyter-render"
|
||||
}
|
||||
|
||||
func (renderer) NeedPostProcess() bool { return true }
|
||||
|
||||
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
|
||||
return markup.ExternalRendererOptions{
|
||||
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
|
||||
@@ -215,7 +212,7 @@ func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) erro
|
||||
|
||||
// Highlight code
|
||||
lexer := highlight.DetectChromaLexerByFileName("", language)
|
||||
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(language))
|
||||
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(lexer.Config().Name))
|
||||
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
|
||||
output.WriteHTML("</code></pre></div>")
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
maliciousNotebook := `{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2,
|
||||
"metadata": {},
|
||||
"metadata": {"language_info":{"name":"any lang"}},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -274,7 +274,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
"execution_count": 1,
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div><script>alert('XSS Vector')</script><table class=\"dataframe\"><tr><td>Safe Content</td></tr></table></div>"
|
||||
"<div><script>foo</script><table class=other><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>"
|
||||
]
|
||||
},
|
||||
"metadata": {}
|
||||
@@ -295,8 +295,8 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt">In [1]:</div>
|
||||
<div class="cell-right cell-input">
|
||||
<pre><code class="chroma language-python">
|
||||
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>
|
||||
<pre><code class="chroma language-fallback">
|
||||
a=1
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,7 +304,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
<div class="cell-left cell-prompt">Out [1]:</div>
|
||||
<div class="cell-right cell-output">
|
||||
<div class="cell-output-html">
|
||||
<div><table><tbody><tr><td>Safe Content</td></tr></tbody></table></div>
|
||||
<div><table><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
func TestMain(m *testing.M) {
|
||||
setting.IsInTesting = true
|
||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||
setting.Markdown.FileNamePatterns = []string{"*.md"}
|
||||
markup.RefreshFileNamePatterns()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
@@ -70,7 +70,15 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
|
||||
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
|
||||
htmlWriter.ExtendingWriter = w
|
||||
|
||||
res, err := org.New().Silent().Parse(input, "").Write(w)
|
||||
cfg := org.New()
|
||||
cfg.ReadFile = func(path string) ([]byte, error) {
|
||||
// actually the orgmode render doesn't support rendering the content from the content again,
|
||||
// so just leave the plain text to end users
|
||||
content := fmt.Sprintf("#+INCLUDE: [[%s]]", path)
|
||||
return []byte(content), nil
|
||||
}
|
||||
doc := cfg.Silent().Parse(input, "")
|
||||
res, err := doc.Write(w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orgmode.Render failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -21,86 +21,74 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
func testRender(t *testing.T, input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[https://google.com/]]",
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
testRender(t, "[[https://google.com/]]",
|
||||
`<p><a href="https://google.com/">https://google.com/</a></p>`)
|
||||
test("[[ImageLink.svg][The Image Desc]]",
|
||||
testRender(t, "[[ImageLink.svg][The Image Desc]]",
|
||||
`<p><a href="ImageLink.svg">The Image Desc</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_InternalLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:test.org][Test]]",
|
||||
testRender(t, "[[file:test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[./test.org][Test]]",
|
||||
testRender(t, "[[./test.org][Test]]",
|
||||
`<p><a href="./test.org">Test</a></p>`)
|
||||
test("[[test.org][Test]]",
|
||||
testRender(t, "[[test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[path/to/test.org][Test]]",
|
||||
testRender(t, "[[path/to/test.org][Test]]",
|
||||
`<p><a href="path/to/test.org">Test</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Media(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:../../.images/src/02/train.jpg]]",
|
||||
testRender(t, "[[file:../../.images/src/02/train.jpg]]",
|
||||
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`)
|
||||
test("[[file:train.jpg]]",
|
||||
testRender(t, "[[file:train.jpg]]",
|
||||
`<p><img src="train.jpg" alt="train.jpg"></p>`)
|
||||
|
||||
// With description.
|
||||
test("[[https://example.com][https://example.com/example.svg]]",
|
||||
testRender(t, "[[https://example.com][https://example.com/example.svg]]",
|
||||
`<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.svg post]]",
|
||||
testRender(t, "[[https://example.com][pre https://example.com/example.svg post]]",
|
||||
`<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg"> post</a></p>`)
|
||||
test("[[https://example.com][https://example.com/example.mp4]]",
|
||||
testRender(t, "[[https://example.com][https://example.com/example.mp4]]",
|
||||
`<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.mp4 post]]",
|
||||
testRender(t, "[[https://example.com][pre https://example.com/example.mp4 post]]",
|
||||
`<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
|
||||
|
||||
// Without description.
|
||||
test("[[https://example.com/example.svg]]",
|
||||
testRender(t, "[[https://example.com/example.svg]]",
|
||||
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></p>`)
|
||||
test("[[https://example.com/example.mp4]]",
|
||||
testRender(t, "[[https://example.com/example.mp4]]",
|
||||
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
|
||||
|
||||
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
|
||||
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
testRender(t, `[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg"></a></p>`)
|
||||
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
testRender(t, `[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
|
||||
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
testRender(t, `[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
testRender(t, `[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Source(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test(`#+begin_src c
|
||||
testRender(t, `#+begin_src c
|
||||
int a;
|
||||
#+end_src
|
||||
`, `<div class="src src-c">
|
||||
<pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
|
||||
</div>`)
|
||||
}
|
||||
|
||||
func TestRender_IncludeLink(t *testing.T) {
|
||||
testRender(t, `#+INCLUDE: "./other.org" src text`, `<div class="src src-text">
|
||||
<pre><code class="chroma language-plaintext">#+INCLUDE: [[other.org]]</code></pre>
|
||||
</div>`)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/packages"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/validation"
|
||||
|
||||
@@ -46,6 +47,11 @@ var (
|
||||
namePattern = regexp.MustCompile(`\A[a-zA-Z0-9@._+-]+\z`)
|
||||
// (epoch:pkgver-pkgrel)
|
||||
versionPattern = regexp.MustCompile(`\A(?:\d:)?[\w.+~]+(?:-[-\w.+~]+)?\z`)
|
||||
|
||||
// caps on the accumulated package file list (vars so tests can lower them); far above
|
||||
// any legitimate package, but low enough to stop metadata amplification
|
||||
maxFileEntries = 100000
|
||||
maxFileNameBytes = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
@@ -124,7 +130,7 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
}
|
||||
|
||||
var p *Package
|
||||
files := make([]string, 0, 10)
|
||||
files := packages.NewBoundedFileList(maxFileEntries, maxFileNameBytes)
|
||||
|
||||
tr := tar.NewReader(inner)
|
||||
for {
|
||||
@@ -147,7 +153,12 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
return nil, err
|
||||
}
|
||||
} else if !strings.HasPrefix(filename, ".") {
|
||||
files = append(files, hd.Name)
|
||||
if strings.ContainsAny(hd.Name, "\n\r") {
|
||||
continue // a newline would forge extra lines in the pacman index
|
||||
}
|
||||
if err := files.Add(hd.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +166,7 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
return nil, ErrMissingPKGINFOFile
|
||||
}
|
||||
|
||||
p.FileMetadata.Files = files
|
||||
p.FileMetadata.Files = files.Files()
|
||||
p.FileCompressionExtension = compressionType
|
||||
|
||||
return p, nil
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/ulikunitz/xz"
|
||||
@@ -101,6 +104,7 @@ func TestParsePackage(t *testing.T) {
|
||||
data := createPackage(c, map[string][]byte{
|
||||
".PKGINFO": createPKGINFOContent(packageName, packageVersion),
|
||||
"/test/dummy.txt": {},
|
||||
"usr/lib/legit\n\n%FILES%\n/etc/cron.d/x": {}, // must not reach the file list
|
||||
})
|
||||
|
||||
p, err := ParsePackage(data)
|
||||
@@ -167,3 +171,25 @@ func TestParsePackageInfo(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{"usr/bin/paket1"}, p.FileMetadata.Backup)
|
||||
})
|
||||
}
|
||||
|
||||
// TestParsePackageTooManyFiles ensures the accumulated file list is bounded to prevent
|
||||
// metadata amplification from a package with a huge number of (tiny) file entries.
|
||||
func TestParsePackageTooManyFiles(t *testing.T) {
|
||||
defer test.MockVariableValue(&maxFileEntries, 3)()
|
||||
buf := test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{
|
||||
"file1": "content1",
|
||||
".PKGINFO": string(createPKGINFOContent(packageName, packageVersion)),
|
||||
})
|
||||
_, err := ParsePackage(buf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
buf = test.WriteTarCompression(func(w io.Writer) io.WriteCloser { return gzip.NewWriter(w) }, map[string]string{
|
||||
"file1": "content1",
|
||||
"file2": "content2",
|
||||
"file3": "content3",
|
||||
"file4": "content4",
|
||||
".PKGINFO": string(createPKGINFOContent(packageName, packageVersion)),
|
||||
})
|
||||
_, err = ParsePackage(buf)
|
||||
assert.ErrorIs(t, err, util.ErrInvalidArgument)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,10 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
return nil, GlobalVars().ErrUnsupportedCompression
|
||||
}
|
||||
|
||||
tr := tar.NewReader(inner)
|
||||
// bound the decompressed control archive: it holds only the small control file
|
||||
// and maintainer scripts, so a much larger stream is a decompression bomb
|
||||
const maxControlTarSize = 32 * 1024 * 1024
|
||||
tr := tar.NewReader(io.LimitReader(inner, maxControlTarSize))
|
||||
for {
|
||||
hd, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
@@ -168,6 +171,7 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
key := ""
|
||||
var depends strings.Builder
|
||||
var control strings.Builder
|
||||
var description strings.Builder
|
||||
|
||||
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#syntax-of-control-files
|
||||
s := bufio.NewScanner(r)
|
||||
@@ -189,10 +193,13 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
control.WriteString(line)
|
||||
control.WriteByte('\n')
|
||||
|
||||
// a leading space or tab marks a folded continuation line that belongs to the previous field
|
||||
// (identified by key), not a new "Key: value" pair; only the multi-line fields append here.
|
||||
// Continuation lines may themselves contain a colon, so they must not be re-split on ":".
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
switch key {
|
||||
case "Description":
|
||||
p.Metadata.Description += line
|
||||
description.WriteString(line)
|
||||
case "Depends":
|
||||
depends.WriteString(trimmed)
|
||||
}
|
||||
@@ -219,7 +226,8 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
p.Metadata.Maintainer = a.Name
|
||||
}
|
||||
case "Description":
|
||||
p.Metadata.Description = value
|
||||
description.Reset()
|
||||
description.WriteString(value)
|
||||
case "Depends":
|
||||
depends.WriteString(value)
|
||||
case "Homepage":
|
||||
@@ -243,6 +251,8 @@ func ParseControlFile(r io.Reader) (*Package, error) {
|
||||
return nil, GlobalVars().ErrInvalidArchitecture
|
||||
}
|
||||
|
||||
p.Metadata.Description = description.String()
|
||||
|
||||
dependencies := strings.Split(depends.String(), ",")
|
||||
for i := range dependencies {
|
||||
dependencies[i] = strings.TrimSpace(dependencies[i])
|
||||
|
||||
@@ -232,3 +232,15 @@ func TestValidateDistributionOrComponent(t *testing.T) {
|
||||
assert.True(t, IsValidDistributionOrComponent(name), "good=%q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseControlFileMultilineDescription verifies a multi-line Description is assembled in order
|
||||
// (the parser accumulates it in a strings.Builder); it guards the assembled value, not its timing.
|
||||
func TestParseControlFileMultilineDescription(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("Package: testpkg\nVersion: 1.0\nArchitecture: amd64\nDescription: short summary\n more details\n even more\n")
|
||||
|
||||
p, err := ParseControlFile(&buf)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
assert.Equal(t, "short summary more details even more", p.Metadata.Description)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package packages
|
||||
|
||||
import "gitea.dev/modules/util"
|
||||
|
||||
// BoundedFileList accumulates file names from a package archive while enforcing caps on the number of
|
||||
// entries and their total name length, returning an error once either cap would be exceeded.
|
||||
type BoundedFileList struct {
|
||||
files []string
|
||||
nameBytes int
|
||||
maxFiles int
|
||||
maxBytes int
|
||||
}
|
||||
|
||||
// NewBoundedFileList creates a BoundedFileList with the given caps; a non-positive cap falls back to the
|
||||
// corresponding default.
|
||||
func NewBoundedFileList(maxFiles, maxNameBytes int) *BoundedFileList {
|
||||
return &BoundedFileList{maxFiles: maxFiles, maxBytes: maxNameBytes}
|
||||
}
|
||||
|
||||
// Add appends name, returning util.ErrInvalidArgument once the entry count or accumulated byte length
|
||||
// would exceed the configured cap.
|
||||
func (b *BoundedFileList) Add(name string) error {
|
||||
if len(b.files) >= b.maxFiles || b.nameBytes+len(name) > b.maxBytes {
|
||||
return util.NewInvalidArgumentErrorf("package contains too many file entries")
|
||||
}
|
||||
b.nameBytes += len(name)
|
||||
b.files = append(b.files, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Files returns the accumulated file names.
|
||||
func (b *BoundedFileList) Files() []string {
|
||||
return b.files
|
||||
}
|
||||
@@ -123,11 +123,26 @@ func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) {
|
||||
},
|
||||
}
|
||||
|
||||
// Nested packages (test fixtures, examples, benchmarks) ship their own manifests, which must not
|
||||
// replace the package manifest. The package sits at the archive root or in a single top level
|
||||
// directory, so keep only the shallowest manifest directory, breaking ties by name for stability.
|
||||
var manifestFiles []*zip.File
|
||||
manifestDir, manifestDepth := "", 0
|
||||
for _, file := range zr.File {
|
||||
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
|
||||
if len(manifestMatch) == 0 {
|
||||
if strings.HasSuffix(file.Name, "/") || !manifestPattern.MatchString(path.Base(file.Name)) {
|
||||
continue
|
||||
}
|
||||
dir, depth := path.Dir(file.Name), strings.Count(file.Name, "/")
|
||||
switch {
|
||||
case manifestFiles == nil || depth < manifestDepth || (depth == manifestDepth && dir < manifestDir):
|
||||
manifestDir, manifestDepth, manifestFiles = dir, depth, []*zip.File{file}
|
||||
case dir == manifestDir:
|
||||
manifestFiles = append(manifestFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range manifestFiles {
|
||||
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
|
||||
|
||||
if file.UncompressedSize64 > maxManifestFileSize {
|
||||
return nil, ErrManifestFileTooLarge
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package swift
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -24,6 +25,18 @@ const (
|
||||
packageLicense = "MIT"
|
||||
)
|
||||
|
||||
// writeOrderedZipArchive writes name/content pairs in the given order, which map based test.WriteZipArchive cannot do
|
||||
func writeOrderedZipArchive(entries [][2]string) *bytes.Buffer {
|
||||
buf := &bytes.Buffer{}
|
||||
zw := zip.NewWriter(buf)
|
||||
for _, entry := range entries {
|
||||
w, _ := zw.Create(entry[0])
|
||||
_, _ = w.Write([]byte(entry[1]))
|
||||
}
|
||||
_ = zw.Close()
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestParsePackage(t *testing.T) {
|
||||
t.Run("MissingManifestFile", func(t *testing.T) {
|
||||
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""})
|
||||
@@ -65,6 +78,77 @@ func TestParsePackage(t *testing.T) {
|
||||
assert.Equal(t, content2, m.Content)
|
||||
})
|
||||
|
||||
t.Run("IgnoresNestedManifests", func(t *testing.T) {
|
||||
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
|
||||
rootAltManifest := "// swift-tools-version:5.5\n//\n// Package@swift-5.5.swift"
|
||||
rootPatchAltManifest := "// swift-tools-version:5.7.1\n//\n// Package@swift-5.7.1.swift"
|
||||
nestedManifest := "// swift-tools-version:6.3\n//\n// nested fixture package"
|
||||
|
||||
data := writeOrderedZipArchive([][2]string{
|
||||
{"Package.swift", rootManifest},
|
||||
{"Package@swift-5.5.swift", rootAltManifest},
|
||||
{"Package@swift-5.7.1.swift", rootPatchAltManifest},
|
||||
{"Benchmarks/Package.swift", nestedManifest},
|
||||
{"Utils/Fixtures/PlainPackage/Package.swift", nestedManifest},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, p.Metadata.Manifests, 3)
|
||||
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
|
||||
assert.Equal(t, "5.7", p.Metadata.Manifests[""].ToolsVersion)
|
||||
assert.Equal(t, rootAltManifest, p.Metadata.Manifests["5.5"].Content)
|
||||
assert.Equal(t, rootPatchAltManifest, p.Metadata.Manifests["5.7.1"].Content)
|
||||
})
|
||||
|
||||
t.Run("IgnoresNestedManifestsInPrefixedArchive", func(t *testing.T) {
|
||||
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
|
||||
|
||||
// `swift package archive-source` produces archives with a single top level directory
|
||||
data := writeOrderedZipArchive([][2]string{
|
||||
{"gitea-1.0.1/Package.swift", rootManifest},
|
||||
{"gitea-1.0.1/Tests/Fixtures/Package.swift", "// swift-tools-version:6.3"},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, p.Metadata.Manifests, 1)
|
||||
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
|
||||
})
|
||||
|
||||
t.Run("AltManifestOnlyInRootDirectory", func(t *testing.T) {
|
||||
// a deeper Package.swift belongs to a nested package and must not stand in for the missing root manifest
|
||||
data := test.WriteZipArchive(map[string]string{
|
||||
"Package@swift-5.5.swift": "// swift-tools-version:5.5",
|
||||
"Sub/Package.swift": "// swift-tools-version:5.7",
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrMissingManifestFile)
|
||||
})
|
||||
|
||||
t.Run("ManifestDirectoryTieBreak", func(t *testing.T) {
|
||||
contentA := "// swift-tools-version:5.7\n// A"
|
||||
contentB := "// swift-tools-version:5.7\n// B"
|
||||
|
||||
// at equal depth the name decides, never the archive order
|
||||
data := writeOrderedZipArchive([][2]string{
|
||||
{"a/Package.swift", contentA},
|
||||
{"b/Package.swift", contentB},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, p.Metadata.Manifests, 1)
|
||||
assert.Equal(t, contentA, p.Metadata.Manifests[""].Content)
|
||||
})
|
||||
|
||||
t.Run("WithMetadata", func(t *testing.T) {
|
||||
data := test.WriteZipArchive(map[string]string{
|
||||
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -53,12 +54,37 @@ func dialContextInternalAPI(ctx context.Context, network, address string) (conn
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// internalAPIConnectionIsLocal reports whether the internal API transport connects to a local target,
|
||||
// where the self-signed local certificate cannot be verified so skipping verification is safe. It mirrors
|
||||
// what dialContextInternalAPI actually dials: a unix socket whenever Protocol is HTTPUnix (always local,
|
||||
// whatever LOCAL_ROOT_URL says), otherwise the LOCAL_ROOT_URL host directly. A non-loopback LOCAL_ROOT_URL
|
||||
// is a real network hop, so its certificate must be verified, else the internal token can be MITM'd. An
|
||||
// unparseable LOCAL_ROOT_URL is a hard misconfiguration and fails closed (verify).
|
||||
func internalAPIConnectionIsLocal(protocol setting.Scheme, localURL string) bool {
|
||||
if protocol == setting.HTTPUnix {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(localURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
var internalAPITransport = sync.OnceValue(func() http.RoundTripper {
|
||||
return &http.Transport{
|
||||
DialContext: dialContextInternalAPI,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: setting.Domain,
|
||||
// Skip verification only for a local target (unix socket, or a loopback LOCAL_ROOT_URL), where the
|
||||
// self-signed local cert can't be verified anyway; a non-loopback LOCAL_ROOT_URL is a real network
|
||||
// hop and must be verified so the internal token can't be MITM'd. When verifying, Go's default
|
||||
// ServerName (the dialed LOCAL_ROOT_URL host) is already correct, so it is not overridden.
|
||||
InsecureSkipVerify: internalAPIConnectionIsLocal(setting.Protocol, setting.LocalURL),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package private
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestInternalAPIConnectionIsLocal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
protocol setting.Scheme
|
||||
localURL string
|
||||
want bool
|
||||
}{
|
||||
// HTTPUnix always dials the unix socket (a local target), whatever LOCAL_ROOT_URL says
|
||||
{"unix socket", setting.HTTPUnix, "https://gitea.example.com/", true},
|
||||
{"localhost", setting.HTTP, "http://localhost:3000/", true},
|
||||
{"loopback ipv4", setting.HTTPS, "https://127.0.0.1:3000/", true},
|
||||
{"loopback ipv6", setting.HTTPS, "https://[::1]:3000/", true},
|
||||
// a non-loopback LOCAL_ROOT_URL is a real network hop and must be verified
|
||||
{"remote host", setting.HTTPS, "https://gitea.internal:443/", false},
|
||||
{"remote ip", setting.HTTPS, "https://10.0.0.5:3000/", false},
|
||||
// an unparseable LOCAL_ROOT_URL is a hard misconfiguration; fail closed to verification
|
||||
{"invalid url", setting.HTTPS, "://bad", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, internalAPIConnectionIsLocal(c.protocol, c.localURL))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,10 @@ const (
|
||||
KeyUname = "uname"
|
||||
|
||||
KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth"
|
||||
|
||||
// KeySignInMethod records how the current session was authenticated so logout
|
||||
// can decide whether RP-initiated OIDC logout is appropriate.
|
||||
KeySignInMethod = "signInMethod"
|
||||
|
||||
SignInMethodOAuth2 = "oauth2"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
|
||||
const defaultMaxRerunAttempts = 50
|
||||
|
||||
const defaultMaxConcurrentTaskPicks = 16
|
||||
|
||||
// Actions settings
|
||||
var (
|
||||
Actions = struct {
|
||||
@@ -31,13 +33,18 @@ var (
|
||||
WorkflowDirs []string `ini:"WORKFLOW_DIRS"`
|
||||
ScopedWorkflowDirs []string `ini:"SCOPED_WORKFLOW_DIRS"`
|
||||
MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"`
|
||||
// MaxConcurrentTaskPicks bounds how many runners may run the task-assignment
|
||||
// transaction at once per Gitea instance, to avoid a thundering herd when many
|
||||
// runners poll together. It is a per-process limit, not a cluster-wide one.
|
||||
MaxConcurrentTaskPicks int `ini:"MAX_CONCURRENT_TASK_PICKS"`
|
||||
}{
|
||||
Enabled: true,
|
||||
DefaultActionsURL: defaultActionsURLGitHub,
|
||||
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
|
||||
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
|
||||
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
|
||||
MaxRerunAttempts: defaultMaxRerunAttempts,
|
||||
Enabled: true,
|
||||
DefaultActionsURL: defaultActionsURLGitHub,
|
||||
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
|
||||
WorkflowDirs: []string{".gitea/workflows", ".github/workflows"},
|
||||
ScopedWorkflowDirs: []string{".gitea/scoped_workflows"},
|
||||
MaxRerunAttempts: defaultMaxRerunAttempts,
|
||||
MaxConcurrentTaskPicks: defaultMaxConcurrentTaskPicks,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -128,6 +135,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
|
||||
Actions.MaxRerunAttempts = defaultMaxRerunAttempts
|
||||
}
|
||||
|
||||
if Actions.MaxConcurrentTaskPicks <= 0 {
|
||||
Actions.MaxConcurrentTaskPicks = defaultMaxConcurrentTaskPicks
|
||||
}
|
||||
|
||||
if !Actions.LogCompression.IsValid() {
|
||||
return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ var Markdown = struct {
|
||||
MathCodeBlockDetection []string
|
||||
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
|
||||
}{
|
||||
EnableMath: true,
|
||||
EnableMath: true,
|
||||
FileNamePatterns: []string{"*.md"},
|
||||
}
|
||||
|
||||
// MarkupRenderer defines the external parser configured in ini
|
||||
|
||||
@@ -20,9 +20,11 @@ var Security = struct {
|
||||
XContentTypeOptions string
|
||||
|
||||
ContentSecurityPolicyGeneral string // it only supports empty (default policy) or "unset", maybe it can support more in the future
|
||||
AllowedHostList string
|
||||
}{
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
XContentTypeOptions: "nosniff",
|
||||
AllowedHostList: "external",
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -10,13 +10,20 @@ import (
|
||||
)
|
||||
|
||||
func TestLoadSecurityFrom(t *testing.T) {
|
||||
assert.Equal(t, "SAMEORIGIN", Security.XFrameOptions)
|
||||
assert.Equal(t, "nosniff", Security.XContentTypeOptions)
|
||||
assert.Equal(t, "external", Security.AllowedHostList)
|
||||
|
||||
cfg, err := NewConfigProviderFromData(`[security]
|
||||
X_FRAME_OPTIONS = DENY
|
||||
X_CONTENT_TYPE_OPTIONS = unset
|
||||
CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"`)
|
||||
ALLOWED_HOST_LIST = foo
|
||||
CONTENT_SECURITY_POLICY_GENERAL = "script-src *; foo"
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
loadSecurityFrom(cfg)
|
||||
assert.Equal(t, "DENY", Security.XFrameOptions)
|
||||
assert.Equal(t, "unset", Security.XContentTypeOptions)
|
||||
assert.Equal(t, "foo", Security.AllowedHostList)
|
||||
assert.Equal(t, `"script-src *`, Security.ContentSecurityPolicyGeneral) // holy shit ini package bug
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ func loadWebhookFrom(rootCfg ConfigProvider) {
|
||||
Webhook.QueueLength = sec.Key("QUEUE_LENGTH").MustInt(1000)
|
||||
Webhook.DeliverTimeout = sec.Key("DELIVER_TIMEOUT").MustInt(5)
|
||||
Webhook.SkipTLSVerify = sec.Key("SKIP_TLS_VERIFY").MustBool()
|
||||
Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString("")
|
||||
|
||||
deprecatedSetting(rootCfg, "webhook", "ALLOWED_HOST_LIST", "security", "ALLOWED_HOST_LIST", "v28.0.0")
|
||||
Webhook.AllowedHostList = sec.Key("ALLOWED_HOST_LIST").MustString(Security.AllowedHostList)
|
||||
|
||||
Webhook.Types = []string{"gitea", "gogs", "slack", "discord", "dingtalk", "telegram", "msteams", "feishu", "matrix", "wechatwork", "packagist"}
|
||||
Webhook.PagingNum = sec.Key("PAGING_NUM").MustInt(10)
|
||||
Webhook.ProxyURL = sec.Key("PROXY_URL").MustString("")
|
||||
|
||||
+14
-13
@@ -73,17 +73,18 @@ func TestInitKeys(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, keyFiles, len(keyTypes))
|
||||
|
||||
metadata := map[string]os.FileInfo{}
|
||||
// Record file contents so regeneration can be detected
|
||||
content := map[string][]byte{}
|
||||
for _, keyType := range keyTypes {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
info, err := os.Stat(privKeyPath)
|
||||
data, err := os.ReadFile(privKeyPath)
|
||||
require.NoError(t, err)
|
||||
metadata[privKeyPath] = info
|
||||
content[privKeyPath] = data
|
||||
|
||||
info, err = os.Stat(pubKeyPath)
|
||||
data, err = os.ReadFile(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
metadata[pubKeyPath] = info
|
||||
content[pubKeyPath] = data
|
||||
}
|
||||
|
||||
// Test recreation on missing private key and noop for missing pub key
|
||||
@@ -98,26 +99,26 @@ func TestInitKeys(t *testing.T) {
|
||||
privKeyPath := filepath.Join(tempDir, "gitea."+keyType)
|
||||
pubKeyPath := filepath.Join(tempDir, "gitea."+keyType+".pub")
|
||||
|
||||
infoPriv, err := os.Stat(privKeyPath)
|
||||
dataPriv, err := os.ReadFile(privKeyPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
switch keyType {
|
||||
case "rsa":
|
||||
// No modification to RSA key
|
||||
infoPub, err := os.Stat(pubKeyPath)
|
||||
dataPub, err := os.ReadFile(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
||||
assert.Equal(t, metadata[pubKeyPath], infoPub)
|
||||
assert.Equal(t, content[privKeyPath], dataPriv)
|
||||
assert.Equal(t, content[pubKeyPath], dataPub)
|
||||
case "ecdsa":
|
||||
// ECDSA public key should be missing, private unchanged
|
||||
assert.Equal(t, metadata[privKeyPath], infoPriv)
|
||||
assert.Equal(t, content[privKeyPath], dataPriv)
|
||||
assert.NoFileExists(t, pubKeyPath)
|
||||
case "ed25519":
|
||||
// ed25519 private key was removed, so both keys regenerated
|
||||
infoPub, err := os.Stat(pubKeyPath)
|
||||
dataPub, err := os.ReadFile(pubKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, metadata[privKeyPath], infoPriv)
|
||||
assert.NotEqual(t, metadata[pubKeyPath], infoPub)
|
||||
assert.NotEqual(t, content[privKeyPath], dataPriv)
|
||||
assert.NotEqual(t, content[pubKeyPath], dataPub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,12 +304,10 @@ func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqPar
|
||||
|
||||
// IterateObjects iterates across the objects in the azureblobstorage
|
||||
func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
|
||||
dirName = a.buildAzureBlobPath(dirName)
|
||||
if dirName != "" {
|
||||
dirName += "/"
|
||||
}
|
||||
basePrefix := buildObjectStorePathPrefix(a.cfg.BasePath, "")
|
||||
dirPrefix := buildObjectStorePathPrefix(a.cfg.BasePath, dirName)
|
||||
pager := a.client.NewListBlobsFlatPager(a.cfg.Container, &container.ListBlobsFlatOptions{
|
||||
Prefix: &dirName,
|
||||
Prefix: &dirPrefix,
|
||||
})
|
||||
for pager.More() {
|
||||
resp, err := pager.NextPage(a.ctx)
|
||||
@@ -317,7 +315,8 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
||||
return convertAzureBlobErr(err)
|
||||
}
|
||||
for _, object := range resp.Segment.BlobItems {
|
||||
blobClient := a.getBlobClient(*object.Name)
|
||||
objPath := strings.TrimPrefix(*object.Name, basePrefix)
|
||||
blobClient := a.getBlobClient(objPath)
|
||||
object := &azureBlobObject{
|
||||
Context: a.ctx,
|
||||
blobClient: blobClient,
|
||||
@@ -327,7 +326,7 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
||||
}
|
||||
if err := func(object *azureBlobObject, fn func(path string, obj Object) error) error {
|
||||
defer object.Close()
|
||||
return fn(strings.TrimPrefix(object.Name, a.cfg.BasePath), object)
|
||||
return fn(objPath, object)
|
||||
}(object, fn); err != nil {
|
||||
return convertAzureBlobErr(err)
|
||||
}
|
||||
@@ -336,7 +335,6 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete delete a file
|
||||
func (a *AzureBlobStorage) getBlobClient(path string) *blob.Client {
|
||||
return a.client.ServiceClient().NewContainerClient(a.cfg.Container).NewBlobClient(a.buildAzureBlobPath(path))
|
||||
}
|
||||
|
||||
@@ -27,24 +27,16 @@ func TestAzureBlobStorage(t *testing.T) {
|
||||
Container: "test",
|
||||
},
|
||||
}
|
||||
table := []struct {
|
||||
name string
|
||||
test func(t *testing.T, typStr Type, cfg *setting.Storage)
|
||||
}{
|
||||
{
|
||||
name: "iterator",
|
||||
test: testStorageIterator,
|
||||
},
|
||||
{
|
||||
name: "testBlobStorageURLContentTypeAndDisposition",
|
||||
test: testBlobStorageURLContentTypeAndDisposition,
|
||||
},
|
||||
}
|
||||
for _, entry := range table {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
entry.test(t, storageType, config)
|
||||
})
|
||||
}
|
||||
t.Run("Iterator", func(t *testing.T) {
|
||||
testStorageIterator(t, storageType, config)
|
||||
})
|
||||
t.Run("BlobStorageURLContentTypeAndDisposition", func(t *testing.T) {
|
||||
testBlobStorageURLContentTypeAndDisposition(t, storageType, config)
|
||||
})
|
||||
t.Run("IteratorWithBasePath", func(t *testing.T) {
|
||||
config.AzureBlobConfig.BasePath = "test-base-path"
|
||||
testStorageIterator(t, storageType, config)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAzureBlobStoragePath(t *testing.T) {
|
||||
|
||||
+33
-33
@@ -6,6 +6,7 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -25,6 +26,8 @@ import (
|
||||
|
||||
var _ ObjectStorage = &MinioStorage{}
|
||||
|
||||
const unknownSizePartSize = 1024 * 1024 * 16 // same as minio-go's minPartSize
|
||||
|
||||
type minioObject struct {
|
||||
*minio.Object
|
||||
}
|
||||
@@ -47,40 +50,42 @@ type MinioStorage struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
func convertMinioErr(err error) error {
|
||||
func convertMinioErr(err error, optMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
errResp, ok := err.(minio.ErrorResponse)
|
||||
|
||||
wrapErr := func(err error) error {
|
||||
if len(optMsg) == 0 {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%s: %w", optMsg[0], err)
|
||||
}
|
||||
|
||||
errResp, ok := errors.AsType[minio.ErrorResponse](err)
|
||||
if !ok {
|
||||
return err
|
||||
return wrapErr(err)
|
||||
}
|
||||
|
||||
// Convert two responses to standard analogues
|
||||
switch errResp.Code {
|
||||
case "NoSuchKey":
|
||||
return os.ErrNotExist
|
||||
return wrapErr(os.ErrNotExist)
|
||||
case "AccessDenied":
|
||||
return os.ErrPermission
|
||||
return wrapErr(os.ErrPermission)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
|
||||
_, err := minioClient.GetBucketVersioning(ctx, bucket)
|
||||
return err
|
||||
return wrapErr(err)
|
||||
}
|
||||
|
||||
// NewMinioStorage returns a minio storage
|
||||
func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage, error) {
|
||||
config := cfg.MinioConfig
|
||||
log.Info("Creating minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
|
||||
if config.ChecksumAlgorithm != "" && config.ChecksumAlgorithm != "default" && config.ChecksumAlgorithm != "md5" {
|
||||
return nil, fmt.Errorf("invalid minio checksum algorithm: %s", config.ChecksumAlgorithm)
|
||||
}
|
||||
|
||||
log.Info("Creating Minio storage at %s:%s with base path %s", config.Endpoint, config.Bucket, config.BasePath)
|
||||
|
||||
var lookup minio.BucketLookupType
|
||||
switch config.BucketLookUpType {
|
||||
case "auto", "":
|
||||
@@ -93,6 +98,13 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
|
||||
return nil, fmt.Errorf("invalid minio bucket lookup type: %s", config.BucketLookUpType)
|
||||
}
|
||||
|
||||
// The request error message is something like:
|
||||
// * "The request signature we calculated does not match the signature you provided. Check your key and signing method."
|
||||
// It doesn't contain useful information to site admin, so here we wrap the error with our error message
|
||||
// to tell the site admin what is the problem.
|
||||
makeErrMsg := func(hint string) string {
|
||||
return fmt.Sprintf("ObjectStorage.%s: endpoint=%s, location=%s, bucket=%s", hint, config.Endpoint, config.Location, config.Bucket)
|
||||
}
|
||||
minioClient, err := minio.New(config.Endpoint, &minio.Options{
|
||||
Creds: buildMinioCredentials(config),
|
||||
Secure: config.UseSSL,
|
||||
@@ -101,37 +113,21 @@ func NewMinioStorage(ctx context.Context, cfg *setting.Storage) (ObjectStorage,
|
||||
BucketLookup: lookup,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, convertMinioErr(err)
|
||||
}
|
||||
|
||||
// The GetBucketVersioning is only used for checking whether the Object Storage parameters are generally good. It doesn't need to succeed.
|
||||
// The assumption is that if the API returns the HTTP code 400, then the parameters could be incorrect.
|
||||
// Otherwise even if the request itself fails (403, 404, etc), the code should still continue because the parameters seem "good" enough.
|
||||
// Keep in mind that GetBucketVersioning requires "owner" to really succeed, so it can't be used to check the existence.
|
||||
// Not using "BucketExists (HeadBucket)" because it doesn't include detailed failure reasons.
|
||||
err = getBucketVersioning(ctx, minioClient, config.Bucket)
|
||||
if err != nil {
|
||||
errResp, ok := err.(minio.ErrorResponse)
|
||||
if !ok {
|
||||
return nil, err
|
||||
}
|
||||
if errResp.StatusCode == http.StatusBadRequest {
|
||||
log.Error("S3 storage connection failure at %s:%s with base path %s and region: %s", config.Endpoint, config.Bucket, config.Location, errResp.Message)
|
||||
return nil, err
|
||||
}
|
||||
return nil, convertMinioErr(err, makeErrMsg("NewClient"))
|
||||
}
|
||||
|
||||
// Check to see if we already own this bucket
|
||||
exists, err := minioClient.BucketExists(ctx, config.Bucket)
|
||||
if err != nil {
|
||||
return nil, convertMinioErr(err)
|
||||
return nil, convertMinioErr(err, makeErrMsg("BucketExists"))
|
||||
}
|
||||
|
||||
// If the bucket doesn't exist, try to create one
|
||||
if !exists {
|
||||
if err := minioClient.MakeBucket(ctx, config.Bucket, minio.MakeBucketOptions{
|
||||
Region: config.Location,
|
||||
}); err != nil {
|
||||
return nil, convertMinioErr(err)
|
||||
return nil, convertMinioErr(err, makeErrMsg("MakeBucket"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +213,10 @@ func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error)
|
||||
// * https://www.backblaze.com/b2/docs/s3_compatible_api.html
|
||||
// do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
|
||||
SendContentMd5: m.cfg.ChecksumAlgorithm == "md5",
|
||||
|
||||
// with an unknown size (-1) minio-go assumes a 5TiB object and buffers a 528MiB part for it, even
|
||||
// for a payload of a few KiB, so pin the part size there, a known size derives its own
|
||||
PartSize: util.Iif[uint64](size < 0, unknownSizePartSize, 0),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,16 +4,13 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -84,31 +81,18 @@ func TestMinioStoragePath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestS3StorageBadRequest(t *testing.T) {
|
||||
if os.Getenv("CI") == "" {
|
||||
t.Skip("S3Storage not present outside of CI")
|
||||
return
|
||||
}
|
||||
endpoint := test.ExternalServiceHTTP(t, "TEST_MINIO_ENDPOINT", "minio:9000")
|
||||
cfg := &setting.Storage{
|
||||
MinioConfig: setting.MinioStorageConfig{
|
||||
Endpoint: "minio:9000",
|
||||
Endpoint: endpoint,
|
||||
AccessKeyID: "123456",
|
||||
SecretAccessKey: "12345678",
|
||||
SecretAccessKey: "invalid-secret",
|
||||
Bucket: "bucket",
|
||||
Location: "us-east-1",
|
||||
},
|
||||
}
|
||||
message := "ERROR"
|
||||
old := getBucketVersioning
|
||||
defer func() { getBucketVersioning = old }()
|
||||
getBucketVersioning = func(ctx context.Context, minioClient *minio.Client, bucket string) error {
|
||||
return minio.ErrorResponse{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Code: "FixtureError",
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
_, err := NewStorage(setting.MinioStorageType, cfg)
|
||||
assert.ErrorContains(t, err, message)
|
||||
assert.ErrorContains(t, err, "ObjectStorage.BucketExists: endpoint="+endpoint)
|
||||
}
|
||||
|
||||
func TestMinioCredentials(t *testing.T) {
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrURLNotSupported represents url is not supported
|
||||
@@ -139,6 +141,23 @@ func SaveFrom(objStorage ObjectStorage, path string, callback func(w io.Writer)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildObjectStorePath(base, p string) string {
|
||||
p = strings.TrimPrefix(util.PathJoinRelX(base, p), "/") // object store doesn't use slash for root path
|
||||
if p == "." {
|
||||
p = "" // object store doesn't use dot as relative path
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func buildObjectStorePathPrefix(base, p string) string {
|
||||
// ending slash is required for avoiding matching like "foo/" and "foobar/" with prefix "foo"
|
||||
p = buildObjectStorePath(base, p) + "/"
|
||||
if p == "/" {
|
||||
p = "" // object store doesn't use slash for root path
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
var (
|
||||
// Attachments represents attachments storage
|
||||
Attachments ObjectStorage = uninitializedStorage
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user