mirror of
https://gitea.com/gitea/tea.git
synced 2026-08-05 23:07:39 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b645a189a2 | |||
| f6d939a8df | |||
| f34697c5ed | |||
| a613a344de | |||
| 6435b12202 | |||
| 61b8536e4a | |||
| 73b6bf3e23 | |||
| 993eb37b57 |
@@ -8,16 +8,28 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: git fetch --force --tags
|
||||
- uses: actions/setup-go@v6
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: import gpg
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@v7
|
||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
||||
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||
@@ -25,10 +37,10 @@ jobs:
|
||||
id: sdk_version
|
||||
run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT"
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v1"
|
||||
version: "~> v2"
|
||||
args: release --nightly
|
||||
env:
|
||||
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
|
||||
@@ -38,6 +50,10 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
GORELEASER_FORCE_TOKEN: 'gitea'
|
||||
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||
@@ -49,24 +65,24 @@ jobs:
|
||||
DOCKER_LATEST: nightly
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
env:
|
||||
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
|
||||
with:
|
||||
|
||||
@@ -9,16 +9,28 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: git fetch --force --tags
|
||||
- uses: actions/setup-go@v6
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: import gpg
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@v7
|
||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
||||
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||
@@ -26,10 +38,10 @@ jobs:
|
||||
id: sdk_version
|
||||
run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT"
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v1"
|
||||
version: "~> v2"
|
||||
args: release
|
||||
env:
|
||||
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
|
||||
@@ -39,6 +51,10 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
GORELEASER_FORCE_TOKEN: 'gitea'
|
||||
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||
@@ -50,18 +66,18 @@ jobs:
|
||||
DOCKER_LATEST: nightly
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -71,7 +87,7 @@ jobs:
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
env:
|
||||
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
|
||||
with:
|
||||
|
||||
@@ -16,8 +16,8 @@ jobs:
|
||||
name: Lint Build And Unit Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: lint and build
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
GITEA_TEA_TEST_USERNAME: "test01"
|
||||
GITEA_TEA_TEST_PASSWORD: "test01"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: wait for the gitea instance to be ready
|
||||
|
||||
+44
-3
@@ -1,3 +1,5 @@
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
@@ -79,11 +81,50 @@ blobs:
|
||||
provider: s3
|
||||
bucket: "{{ .Env.S3_BUCKET }}"
|
||||
region: "{{ .Env.S3_REGION }}"
|
||||
folder: "tea/{{.Version}}"
|
||||
directory: "tea/{{.Version}}"
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
|
||||
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
||||
# parallel S3+R2 period (S3 will be removed once migration completes).
|
||||
# A second `blobs:` entry is impossible here since the blob pipe
|
||||
# authenticates from the global AWS_* env with no per-entry
|
||||
# credentials; `publishers:` supports per-entry `env:` instead, so
|
||||
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
||||
# publishers inherit almost nothing from the environment, hence the
|
||||
# explicit R2_* forwarding below.
|
||||
#
|
||||
# This publisher fires more than once per distinct key because
|
||||
# goreleaser's release pipe already registers `release.extra_files`
|
||||
# (./**.xz and ./**.xz.sha256, see the `release:` block below) as
|
||||
# UploadableFile artifacts, and `internal/exec`'s filterArtifacts
|
||||
# appends this block's own extra_files with no de-duplication. It
|
||||
# can't be globbed away, since gobwas/glob (via goreleaser/fileglob)
|
||||
# has no substring-exclusion matcher. It's harmless: PUT is
|
||||
# idempotent, and the `./**.xz` glob below is kept deliberately so
|
||||
# this publisher declares its own complete file set rather than
|
||||
# implicitly depending on the `release:` block's globs.
|
||||
#
|
||||
# checksum: true mirrors goreleaser's generated checksums.txt;
|
||||
# signature: true additionally mirrors checksums.txt.sig, which the
|
||||
# `signs:` block below produces by GPG-signing that checksum file.
|
||||
# Without signature: true, artifacts downloaded from the R2 mirror
|
||||
# would have no signature file to verify against.
|
||||
publishers:
|
||||
- name: cloudflare-r2
|
||||
checksum: true
|
||||
signature: true
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
cmd: sh scripts/upload-r2.sh {{ abs .ArtifactPath }} tea/{{ .Version }}/{{ .ArtifactName }}
|
||||
env:
|
||||
- R2_ENDPOINT={{ index .Env "R2_ENDPOINT" }}
|
||||
- R2_BUCKET={{ index .Env "R2_BUCKET" }}
|
||||
- R2_ACCESS_KEY_ID={{ index .Env "R2_ACCESS_KEY_ID" }}
|
||||
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||
|
||||
archives:
|
||||
- format: binary
|
||||
name_template: "{{ .Binary }}"
|
||||
@@ -104,10 +145,10 @@ signs:
|
||||
args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ .Branch }}-devel"
|
||||
version_template: "{{ .Branch }}-devel"
|
||||
|
||||
nightly:
|
||||
name_template: "{{ .Branch }}"
|
||||
version_template: "{{ .Branch }}"
|
||||
|
||||
gitea_urls:
|
||||
api: https://gitea.com/api/v1
|
||||
|
||||
@@ -63,12 +63,12 @@ func listNotifications(requestCtx stdctx.Context, cmd *cli.Command, status []git
|
||||
var news []*gitea.NotificationThread
|
||||
var err error
|
||||
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
all := cmd.Bool("mine")
|
||||
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: all})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
all := ctx.Bool("mine")
|
||||
|
||||
// This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733)
|
||||
listOpts := flags.GetListOptions(cmd)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package notifications
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tea/modules/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func TestRunNotificationsListMineDoesNotProbeGitRepository(t *testing.T) {
|
||||
gitPath := filepath.Join(t.TempDir(), "git")
|
||||
gitScript := "#!/bin/sh\necho 'git should not be called' >&2\nexit 1\n"
|
||||
if runtime.GOOS == "windows" {
|
||||
gitPath += ".bat"
|
||||
gitScript = "@echo git should not be called 1>&2\r\nexit /b 1\r\n"
|
||||
}
|
||||
require.NoError(t, os.WriteFile(gitPath, []byte(gitScript), 0o755))
|
||||
t.Setenv("PATH", filepath.Dir(gitPath))
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v1/notifications", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config.SetConfigForTesting(config.LocalConfig{
|
||||
Logins: []config.Login{{
|
||||
Name: "default",
|
||||
URL: server.URL,
|
||||
Token: "token",
|
||||
User: "user",
|
||||
Default: true,
|
||||
}},
|
||||
})
|
||||
|
||||
cmd := cli.Command{
|
||||
Name: CmdNotificationsList.Name,
|
||||
Flags: CmdNotificationsList.Flags,
|
||||
}
|
||||
require.NoError(t, cmd.Set("mine", "true"))
|
||||
require.NoError(t, cmd.Set("output", "json"))
|
||||
|
||||
require.NoError(t, RunNotificationsList(stdctx.Background(), &cmd))
|
||||
}
|
||||
@@ -24,7 +24,7 @@ var CmdNotificationsMarkRead = cli.Command{
|
||||
ArgsUsage: "[all | <notification id>]",
|
||||
Flags: flags.NotificationFlags,
|
||||
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -48,7 +48,7 @@ var CmdNotificationsMarkUnread = cli.Command{
|
||||
ArgsUsage: "[all | <notification id>]",
|
||||
Flags: flags.NotificationFlags,
|
||||
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -72,7 +72,7 @@ var CmdNotificationsMarkPinned = cli.Command{
|
||||
ArgsUsage: "[all | <notification id>]",
|
||||
Flags: flags.NotificationFlags,
|
||||
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -95,7 +95,7 @@ var CmdNotificationsUnpin = cli.Command{
|
||||
ArgsUsage: "[all | <notification id>]",
|
||||
Flags: flags.NotificationFlags,
|
||||
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@ require (
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/enescakir/emoji v1.0.0
|
||||
github.com/go-authgate/sdk-go v0.14.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/olekukonko/tablewriter v1.1.4
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli-docs/v3 v3.1.0
|
||||
github.com/urfave/cli/v3 v3.10.1
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.47.0
|
||||
@@ -74,7 +74,6 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
github.com/zalando/go-keyring v0.2.8 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
|
||||
@@ -89,8 +89,6 @@ github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog
|
||||
github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/go-authgate/sdk-go v0.14.0 h1:s1i/UCX2Edf3A1pKDW6oXv+oACQfTroxiGY52eqKx+4=
|
||||
github.com/go-authgate/sdk-go v0.14.0/go.mod h1:sa0ige5wtayj2WcnXlxa8wGuyi5z/c/chc0mXPJTl/Q=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/modules/credstore"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/go-authgate/sdk-go/credstore"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
|
||||
@@ -240,11 +240,14 @@ func DeleteLogin(name string) error {
|
||||
}
|
||||
|
||||
isOAuth := config.Logins[idx].IsOAuth()
|
||||
// Use the stored login name, not the CLI-provided one: the lookup above
|
||||
// is case-insensitive, but credstore keys are exact-match.
|
||||
storedName := config.Logins[idx].Name
|
||||
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
|
||||
|
||||
// Clean up credstore tokens for OAuth logins
|
||||
if isOAuth {
|
||||
_ = DeleteOAuthToken(name)
|
||||
_ = DeleteOAuthToken(storedName)
|
||||
}
|
||||
|
||||
return saveConfigUnsafe()
|
||||
|
||||
+34
-18
@@ -37,6 +37,12 @@ type TeaContext struct {
|
||||
LocalRepo *git.TeaRepo // is set if flags specified a local repo via --repo, or if $PWD is a git repo
|
||||
}
|
||||
|
||||
// InitOptions controls which optional sources InitCommand may inspect.
|
||||
type InitOptions struct {
|
||||
// SkipLocalRepo avoids probing the current directory for a git repository.
|
||||
SkipLocalRepo bool
|
||||
}
|
||||
|
||||
// GetRemoteRepoHTMLURL returns the web-ui url of the remote repo,
|
||||
// after ensuring a remote repo is present in the context.
|
||||
func (ctx *TeaContext) GetRemoteRepoHTMLURL() (string, error) {
|
||||
@@ -61,6 +67,12 @@ func shouldPromptFallbackLogin(login *config.Login, canPrompt bool) bool {
|
||||
// the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from
|
||||
// command flags. If a local git repo can't be found, repo slug values are unset.
|
||||
func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
||||
return InitCommandWithOptions(cmd, InitOptions{})
|
||||
}
|
||||
|
||||
// InitCommandWithOptions resolves the application context like InitCommand, with
|
||||
// optional controls for commands that do not need repository context.
|
||||
func InitCommandWithOptions(cmd *cli.Command, opts InitOptions) (*TeaContext, error) {
|
||||
// these flags are used as overrides to the context detection via local git repo
|
||||
repoFlag := cmd.String("repo")
|
||||
loginFlag := cmd.String("login")
|
||||
@@ -76,7 +88,7 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
||||
)
|
||||
|
||||
// check if repoFlag can be interpreted as path to local repo.
|
||||
if len(repoFlag) != 0 {
|
||||
if len(repoFlag) != 0 && !opts.SkipLocalRepo {
|
||||
if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -85,6 +97,8 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
||||
} else {
|
||||
c.RepoSlug = repoFlag
|
||||
}
|
||||
} else if len(repoFlag) != 0 {
|
||||
c.RepoSlug = repoFlag
|
||||
}
|
||||
|
||||
if len(remoteFlag) == 0 {
|
||||
@@ -101,24 +115,26 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
||||
extraLogins = append(extraLogins, *envLogin)
|
||||
}
|
||||
|
||||
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
|
||||
// otherwise attempt PWD. if no repo is found, continue with default login
|
||||
if repoPath == "" {
|
||||
if repoPath, err = os.Getwd(); err != nil {
|
||||
return nil, err
|
||||
if !opts.SkipLocalRepo {
|
||||
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
|
||||
// otherwise attempt PWD. if no repo is found, continue with default login
|
||||
if repoPath == "" {
|
||||
if repoPath, err = os.Getwd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var localSlug string
|
||||
if c.LocalRepo, c.Login, localSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
|
||||
if err == errNotAGiteaRepo || err == git.ErrRepositoryNotExists {
|
||||
// we can deal with that, commands needing the optional values use ctx.Ensure()
|
||||
} else {
|
||||
return nil, err
|
||||
var localSlug string
|
||||
if c.LocalRepo, c.Login, localSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
|
||||
if err == errNotAGiteaRepo || err == git.ErrRepositoryNotExists {
|
||||
// we can deal with that, commands needing the optional values use ctx.Ensure()
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if c.RepoSlug == "" && localSlug != "" {
|
||||
c.RepoSlug = localSlug
|
||||
}
|
||||
}
|
||||
if c.RepoSlug == "" && localSlug != "" {
|
||||
c.RepoSlug = localSlug
|
||||
}
|
||||
|
||||
// If env vars are set, always use the env login (but repo slug was already
|
||||
@@ -150,7 +166,7 @@ and then run your command again`)
|
||||
if shouldPromptFallbackLogin(c.Login, canPrompt) {
|
||||
fallback := false
|
||||
if err := huh.NewConfirm().
|
||||
Title(fmt.Sprintf("NOTE: no gitea login detected, whether falling back to login '%s'?", c.Login.Name)).
|
||||
Title(fmt.Sprintf("NOTE: no login matched this repository. Fall back to login '%s'?", c.Login.Name)).
|
||||
Value(&fallback).
|
||||
WithTheme(theme.GetTheme()).
|
||||
Run(); err != nil {
|
||||
@@ -160,7 +176,7 @@ and then run your command again`)
|
||||
return nil, ErrCommandCanceled
|
||||
}
|
||||
} else if !c.Login.Default {
|
||||
fmt.Fprintf(os.Stderr, "NOTE: no gitea login detected, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
|
||||
fmt.Fprintf(os.Stderr, "NOTE: no login matched this repository, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Codec handles encoding/decoding values to/from strings for storage.
|
||||
type Codec[T any] interface {
|
||||
Encode(v T) (string, error)
|
||||
Decode(s string) (T, error)
|
||||
}
|
||||
|
||||
// JSONCodec encodes T as JSON.
|
||||
type JSONCodec[T any] struct{}
|
||||
|
||||
// Encode marshals v to a JSON string.
|
||||
func (JSONCodec[T]) Encode(v T) (string, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal data: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Decode unmarshals a JSON string into T.
|
||||
func (JSONCodec[T]) Decode(s string) (T, error) {
|
||||
var v T
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return v, fmt.Errorf("failed to unmarshal data: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// StringCodec is the identity codec for plain strings.
|
||||
type StringCodec struct{}
|
||||
|
||||
// Encode returns the string as-is.
|
||||
func (StringCodec) Encode(v string) (string, error) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Decode returns the string as-is.
|
||||
func (StringCodec) Decode(s string) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package credstore provides secure storage for OAuth tokens. Values are
|
||||
// AES-256-GCM-encrypted into a JSON file while only the 32-byte master key
|
||||
// lives in the OS keyring; when the keyring is unavailable the store falls
|
||||
// back to plaintext file storage.
|
||||
package credstore
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// masterKeySize is the AES-256 key length in bytes.
|
||||
const masterKeySize = 32
|
||||
|
||||
// masterKeyUser is the keyring account name under which the master key is
|
||||
// stored. It must never change: installations hold their master key under
|
||||
// this exact name.
|
||||
const masterKeyUser = "__tea_master_key__"
|
||||
|
||||
// sealedPrefix versions the on-disk encrypted value format so a future
|
||||
// algorithm change can be detected instead of guessed at.
|
||||
const sealedPrefix = "v1:"
|
||||
|
||||
// masterKey manages a per-service AES-256 key held in the OS keyring and
|
||||
// caches the derived AEAD in memory. See EncryptedFileStore for why only the
|
||||
// key lives in the keyring.
|
||||
type masterKey struct {
|
||||
store *KeyringStore[string]
|
||||
|
||||
mu sync.Mutex
|
||||
aead cipher.AEAD // cached after the first successful load or create
|
||||
}
|
||||
|
||||
// loadLocked returns the cached or keyring-held AEAD. It returns ErrNotFound
|
||||
// unwrapped when no key exists yet so callers can distinguish "no key" from
|
||||
// "keyring unavailable". m.mu must be held.
|
||||
func (m *masterKey) loadLocked() (cipher.AEAD, error) {
|
||||
if m.aead != nil {
|
||||
return m.aead, nil
|
||||
}
|
||||
|
||||
encoded, err := m.store.Load(masterKeyUser)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
// e.g. Linux headless without Secret Service, or keyring locked.
|
||||
return nil, fmt.Errorf("failed to read master key: %w", err)
|
||||
}
|
||||
|
||||
key, decodeErr := base64.StdEncoding.DecodeString(encoded)
|
||||
if decodeErr != nil || len(key) != masterKeySize {
|
||||
return nil, errors.New("corrupted master key in keyring")
|
||||
}
|
||||
return m.cacheLocked(key)
|
||||
}
|
||||
|
||||
// cacheLocked builds the AEAD for key and caches it. m.mu must be held.
|
||||
func (m *masterKey) cacheLocked(key []byte) (cipher.AEAD, error) {
|
||||
aead, err := newGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.aead = aead
|
||||
return aead, nil
|
||||
}
|
||||
|
||||
// load returns the AEAD without ever creating a key, so decryption paths
|
||||
// cannot mint a key that has no chance of opening existing ciphertext.
|
||||
func (m *masterKey) load() (cipher.AEAD, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.loadLocked()
|
||||
}
|
||||
|
||||
// get returns the AEAD, generating and persisting a new key on first use.
|
||||
func (m *masterKey) get() (cipher.AEAD, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
aead, err := m.loadLocked()
|
||||
if err == nil {
|
||||
return aead, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First use: generate and persist a new key.
|
||||
key := make([]byte, masterKeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate master key: %w", err)
|
||||
}
|
||||
if err := m.store.Save(masterKeyUser, base64.StdEncoding.EncodeToString(key)); err != nil {
|
||||
return nil, fmt.Errorf("failed to store master key: %w", err)
|
||||
}
|
||||
return m.cacheLocked(key)
|
||||
}
|
||||
|
||||
// available reports whether the keyring can serve the master key without
|
||||
// creating one: a cached or stored valid key counts, and so does a clean
|
||||
// not-found (the key is generated lazily on first Save). A corrupted key or
|
||||
// an unreachable keyring does not.
|
||||
func (m *masterKey) available() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, err := m.loadLocked()
|
||||
return err == nil || errors.Is(err, ErrNotFound)
|
||||
}
|
||||
|
||||
// newGCM creates an AES-256-GCM AEAD for the given key.
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
return gcm, nil
|
||||
}
|
||||
|
||||
// sealValue encrypts plaintext with AES-256-GCM and returns
|
||||
// "v1:" + base64(nonce || ciphertext).
|
||||
func sealValue(aead cipher.AEAD, plaintext string) (string, error) {
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
// Seal appends ciphertext+tag to nonce, so the stored value is self-contained.
|
||||
sealed := aead.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return sealedPrefix + base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// openValue decrypts a value produced by sealValue.
|
||||
func openValue(aead cipher.AEAD, encoded string) (string, error) {
|
||||
rest, ok := strings.CutPrefix(encoded, sealedPrefix)
|
||||
if !ok {
|
||||
return "", errors.New("unrecognized encrypted value format")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(rest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode encrypted value: %w", err)
|
||||
}
|
||||
if len(data) < aead.NonceSize() {
|
||||
return "", errors.New("encrypted value too short")
|
||||
}
|
||||
nonce, ciphertext := data[:aead.NonceSize()], data[aead.NonceSize():]
|
||||
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
// Wrong key or tampered value — GCM authentication failed.
|
||||
return "", fmt.Errorf("failed to decrypt value (key mismatch or tampering): %w", err)
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// encryptedCodec wraps an inner codec with AES-256-GCM encryption using a
|
||||
// keyring-held master key.
|
||||
type encryptedCodec[T any] struct {
|
||||
inner Codec[T]
|
||||
key *masterKey
|
||||
}
|
||||
|
||||
// Encode encodes v with the inner codec and encrypts the result.
|
||||
func (c encryptedCodec[T]) Encode(v T) (string, error) {
|
||||
aead, err := c.key.get()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := c.inner.Encode(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sealValue(aead, plaintext)
|
||||
}
|
||||
|
||||
// Decode decrypts s and decodes the plaintext with the inner codec.
|
||||
func (c encryptedCodec[T]) Decode(s string) (T, error) {
|
||||
var zero T
|
||||
aead, err := c.key.load()
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Deliberately not wrapping ErrNotFound: the value exists but
|
||||
// cannot be decrypted, which must not read as "no data stored".
|
||||
return zero, errors.New("cannot decrypt stored value: master key not found in keyring")
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
plaintext, err := openValue(aead, s)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return c.inner.Decode(plaintext)
|
||||
}
|
||||
|
||||
// EncryptedFileStore stores values encrypted with AES-256-GCM in a JSON file,
|
||||
// keeping only the 32-byte master key in the OS keyring. The keyring payload
|
||||
// is a constant 44 bytes (base64) regardless of value size, so it never hits
|
||||
// the Windows Credential Manager 2560-byte blob limit or the macOS/Linux
|
||||
// keyring item size limits. The values themselves (which can be several KB
|
||||
// for tokens with groups claims) are encrypted into a file with 0600
|
||||
// permissions, file locking, and atomic writes.
|
||||
//
|
||||
// EncryptedFileStore implements Store[T] and Prober.
|
||||
type EncryptedFileStore[T any] struct {
|
||||
file *FileStore[T]
|
||||
key *masterKey
|
||||
}
|
||||
|
||||
// NewEncryptedFileStore creates an EncryptedFileStore. serviceName is the
|
||||
// keyring service under which the master key is stored; filePath is the
|
||||
// encrypted data file. Panics if codec is nil.
|
||||
func NewEncryptedFileStore[T any](
|
||||
serviceName, filePath string,
|
||||
codec Codec[T],
|
||||
) *EncryptedFileStore[T] {
|
||||
if codec == nil {
|
||||
panic("credstore: NewEncryptedFileStore called with nil codec")
|
||||
}
|
||||
key := &masterKey{store: NewStringKeyringStore(serviceName)}
|
||||
return &EncryptedFileStore[T]{
|
||||
file: NewFileStore[T](filePath, encryptedCodec[T]{inner: codec, key: key}),
|
||||
key: key,
|
||||
}
|
||||
}
|
||||
|
||||
// Probe reports whether the OS keyring can serve the master key. It is
|
||||
// read-only: the key itself is generated lazily on the first Save. Once the
|
||||
// key is cached in memory, Probe keeps reporting true even if the keyring
|
||||
// later becomes unavailable, because the store remains operational with the
|
||||
// cached key.
|
||||
func (e *EncryptedFileStore[T]) Probe() bool {
|
||||
return e.key.available()
|
||||
}
|
||||
|
||||
// Load loads and decrypts data for the given client ID.
|
||||
func (e *EncryptedFileStore[T]) Load(clientID string) (T, error) {
|
||||
return e.file.Load(clientID)
|
||||
}
|
||||
|
||||
// Save encrypts and saves data for the given client ID.
|
||||
func (e *EncryptedFileStore[T]) Save(clientID string, data T) error {
|
||||
return e.file.Save(clientID, data)
|
||||
}
|
||||
|
||||
// Delete removes data for the given client ID from the file.
|
||||
func (e *EncryptedFileStore[T]) Delete(clientID string) error {
|
||||
return e.file.Delete(clientID)
|
||||
}
|
||||
|
||||
// String returns a description of this store.
|
||||
func (e *EncryptedFileStore[T]) String() string {
|
||||
return "encrypted-file: " + e.file.filePath
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// newLargeTestToken builds a token whose access token is several KB,
|
||||
// mimicking real JWTs with large groups claims that exceed the Windows
|
||||
// Credential Manager 2560-byte blob limit.
|
||||
func newLargeTestToken(clientID string) Token {
|
||||
return Token{
|
||||
AccessToken: "header." + strings.Repeat("groups-claim-payload-", 300) + ".sig",
|
||||
RefreshToken: "test-refresh-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second),
|
||||
ClientID: clientID,
|
||||
}
|
||||
}
|
||||
|
||||
func newTestEncryptedStore(t *testing.T) (*EncryptedFileStore[Token], string) {
|
||||
t.Helper()
|
||||
keyring.MockInit()
|
||||
path := filepath.Join(t.TempDir(), "tokens.enc")
|
||||
return NewEncryptedFileStore[Token]("test-service", path, JSONCodec[Token]{}), path
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreSaveAndLoad(t *testing.T) {
|
||||
store, _ := newTestEncryptedStore(t)
|
||||
|
||||
tok := newLargeTestToken("test-client")
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
|
||||
loaded, err := store.Load("test-client")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tok.AccessToken, loaded.AccessToken)
|
||||
assert.Equal(t, tok.RefreshToken, loaded.RefreshToken)
|
||||
assert.True(t, tok.ExpiresAt.Equal(loaded.ExpiresAt))
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreFileContainsNoPlaintext(t *testing.T) {
|
||||
store, path := newTestEncryptedStore(t)
|
||||
|
||||
tok := newLargeTestToken("test-client")
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(raw), "groups-claim-payload")
|
||||
assert.NotContains(t, string(raw), tok.RefreshToken)
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreKeyringHoldsOnlySmallMasterKey(t *testing.T) {
|
||||
store, _ := newTestEncryptedStore(t)
|
||||
|
||||
tok := newLargeTestToken("test-client")
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
|
||||
// The token itself must not be in the keyring.
|
||||
_, err := keyring.Get("test-service", "test-client")
|
||||
assert.ErrorIs(t, err, keyring.ErrNotFound)
|
||||
|
||||
// Only the 44-byte base64 master key may live in the keyring —
|
||||
// well under the Windows Credential Manager 2560-byte blob limit.
|
||||
encoded, err := keyring.Get("test-service", masterKeyUser)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, encoded, 44)
|
||||
key, err := base64.StdEncoding.DecodeString(encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, key, 32)
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreLoadNotFound(t *testing.T) {
|
||||
store, _ := newTestEncryptedStore(t)
|
||||
|
||||
_, err := store.Load("nonexistent")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreDelete(t *testing.T) {
|
||||
store, _ := newTestEncryptedStore(t)
|
||||
|
||||
tok := newLargeTestToken("test-client")
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
require.NoError(t, store.Delete("test-client"))
|
||||
|
||||
_, err := store.Load("test-client")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreSaveEmptyClientID(t *testing.T) {
|
||||
store, _ := newTestEncryptedStore(t)
|
||||
|
||||
err := store.Save("", newLargeTestToken("x"))
|
||||
assert.ErrorIs(t, err, ErrEmptyClientID)
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreProbe(t *testing.T) {
|
||||
store, _ := newTestEncryptedStore(t)
|
||||
|
||||
assert.True(t, store.Probe())
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreCorruptMasterKeyFailsLoad(t *testing.T) {
|
||||
store, _ := newTestEncryptedStore(t)
|
||||
|
||||
tok := newLargeTestToken("test-client")
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
|
||||
// A fresh store whose keyring holds a corrupted key must fail to decrypt
|
||||
// rather than return garbage or mint a new key.
|
||||
require.NoError(t, keyring.Set("test-service", masterKeyUser, "not-base64!"))
|
||||
fresh := NewEncryptedFileStore[Token]("test-service", store.file.filePath, JSONCodec[Token]{})
|
||||
assert.False(t, fresh.Probe())
|
||||
_, err := fresh.Load("test-client")
|
||||
require.Error(t, err)
|
||||
assert.NotErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreNilCodecPanics(t *testing.T) {
|
||||
assert.Panics(t, func() {
|
||||
NewEncryptedFileStore[string]("svc", "path", nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncryptedFileStoreString(t *testing.T) {
|
||||
store, path := newTestEncryptedStore(t)
|
||||
assert.Equal(t, "encrypted-file: "+path, store.String())
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.dev/tea/modules/filelock"
|
||||
)
|
||||
|
||||
// storageMap manages encoded values for multiple clients.
|
||||
type storageMap struct {
|
||||
Data map[string]string `json:"data"` // clientID -> encoded value
|
||||
}
|
||||
|
||||
// FileStore stores values in a JSON file with file locking and atomic writes.
|
||||
type FileStore[T any] struct {
|
||||
filePath string
|
||||
codec Codec[T]
|
||||
}
|
||||
|
||||
// NewFileStore creates a new FileStore with the given codec.
|
||||
// Panics if codec is nil.
|
||||
func NewFileStore[T any](filePath string, codec Codec[T]) *FileStore[T] {
|
||||
if codec == nil {
|
||||
panic("credstore: NewFileStore called with nil codec")
|
||||
}
|
||||
return &FileStore[T]{filePath: filePath, codec: codec}
|
||||
}
|
||||
|
||||
// readStorageMap reads and unmarshals the storage map from the file.
|
||||
// Returns an empty initialized map if the file does not exist.
|
||||
func (f *FileStore[T]) readStorageMap() (storageMap, error) {
|
||||
var m storageMap
|
||||
data, err := os.ReadFile(f.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
m.Data = make(map[string]string)
|
||||
return m, nil
|
||||
}
|
||||
return m, fmt.Errorf("failed to read file %q: %w", f.filePath, err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return m, fmt.Errorf("failed to parse file %q: %w", f.filePath, err)
|
||||
}
|
||||
if m.Data == nil {
|
||||
m.Data = make(map[string]string)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ensureDir creates the parent directory of the store file if it does not exist.
|
||||
func (f *FileStore[T]) ensureDir() error {
|
||||
if err := os.MkdirAll(filepath.Dir(f.filePath), 0o700); err != nil {
|
||||
return fmt.Errorf("failed to create store directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeStorageMap marshals and atomically writes the storage map to the file.
|
||||
func (f *FileStore[T]) writeStorageMap(m storageMap) error {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempFile := f.filePath + ".tmp"
|
||||
if err := os.WriteFile(tempFile, data, 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
// WriteFile only applies the mode when creating the file; enforce it in
|
||||
// case a stale temp file with looser permissions was left behind.
|
||||
if err := os.Chmod(tempFile, 0o600); err != nil {
|
||||
_ = os.Remove(tempFile)
|
||||
return fmt.Errorf("failed to set temp file permissions: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tempFile, f.filePath); err != nil {
|
||||
_ = os.Remove(tempFile)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// withFileLock acquires an exclusive cross-process lock on filePath+".lock",
|
||||
// runs fn, and releases the lock. The kernel-level lock (flock/LockFileEx via
|
||||
// modules/filelock) is released automatically if the process dies, so no
|
||||
// stale-lock heuristics are needed. The .lock file itself remains on disk.
|
||||
func (f *FileStore[T]) withFileLock(fn func() error) error {
|
||||
return filelock.New(f.filePath+".lock", filelock.DefaultTimeout).WithLock(fn)
|
||||
}
|
||||
|
||||
// Load loads data from the file for the given client ID.
|
||||
// No file lock is needed: Save uses atomic rename, so reads always see a
|
||||
// consistent snapshot on POSIX systems.
|
||||
func (f *FileStore[T]) Load(clientID string) (T, error) {
|
||||
var zero T
|
||||
m, err := f.readStorageMap()
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
encoded, ok := m.Data[clientID]
|
||||
if !ok {
|
||||
return zero, ErrNotFound
|
||||
}
|
||||
|
||||
decoded, err := f.codec.Decode(encoded)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("failed to decode value from %q: %w", f.filePath, err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// Save saves data to the file for the given client ID.
|
||||
// Uses file locking to prevent race conditions.
|
||||
// Automatically creates parent directories if they do not exist.
|
||||
func (f *FileStore[T]) Save(clientID string, data T) error {
|
||||
if clientID == "" {
|
||||
return ErrEmptyClientID
|
||||
}
|
||||
|
||||
encoded, err := f.codec.Encode(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode value for storage: %w", err)
|
||||
}
|
||||
|
||||
if err := f.ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.withFileLock(func() error {
|
||||
m, err := f.readStorageMap()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.Data[clientID] = encoded
|
||||
|
||||
return f.writeStorageMap(m)
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes data for the given client ID from the file.
|
||||
func (f *FileStore[T]) Delete(clientID string) error {
|
||||
return f.withFileLock(func() error {
|
||||
m, err := f.readStorageMap()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := m.Data[clientID]; !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(m.Data, clientID)
|
||||
|
||||
return f.writeStorageMap(m)
|
||||
})
|
||||
}
|
||||
|
||||
// String returns a description of this store.
|
||||
func (f *FileStore[T]) String() string {
|
||||
return "file: " + f.filePath
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestFileStore(t *testing.T) (*FileStore[Token], string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "tokens.json")
|
||||
return NewFileStore[Token](path, JSONCodec[Token]{}), path
|
||||
}
|
||||
|
||||
func TestFileStoreSaveAndLoad(t *testing.T) {
|
||||
store, _ := newTestFileStore(t)
|
||||
|
||||
tok := Token{
|
||||
AccessToken: "test-access-token",
|
||||
RefreshToken: "test-refresh-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second),
|
||||
ClientID: "test-client",
|
||||
}
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
|
||||
loaded, err := store.Load("test-client")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tok.AccessToken, loaded.AccessToken)
|
||||
assert.Equal(t, tok.RefreshToken, loaded.RefreshToken)
|
||||
assert.Equal(t, tok.ClientID, loaded.ClientID)
|
||||
}
|
||||
|
||||
func TestFileStoreLoadNotFound(t *testing.T) {
|
||||
store, _ := newTestFileStore(t)
|
||||
|
||||
_, err := store.Load("nonexistent")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestFileStoreLoadFromExistingFileNotFound(t *testing.T) {
|
||||
store, _ := newTestFileStore(t)
|
||||
|
||||
require.NoError(t, store.Save("client-1", Token{AccessToken: "token-1", ClientID: "client-1"}))
|
||||
|
||||
_, err := store.Load("client-2")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestFileStoreDelete(t *testing.T) {
|
||||
store, _ := newTestFileStore(t)
|
||||
|
||||
require.NoError(t, store.Save("test-client", Token{AccessToken: "test-token", ClientID: "test-client"}))
|
||||
require.NoError(t, store.Delete("test-client"))
|
||||
|
||||
_, err := store.Load("test-client")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestFileStoreDeleteNonexistent(t *testing.T) {
|
||||
store, _ := newTestFileStore(t)
|
||||
|
||||
// Should not error when deleting from nonexistent file
|
||||
assert.NoError(t, store.Delete("nonexistent"))
|
||||
}
|
||||
|
||||
func TestFileStoreDeletePreservesOtherClients(t *testing.T) {
|
||||
store, _ := newTestFileStore(t)
|
||||
|
||||
for _, id := range []string{"client-1", "client-2"} {
|
||||
require.NoError(t, store.Save(id, Token{AccessToken: "token-" + id, ClientID: id}))
|
||||
}
|
||||
|
||||
require.NoError(t, store.Delete("client-1"))
|
||||
|
||||
loaded, err := store.Load("client-2")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "token-client-2", loaded.AccessToken)
|
||||
}
|
||||
|
||||
func TestFileStoreConcurrentWrites(t *testing.T) {
|
||||
store, _ := newTestFileStore(t)
|
||||
|
||||
const goroutines = 10
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(goroutines)
|
||||
for i := range goroutines {
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
|
||||
tok := Token{
|
||||
AccessToken: fmt.Sprintf("access-token-%d", id),
|
||||
ClientID: fmt.Sprintf("client-%d", id),
|
||||
}
|
||||
assert.NoError(t, store.Save(tok.ClientID, tok))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Verify all tokens were saved by loading each one
|
||||
for i := range goroutines {
|
||||
clientID := fmt.Sprintf("client-%d", i)
|
||||
loaded, err := store.Load(clientID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fmt.Sprintf("access-token-%d", i), loaded.AccessToken)
|
||||
}
|
||||
|
||||
// The kernel lock is released after the saves: the lock file (which
|
||||
// legitimately remains on disk with flock-style locking) must be
|
||||
// immediately re-lockable without hitting the timeout.
|
||||
require.NoError(t, store.withFileLock(func() error { return nil }))
|
||||
}
|
||||
|
||||
func TestFileStoreSaveEmptyClientID(t *testing.T) {
|
||||
store, _ := newTestFileStore(t)
|
||||
|
||||
err := store.Save("", Token{AccessToken: "tok"})
|
||||
assert.ErrorIs(t, err, ErrEmptyClientID)
|
||||
}
|
||||
|
||||
func TestFileStoreFilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("file permission test is not applicable on Windows")
|
||||
}
|
||||
|
||||
store, path := newTestFileStore(t)
|
||||
|
||||
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
||||
|
||||
info, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
||||
}
|
||||
|
||||
func TestFileStoreSaveCreatesParentDirectories(t *testing.T) {
|
||||
nestedPath := filepath.Join(t.TempDir(), "a", "b", "c", "tokens.json")
|
||||
store := NewFileStore[Token](nestedPath, JSONCodec[Token]{})
|
||||
|
||||
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
||||
|
||||
loaded, err := store.Load("c1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "tok", loaded.AccessToken)
|
||||
}
|
||||
|
||||
func TestFileStoreInvalidJSON(t *testing.T) {
|
||||
store, path := newTestFileStore(t)
|
||||
require.NoError(t, os.WriteFile(path, []byte("{invalid"), 0o600))
|
||||
|
||||
_, err := store.Load("any")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFileStoreNullDataField(t *testing.T) {
|
||||
store, path := newTestFileStore(t)
|
||||
require.NoError(t, os.WriteFile(path, []byte(`{"data": null}`), 0o600))
|
||||
|
||||
// A null data map must read as empty, not crash.
|
||||
_, err := store.Load("any")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
||||
}
|
||||
|
||||
func TestFileStoreWithFileLockPropagatesErrorAndReleases(t *testing.T) {
|
||||
store, _ := newTestFileStore(t)
|
||||
|
||||
sentinel := fmt.Errorf("sentinel failure")
|
||||
err := store.withFileLock(func() error { return sentinel })
|
||||
assert.ErrorIs(t, err, sentinel)
|
||||
|
||||
// The lock must have been released despite the error: re-acquiring
|
||||
// immediately must succeed without hitting the timeout.
|
||||
assert.NoError(t, store.withFileLock(func() error { return nil }))
|
||||
}
|
||||
|
||||
func TestFileStoreSaveLeavesNoTempFile(t *testing.T) {
|
||||
store, path := newTestFileStore(t)
|
||||
|
||||
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
||||
|
||||
_, err := os.Stat(path + ".tmp")
|
||||
assert.True(t, os.IsNotExist(err), "temp file left behind after successful save")
|
||||
}
|
||||
|
||||
func TestFileStoreDeleteAbsentKeyDoesNotRewriteFile(t *testing.T) {
|
||||
store, path := newTestFileStore(t)
|
||||
|
||||
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
||||
before, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Deleting a key that is not present must be a no-op write-wise:
|
||||
// other clients' data stays byte-identical on disk.
|
||||
require.NoError(t, store.Delete("absent"))
|
||||
after, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(before), string(after))
|
||||
}
|
||||
|
||||
func TestFileStoreString(t *testing.T) {
|
||||
store := NewFileStore[Token]("/path/to/tokens.json", JSONCodec[Token]{})
|
||||
assert.Equal(t, "file: /path/to/tokens.json", store.String())
|
||||
}
|
||||
|
||||
func TestFileStoreNilCodecPanics(t *testing.T) {
|
||||
assert.Panics(t, func() {
|
||||
NewFileStore[string]("path", nil)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// KeyringStore stores values in the OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Manager).
|
||||
type KeyringStore[T any] struct {
|
||||
serviceName string
|
||||
codec Codec[T]
|
||||
}
|
||||
|
||||
// NewKeyringStore creates a new KeyringStore with the given codec.
|
||||
// Panics if codec is nil.
|
||||
func NewKeyringStore[T any](serviceName string, codec Codec[T]) *KeyringStore[T] {
|
||||
if codec == nil {
|
||||
panic("credstore: NewKeyringStore called with nil codec")
|
||||
}
|
||||
return &KeyringStore[T]{serviceName: serviceName, codec: codec}
|
||||
}
|
||||
|
||||
// Load loads data from the keyring for the given client ID.
|
||||
func (k *KeyringStore[T]) Load(clientID string) (T, error) {
|
||||
var zero T
|
||||
data, err := keyring.Get(k.serviceName, clientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return zero, ErrNotFound
|
||||
}
|
||||
return zero, fmt.Errorf("failed to read from keyring: %w", err)
|
||||
}
|
||||
|
||||
decoded, err := k.codec.Decode(data)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("failed to decode keyring data: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// Save saves data to the keyring for the given client ID.
|
||||
func (k *KeyringStore[T]) Save(clientID string, data T) error {
|
||||
if clientID == "" {
|
||||
return ErrEmptyClientID
|
||||
}
|
||||
|
||||
encoded, err := k.codec.Encode(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode data for keyring: %w", err)
|
||||
}
|
||||
|
||||
if err := keyring.Set(k.serviceName, clientID, encoded); err != nil {
|
||||
return fmt.Errorf("failed to save to keyring: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes data for the given client ID from the keyring.
|
||||
func (k *KeyringStore[T]) Delete(clientID string) error {
|
||||
err := keyring.Delete(k.serviceName, clientID)
|
||||
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
|
||||
return fmt.Errorf("failed to delete from keyring: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns a description of this store.
|
||||
func (k *KeyringStore[T]) String() string {
|
||||
return "keyring: " + k.serviceName
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func TestKeyringStoreSaveAndLoad(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
store := NewStringKeyringStore("test-service")
|
||||
|
||||
require.NoError(t, store.Save("my-client", "eyJhbGciOiJSUzI1NiJ9"))
|
||||
|
||||
loaded, err := store.Load("my-client")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "eyJhbGciOiJSUzI1NiJ9", loaded)
|
||||
}
|
||||
|
||||
func TestKeyringStoreLoadNotFound(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
store := NewStringKeyringStore("test-service")
|
||||
|
||||
_, err := store.Load("nonexistent")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestKeyringStoreDelete(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
store := NewStringKeyringStore("test-service")
|
||||
|
||||
require.NoError(t, store.Save("test-client", "test-token"))
|
||||
require.NoError(t, store.Delete("test-client"))
|
||||
|
||||
_, err := store.Load("test-client")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestKeyringStoreDeleteNonexistent(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
store := NewStringKeyringStore("test-service")
|
||||
|
||||
// Should not error when deleting nonexistent key
|
||||
assert.NoError(t, store.Delete("nonexistent"))
|
||||
}
|
||||
|
||||
func TestKeyringStoreOverwriteExisting(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
store := NewStringKeyringStore("test-service")
|
||||
|
||||
require.NoError(t, store.Save("test-client", "token-v1"))
|
||||
require.NoError(t, store.Save("test-client", "token-v2"))
|
||||
|
||||
loaded, err := store.Load("test-client")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "token-v2", loaded)
|
||||
}
|
||||
|
||||
func TestKeyringStoreSaveEmptyClientID(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
store := NewStringKeyringStore("test-service")
|
||||
|
||||
err := store.Save("", "tok")
|
||||
assert.ErrorIs(t, err, ErrEmptyClientID)
|
||||
}
|
||||
|
||||
func TestKeyringStoreString(t *testing.T) {
|
||||
store := NewStringKeyringStore("my-service")
|
||||
assert.Equal(t, "keyring: my-service", store.String())
|
||||
}
|
||||
|
||||
func TestKeyringStoreNilCodecPanics(t *testing.T) {
|
||||
assert.Panics(t, func() {
|
||||
NewKeyringStore[string]("svc", nil)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
// Prober is an optional interface that a Store can implement to test
|
||||
// whether its backend is available.
|
||||
type Prober interface {
|
||||
Probe() bool
|
||||
}
|
||||
|
||||
// DefaultSecureStore creates a SecureStore with the given codec and sensible defaults.
|
||||
// The primary backend is an EncryptedFileStore writing to filePath+".enc"
|
||||
// with its master key in the OS keyring; see EncryptedFileStore for why only
|
||||
// the key lives there. When the keyring is unavailable, it falls back to
|
||||
// plaintext file storage at filePath.
|
||||
func DefaultSecureStore[T any](serviceName, filePath string, codec Codec[T]) *SecureStore[T] {
|
||||
return NewSecureStore[T](
|
||||
NewEncryptedFileStore[T](serviceName, filePath+".enc", codec),
|
||||
NewFileStore[T](filePath, codec))
|
||||
}
|
||||
|
||||
// SecureStore is a composite Store that uses the keyring-backed primary
|
||||
// store when the keyring is available and falls back to file-based storage
|
||||
// otherwise. The active backend is chosen once at construction time.
|
||||
type SecureStore[T any] struct {
|
||||
active Store[T]
|
||||
}
|
||||
|
||||
// NewSecureStore creates a SecureStore. If kr implements Prober and the probe
|
||||
// succeeds, kr is used as the active store. Otherwise, file is used as the
|
||||
// fallback.
|
||||
func NewSecureStore[T any](kr, file Store[T]) *SecureStore[T] {
|
||||
if p, ok := kr.(Prober); ok && p.Probe() {
|
||||
return &SecureStore[T]{active: kr}
|
||||
}
|
||||
return &SecureStore[T]{active: file}
|
||||
}
|
||||
|
||||
// Load loads data from the active store.
|
||||
func (s *SecureStore[T]) Load(clientID string) (T, error) {
|
||||
return s.active.Load(clientID)
|
||||
}
|
||||
|
||||
// Save saves data to the active store.
|
||||
func (s *SecureStore[T]) Save(clientID string, data T) error {
|
||||
return s.active.Save(clientID, data)
|
||||
}
|
||||
|
||||
// Delete removes data from the active store.
|
||||
func (s *SecureStore[T]) Delete(clientID string) error {
|
||||
return s.active.Delete(clientID)
|
||||
}
|
||||
|
||||
// String returns a description of the active store.
|
||||
func (s *SecureStore[T]) String() string {
|
||||
return s.active.String()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// mockStore is a simple mock implementing Store[T] for testing.
|
||||
type mockStore[T any] struct {
|
||||
data map[string]T
|
||||
name string
|
||||
}
|
||||
|
||||
func newMockStore[T any](name string) *mockStore[T] {
|
||||
return &mockStore[T]{
|
||||
data: make(map[string]T),
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockStore[T]) Load(clientID string) (T, error) {
|
||||
data, ok := m.data[clientID]
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, ErrNotFound
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (m *mockStore[T]) Save(clientID string, data T) error {
|
||||
m.data[clientID] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStore[T]) Delete(clientID string) error {
|
||||
delete(m.data, clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStore[T]) String() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
// mockProberStore implements both Store[T] and Prober.
|
||||
type mockProberStore[T any] struct {
|
||||
mockStore[T]
|
||||
probeResult bool
|
||||
}
|
||||
|
||||
func newMockProberStore[T any](name string, probeResult bool) *mockProberStore[T] {
|
||||
return &mockProberStore[T]{
|
||||
mockStore: mockStore[T]{data: make(map[string]T), name: name},
|
||||
probeResult: probeResult,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockProberStore[T]) Probe() bool {
|
||||
return m.probeResult
|
||||
}
|
||||
|
||||
func TestSecureStoreUsesKeyringWhenProbeSucceeds(t *testing.T) {
|
||||
kr := newMockProberStore[Token]("keyring: test", true)
|
||||
file := newMockStore[Token]("file: test")
|
||||
|
||||
store := NewSecureStore[Token](kr, file)
|
||||
|
||||
tok := Token{
|
||||
AccessToken: "test-token",
|
||||
ClientID: "test-client",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
|
||||
// Should be in keyring, not file
|
||||
assert.Contains(t, kr.data, "test-client")
|
||||
assert.NotContains(t, file.data, "test-client")
|
||||
|
||||
loaded, err := store.Load("test-client")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-token", loaded.AccessToken)
|
||||
assert.Equal(t, "keyring: test", store.String())
|
||||
}
|
||||
|
||||
func TestSecureStoreFallsBackToFileWhenProbeFails(t *testing.T) {
|
||||
kr := newMockProberStore[Token]("keyring: test", false)
|
||||
file := newMockStore[Token]("file: test")
|
||||
|
||||
store := NewSecureStore[Token](kr, file)
|
||||
|
||||
tok := Token{
|
||||
AccessToken: "test-token",
|
||||
ClientID: "test-client",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
|
||||
// Should be in file, not keyring
|
||||
assert.Contains(t, file.data, "test-client")
|
||||
assert.NotContains(t, kr.data, "test-client")
|
||||
assert.Equal(t, "file: test", store.String())
|
||||
}
|
||||
|
||||
func TestSecureStoreFallsBackWhenKrNotProber(t *testing.T) {
|
||||
// kr does not implement Prober, should fall back to file
|
||||
kr := newMockStore[Token]("keyring: test")
|
||||
file := newMockStore[Token]("file: test")
|
||||
|
||||
store := NewSecureStore[Token](kr, file)
|
||||
|
||||
assert.Equal(t, "file: test", store.String())
|
||||
}
|
||||
|
||||
func TestSecureStoreDelete(t *testing.T) {
|
||||
kr := newMockProberStore[Token]("keyring: test", true)
|
||||
file := newMockStore[Token]("file: test")
|
||||
store := NewSecureStore[Token](kr, file)
|
||||
|
||||
tok := Token{
|
||||
AccessToken: "test-token",
|
||||
ClientID: "test-client",
|
||||
}
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
require.NoError(t, store.Delete("test-client"))
|
||||
|
||||
_, err := store.Load("test-client")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
// TestDefaultTokenSecureStoreRoundTrip verifies the happy path: with a working
|
||||
// keyring, Save writes AES-256-GCM ciphertext (v1: prefix) to filePath+".enc",
|
||||
// Load returns the identical token, and Delete makes Load return ErrNotFound.
|
||||
func TestDefaultTokenSecureStoreRoundTrip(t *testing.T) {
|
||||
keyring.MockInit() // avoid touching the real OS keyring
|
||||
plainPath := filepath.Join(t.TempDir(), "credentials.json")
|
||||
store := DefaultTokenSecureStore("test-service", plainPath)
|
||||
|
||||
tok := Token{
|
||||
AccessToken: "secret-access-token",
|
||||
RefreshToken: "secret-refresh-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second),
|
||||
ClientID: "test-client",
|
||||
}
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
|
||||
loaded, err := store.Load("test-client")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tok.AccessToken, loaded.AccessToken)
|
||||
assert.Equal(t, tok.RefreshToken, loaded.RefreshToken)
|
||||
assert.Equal(t, tok.TokenType, loaded.TokenType)
|
||||
assert.Equal(t, tok.ClientID, loaded.ClientID)
|
||||
assert.True(t, tok.ExpiresAt.Equal(loaded.ExpiresAt))
|
||||
|
||||
// The encrypted file must exist and contain only v1:-prefixed ciphertext.
|
||||
raw, err := os.ReadFile(plainPath + ".enc")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(raw), `"v1:`)
|
||||
assert.NotContains(t, string(raw), "secret-access-token")
|
||||
assert.NotContains(t, string(raw), "secret-refresh-token")
|
||||
|
||||
// No plaintext fallback file may be created.
|
||||
_, err = os.Stat(plainPath)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
|
||||
require.NoError(t, store.Delete("test-client"))
|
||||
_, err = store.Load("test-client")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
// TestDefaultTokenSecureStoreFallbackWithoutKeyring verifies the CI/headless
|
||||
// path: when the OS keyring is unavailable, the store falls back to the
|
||||
// plaintext file and Save/Load still succeed.
|
||||
func TestDefaultTokenSecureStoreFallbackWithoutKeyring(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keyring unavailable"))
|
||||
t.Cleanup(keyring.MockInit) // restore a working mock for later tests
|
||||
|
||||
plainPath := filepath.Join(t.TempDir(), "credentials.json")
|
||||
store := DefaultTokenSecureStore("test-service", plainPath)
|
||||
|
||||
tok := Token{
|
||||
AccessToken: "fallback-token",
|
||||
ClientID: "test-client",
|
||||
}
|
||||
require.NoError(t, store.Save(tok.ClientID, tok))
|
||||
assert.Equal(t, "file: "+plainPath, store.String())
|
||||
|
||||
loaded, err := store.Load("test-client")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "fallback-token", loaded.AccessToken)
|
||||
|
||||
// Plaintext file exists, encrypted file does not.
|
||||
raw, err := os.ReadFile(plainPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.Contains(string(raw), "fallback-token"))
|
||||
_, err = os.Stat(plainPath + ".enc")
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
// Format-stability fixture: a fixed master key and a credentials.json.enc
|
||||
// file in the "v1:" AES-256-GCM format. They must remain decryptable so
|
||||
// users do not lose their stored tokens when upgrading tea.
|
||||
const (
|
||||
fixtureMasterKeyB64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
fixtureEncFile = `{
|
||||
"data": {
|
||||
"fixture-login": "v1:KallKg6+rJ3Sbxf6Kz1E5yF9bRgqq0Of00ZSctEY2Dem6qpm2wt9RdpCqSMdoX9AQ6/u9ujuC4a0LPb1n3ryXm0EJGrFpXHff0ukpatB1OZhdYlgcbuA8EFpPF/rSgN1hMXOXYQFn64r3iIEaXkgW69s887RNLbaxXALy3o7qvzmEWXuTPtEy3x+J4O6pmbDusvqgVWrOLPT9A1fSJnXWcViUcG13JF0X36NFPc149hsf1S0OUB2Uwn3hVl8jIISaw=="
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
// TestDefaultTokenSecureStoreReadsFixtureData verifies on-disk format
|
||||
// compatibility: the AES-256-GCM "v1:" ciphertext format (as produced by the
|
||||
// original SDK implementation) with the master key in the keyring is
|
||||
// decrypted correctly.
|
||||
func TestDefaultTokenSecureStoreReadsFixtureData(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
require.NoError(t, keyring.Set("tea-cli", masterKeyUser, fixtureMasterKeyB64))
|
||||
|
||||
plainPath := filepath.Join(t.TempDir(), "credentials.json")
|
||||
require.NoError(t, os.WriteFile(plainPath+".enc", []byte(fixtureEncFile), 0o600))
|
||||
|
||||
store := DefaultTokenSecureStore("tea-cli", plainPath)
|
||||
|
||||
loaded, err := store.Load("fixture-login")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "fixture-access-token", loaded.AccessToken)
|
||||
assert.Equal(t, "fixture-refresh-token", loaded.RefreshToken)
|
||||
assert.Equal(t, "Bearer", loaded.TokenType)
|
||||
assert.Equal(t, "fixture-login", loaded.ClientID)
|
||||
assert.True(t, loaded.ExpiresAt.Equal(time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC)))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotFound indicates that no data was found for the given client ID.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrEmptyClientID is returned when an empty client ID is passed to Save.
|
||||
var ErrEmptyClientID = errors.New("client ID cannot be empty")
|
||||
|
||||
// Store defines the interface for loading, saving, and deleting data by client ID.
|
||||
type Store[T any] interface {
|
||||
Load(clientID string) (T, error)
|
||||
Save(clientID string, data T) error
|
||||
Delete(clientID string) error
|
||||
String() string
|
||||
}
|
||||
|
||||
// Token represents saved tokens for a specific client.
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
// IsExpired reports whether the token has expired.
|
||||
// Returns false if ExpiresAt is zero (token has no expiry).
|
||||
func (t *Token) IsExpired() bool {
|
||||
return !t.ExpiresAt.IsZero() && time.Now().After(t.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsValid reports whether the token has a non-empty access token and is not expired.
|
||||
func (t *Token) IsValid() bool {
|
||||
return t.AccessToken != "" && !t.IsExpired()
|
||||
}
|
||||
|
||||
// NewStringKeyringStore creates a KeyringStore for plain string values.
|
||||
func NewStringKeyringStore(serviceName string) *KeyringStore[string] {
|
||||
return NewKeyringStore[string](serviceName, StringCodec{})
|
||||
}
|
||||
|
||||
// DefaultTokenSecureStore creates a SecureStore for Token values with sensible defaults.
|
||||
// Tokens are AES-256-GCM-encrypted to filePath+".enc" with the master key in
|
||||
// the OS keyring; see DefaultSecureStore for details.
|
||||
func DefaultTokenSecureStore(serviceName, filePath string) *SecureStore[Token] {
|
||||
return DefaultSecureStore[Token](serviceName, filePath, JSONCodec[Token]{})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTokenIsExpired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "expired token",
|
||||
expiresAt: time.Now().Add(-1 * time.Hour),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "not expired token",
|
||||
expiresAt: time.Now().Add(1 * time.Hour),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "zero expiry (no expiry)",
|
||||
expiresAt: time.Time{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token := &Token{
|
||||
AccessToken: "test-token",
|
||||
ExpiresAt: tt.expiresAt,
|
||||
}
|
||||
assert.Equal(t, tt.want, token.IsExpired())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
accessToken string
|
||||
expiresAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "valid token with future expiry",
|
||||
accessToken: "test-token",
|
||||
expiresAt: time.Now().Add(1 * time.Hour),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "valid token with zero expiry",
|
||||
accessToken: "test-token",
|
||||
expiresAt: time.Time{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
accessToken: "test-token",
|
||||
expiresAt: time.Now().Add(-1 * time.Hour),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty access token",
|
||||
accessToken: "",
|
||||
expiresAt: time.Now().Add(1 * time.Hour),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token := &Token{
|
||||
AccessToken: tt.accessToken,
|
||||
ExpiresAt: tt.expiresAt,
|
||||
}
|
||||
assert.Equal(t, tt.want, token.IsValid())
|
||||
})
|
||||
}
|
||||
}
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# upload-r2.sh uploads a single local file to a single object key in a
|
||||
# Cloudflare R2 bucket, using curl's built-in AWS SigV4 signer (R2 is
|
||||
# S3-API compatible).
|
||||
#
|
||||
# This is the R2 half of the release process's parallel S3+R2 upload
|
||||
# period: goreleaser's `blobs:` pipe still uploads every release
|
||||
# artifact to AWS S3, and this script is invoked once per artifact
|
||||
# (via a goreleaser `publishers:` entry) to mirror the same artifact
|
||||
# into R2. Once the migration away from S3 is complete, the `blobs:`
|
||||
# block and the AWS_* secrets can be dropped without touching this
|
||||
# script.
|
||||
#
|
||||
# Usage:
|
||||
# upload-r2.sh <local-file> <remote-key>
|
||||
# upload-r2.sh --check-config
|
||||
#
|
||||
# The second form only validates that the required environment
|
||||
# variables below are set (it does not touch the network or the
|
||||
# filesystem beyond that), and is meant to be run as an early
|
||||
# preflight step in CI: goreleaser custom publishers run as the very
|
||||
# last step of the publish pipeline, so without a preflight check a
|
||||
# missing R2_* secret would only be discovered after the Gitea release
|
||||
# has already been created and every artifact already uploaded to S3.
|
||||
#
|
||||
# Required environment variables:
|
||||
# R2_ENDPOINT Base URL of the R2 endpoint, e.g.
|
||||
# https://<account>.r2.cloudflarestorage.com
|
||||
# R2_BUCKET Destination bucket name.
|
||||
# R2_ACCESS_KEY_ID R2 access key id.
|
||||
# R2_SECRET_ACCESS_KEY R2 secret access key.
|
||||
|
||||
set -eu
|
||||
|
||||
# check_env validates that all required R2_* environment variables are
|
||||
# set and non-empty, printing a single "missing required environment
|
||||
# variable(s): ..." message and exiting non-zero otherwise. Used by
|
||||
# both the normal upload mode and --check-config, so the validation
|
||||
# logic only exists in one place.
|
||||
check_env() {
|
||||
missing=""
|
||||
|
||||
if [ -z "${R2_ENDPOINT:-}" ]; then
|
||||
missing="$missing R2_ENDPOINT"
|
||||
fi
|
||||
if [ -z "${R2_BUCKET:-}" ]; then
|
||||
missing="$missing R2_BUCKET"
|
||||
fi
|
||||
if [ -z "${R2_ACCESS_KEY_ID:-}" ]; then
|
||||
missing="$missing R2_ACCESS_KEY_ID"
|
||||
fi
|
||||
if [ -z "${R2_SECRET_ACCESS_KEY:-}" ]; then
|
||||
missing="$missing R2_SECRET_ACCESS_KEY"
|
||||
fi
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
echo "upload-r2.sh: missing required environment variable(s):$missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "--check-config" ]; then
|
||||
check_env
|
||||
echo "upload-r2.sh: R2 configuration OK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: upload-r2.sh <local-file> <remote-key>" >&2
|
||||
echo " upload-r2.sh --check-config" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local_file="$1"
|
||||
remote_key="$2"
|
||||
|
||||
if [ ! -f "$local_file" ]; then
|
||||
echo "upload-r2.sh: local file not found: $local_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check_env
|
||||
|
||||
# Strip a single trailing slash from the endpoint, if present, so that
|
||||
# building the path-style URL below never produces a double slash.
|
||||
endpoint="${R2_ENDPOINT%/}"
|
||||
url="$endpoint/$R2_BUCKET/$remote_key"
|
||||
|
||||
# Credentials are passed to curl through a config file read from
|
||||
# stdin rather than as a command-line argument, so they never show up
|
||||
# in `ps` output.
|
||||
#
|
||||
# --fail-with-body (instead of plain --fail) still exits non-zero on
|
||||
# HTTP errors, but also prints R2's XML error body, which is where the
|
||||
# actual error code lives (SignatureDoesNotMatch, NoSuchBucket,
|
||||
# AccessDenied, ...); with plain --fail that body is discarded and the
|
||||
# failure is silent. --retry 3 (without --retry-all-errors) still
|
||||
# retries the transient cases (5xx, 408, 429, connection failures);
|
||||
# --retry-all-errors would additionally retry permanent 4xx responses
|
||||
# three times with backoff, which only delays an inevitable failure.
|
||||
printf 'user = "%s:%s"\n' "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY" | curl \
|
||||
--config - \
|
||||
--fail-with-body \
|
||||
--silent \
|
||||
--show-error \
|
||||
--retry 3 \
|
||||
--aws-sigv4 "aws:amz:auto:s3" \
|
||||
--upload-file "$local_file" \
|
||||
"$url"
|
||||
@@ -29,6 +29,8 @@ func TestRepoFromPath_Worktree(t *testing.T) {
|
||||
assert.NoError(t, cmd.Run())
|
||||
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User")
|
||||
assert.NoError(t, cmd.Run())
|
||||
cmd = exec.Command("git", "-C", mainRepoPath, "config", "commit.gpgsign", "false")
|
||||
assert.NoError(t, cmd.Run())
|
||||
|
||||
cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
|
||||
assert.NoError(t, cmd.Run())
|
||||
|
||||
Reference in New Issue
Block a user