Compare commits

..

8 Commits

Author SHA1 Message Date
Bo-Yi Wu b645a189a2 fix(login): delete OAuth token using the stored login name
- Pass the stored login name to DeleteOAuthToken instead of the
  CLI-provided one: the login lookup is case-insensitive but credstore
  keys are exact-match, so deleting with different casing left the
  encrypted token orphaned in the credential store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:59:44 +08:00
Bo-Yi Wu f6d939a8df refactor(credstore): embed credential store and drop sdk-go dependency
- Embed the minimal credstore subset used by tea (SecureStore,
  EncryptedFileStore, KeyringStore, FileStore) as modules/credstore so
  external SDK renames can no longer break the build
- Keep the on-disk format fully compatible: AES-256-GCM values with the
  v1: prefix, credentials.json / credentials.json.enc paths, and the
  Token JSON field names are unchanged, verified by a ciphertext fixture
  generated with sdk-go v1.1.0
- Store the keyring master key under a tea-owned account name
- Reuse the existing kernel-level filelock module instead of the
  upstream lockfile protocol, removing a stale-lock race
- Cover roundtrip, keyring-unavailable fallback, and fixture decryption
  with tests using a mocked keyring
- Remove github.com/go-signet/sdk-go and promote
  github.com/zalando/go-keyring to a direct dependency

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:59:07 +08:00
Bo-Yi Wu f34697c5ed chore(config): replace authgate SDK with signet (#1081)
## Summary

- Replace `github.com/go-authgate/sdk-go` with `github.com/go-signet/sdk-go v1.1.0`.
- Update the credential-store import while retaining the existing `credstore` API and OAuth token persistence
behavior.

## Related issues

- GitHub/Gitea: fixed https://gitea.com/gitea/tea/issues/1058

Reviewed-on: https://gitea.com/gitea/tea/pulls/1081
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-08-02 14:38:25 +00:00
Ross Golder a613a344de fix(test): disable gpg signing in worktree test repo (#1072)
Fixes #1071

`TestRepoFromPath_Worktree` creates a throwaway temp repo and commits to it. On machines with `commit.gpgsign=true` in global git config, the commit fails with `No secret key`.

Override the global setting by setting `commit.gpgsign=false` in the temp repo's local config so the test is environment-independent.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1072
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Ross Golder <ross@golder.org>
2026-07-30 00:29:29 +00:00
Renovate Bot 6435b12202 chore(deps): pin dependencies (#1064)
chore(deps): pin dependencies (gitea/tea#1064)

Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-07-27 22:02:00 +00:00
Lunny Xiao 61b8536e4a ci(goreleaser): mirror release artifacts to Cloudflare R2 (#1063)
Ports the Cloudflare R2 release mirror from [gitea.com/gitea/runner](https://gitea.com/gitea/runner) to `tea`, so release artifacts land in R2 alongside S3 for the duration of the migration away from S3.

Two commits, meant to be reviewed in order.

## 1. `ci(goreleaser): migrate release config to goreleaser v2`

A purely mechanical migration, no behaviour change intended:

- add `version: 2`
- `blobs.folder` -> `blobs.directory`
- `snapshot.name_template` -> `snapshot.version_template`
- `nightly.name_template` -> `nightly.version_template`
- `version: "~> v1"` -> `"~> v2"` in both release workflows

This is a prefactor rather than scope creep. Under goreleaser v1 the custom-publisher pipe runs *4th*, before the `release` pipe; under v2 it runs *last*. That ordering difference matters for the change below: on v1 a failed R2 upload would abort the publish after every artifact had already gone to S3 but **before** the Gitea release was created, leaving a half-finished release. On v2 the R2 mirror runs after the release exists, which matches the behaviour the runner repo already has in production.

The pre-existing `archives.format` deprecation warning is deliberately left alone; it is orthogonal to this change and the runner repo has not addressed it either.

## 2. `ci(goreleaser): mirror release artifacts to Cloudflare R2`

- **`scripts/upload-r2.sh`** — uploads one local file to one R2 object key using curl's built-in AWS SigV4 signer (R2 is S3-API compatible). Credentials are fed through `curl --config -` so they never appear in `ps` output. Also provides a `--check-config` preflight mode. This file is byte-identical to the runner repo's copy.
- **`.goreleaser.yaml`** — a `publishers:` entry mirroring the existing S3 `blobs:` upload into R2. A second `blobs:` entry is not usable here: the blob pipe authenticates from the global `AWS_*` environment and has no per-entry credentials, whereas `publishers:` supports per-entry `env:`.
- **Both release workflows** — forward the R2 secrets, plus an early `check R2 configuration` step. Custom publishers run as the very last step of goreleaser's publish pipeline, so without a preflight a missing secret would only surface after the release had been created and every artifact already uploaded to S3.

### Deviation from the runner implementation

The publisher here also sets `signature: true` in addition to `checksum: true`. `tea` has a `signs:` block that GPG-signs the checksum file, and the S3 blob pipe uploads the resulting `checksums.txt.sig`; without `signature: true` the R2 mirror would carry the artifacts and their checksums but no signature to verify them against.

The object key prefix is `tea/{{ .Version }}/...`, matching the existing S3 `directory: "tea/{{.Version}}"`.

## Required repository secrets

This PR is inert until these are configured. The preflight step will fail the release loudly if they are missing:

- `R2_ENDPOINT` — e.g. `https://<account>.r2.cloudflarestorage.com`
- `R2_BUCKET`
- `R2_ACCESS_KEY_ID`
- `R2_SECRET_ACCESS_KEY`

## Verification

- `goreleaser check` against the migrated config (with the pro-only `nightly:` block temporarily stripped, since the check ran with the OSS v2 binary): *configuration is valid*, the only deprecation being the pre-existing `archives.format`.
- A real `goreleaser build --snapshot --clean --single-target` against the v2 config: succeeded, including the `xz` and `.goreleaser.checksum.sh` post-hooks.
- `scripts/upload-r2.sh`: clean under `sh -n` and `shellcheck`; all four `--check-config` cases exercised (all vars unset, one missing, all set, wrong argument count).
- The actual upload path was not exercised end to end, since that needs live R2 credentials.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1063
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-07-26 03:21:03 +00:00
Zach Winter 73b6bf3e23 fix(context): clarify the fallback login prompt wording (#1061)
The prompt shown when no login matches the current repository reads:

```
NOTE: no gitea login detected, whether falling back to login 'X'?
```

Two problems, both raised by @magistra-aria in #817:

- **"whether"** is a conjunction that needs two stated alternatives, so it doesn't parse in front of a yes/no confirm.
- **"no gitea login detected"** is misleading. The condition is that no *configured login matched this repository's remote* — not that a Gitea instance is missing. Read literally it suggests the CLI expects gitea.com specifically, which is how at least one user (me) first misread it.

Reworded to say what actually happened, for both the interactive prompt and its non-interactive counterpart:

```
NOTE: no login matched this repository. Fall back to login 'X'?
```

Strings only, no logic change.

Refs #817

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Zach Winter <contact@zachwinter.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/1061
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Zach Winter <222839+zachwinter@noreply.gitea.com>
2026-07-26 01:38:10 +00:00
Lunny Xiao 993eb37b57 Fix notifications --mine outside git repositories (#1056)
Fixes #1055.

## Root cause
`tea notifications --mine` still initialized full repository context before checking the global notification scope, so it probed the current working directory with git and could select or fail on repository-derived context even though repository data is not needed.

## Changes
- Add an InitCommand option to skip local git repository discovery when a command does not need repository context.
- Use that option for notification list and mark-as operations when `--mine` is set.
- Add a regression test that makes `git` fail if invoked and verifies `notifications --mine` still uses the global notifications API.

## Tests
- `go test ./cmd/notifications ./modules/context`

Reviewed-on: https://gitea.com/gitea/tea/pulls/1056
2026-07-26 01:37:33 +00:00
26 changed files with 1766 additions and 57 deletions
+26 -10
View File
@@ -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@v7
# 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:
+26 -10
View File
@@ -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@v7
# 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:
+4 -4
View File
@@ -16,8 +16,8 @@ jobs:
name: Lint Build And Unit Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
- 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@v7
- 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
View File
@@ -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
+2 -2
View File
@@ -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)
+55
View File
@@ -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))
}
+4 -4
View File
@@ -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
}
+1 -2
View File
@@ -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
-2
View File
@@ -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=
+2 -1
View File
@@ -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"
)
+4 -1
View File
@@ -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
View File
@@ -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)
}
}
+49
View File
@@ -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
}
+8
View File
@@ -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
+259
View File
@@ -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
}
+140
View File
@@ -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())
}
+171
View File
@@ -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
}
+220
View File
@@ -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)
})
}
+76
View File
@@ -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
}
+81
View File
@@ -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)
})
}
+58
View File
@@ -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()
}
+242
View File
@@ -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)))
}
+57
View File
@@ -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]{})
}
+89
View File
@@ -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())
})
}
}
+112
View File
@@ -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"
+2
View File
@@ -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())