mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
refactor: add settings, transport and alias packages (#1122)
Part 1/5 of the tea 1.0 rewrite. **Stack** (split from #1121; each PR targets `main` and builds, tests and lints on its own — the shipped CLI only changes in part 5): 1. #1122 Toolchain and foundation packages ← this PR 2. #1123 Credentials and Git 3. #1124 Terminal 4. #1125 Services 5. #1126 CLI wiring, docs, removal of the old tree ## Changes - **build:** require Go 1.27; bump gofumpt and golangci-lint to releases that can read Go 1.27 export data (own commit). - `internal/clierr`: typed errors and executable exit codes. - `internal/version`: build version and tags. - `internal/filelock`: moved unchanged from `modules/filelock`. - `internal/aliases`: user-defined command aliases. - `internal/transport`: the HTTP client the Gitea SDK runs on (part 4): token, Sudo and SSH-signature auth, retries for safe reads, connect/TLS/response-header timeouts, typed API errors that keep Gitea's message, and a redacted request log for `--debug`; plus raw requests for `tea api`, signed downloads that follow object-storage redirects, and log streaming. - `internal/settings`: split `config.yml` / `hosts.yml` with locked transactions, plus remote URL parsing. Nothing imports these packages yet. Note: `flake.nix` now asks for `go_1_27`; the pinned nixpkgs in `flake.lock` predates Go 1.27 and still needs a `nix flake update nixpkgs`. Reviewed-on: https://gitea.com/gitea/tea/pulls/1122 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
bcd62a1fb2
commit
2b218a5bae
29 files changed
+2541
-77
No files matched your search
@@ -7,8 +7,8 @@ export PATH := $($(GO) env GOPATH)/bin:$(PATH)
|
|||||||
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
|
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
|
||||||
|
|
||||||
# Tool packages with pinned versions
|
# Tool packages with pinned versions
|
||||||
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
|
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.12.0
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0
|
||||||
|
|
||||||
ifneq ($(DRONE_TAG),)
|
ifneq ($(DRONE_TAG),)
|
||||||
VERSION ?= $(subst v,,$(DRONE_TAG))
|
VERSION ?= $(subst v,,$(DRONE_TAG))
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
name = "tea-dev-environment";
|
name = "tea-dev-environment";
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
go_1_25
|
go_1_27
|
||||||
gopls
|
gopls
|
||||||
gnumake
|
gnumake
|
||||||
# Add other dependencies here if needed
|
# Add other dependencies here if needed
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
module gitea.dev/tea
|
module gitea.dev/tea
|
||||||
|
|
||||||
go 1.26.0
|
go 1.27.0
|
||||||
|
|
||||||
toolchain go1.26.6
|
toolchain go1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/glamour/v2 v2.0.1
|
charm.land/glamour/v2 v2.0.1
|
||||||
@@ -10,21 +10,22 @@ require (
|
|||||||
charm.land/lipgloss/v2 v2.0.5
|
charm.land/lipgloss/v2 v2.0.5
|
||||||
code.gitea.io/gitea-vet v0.2.3
|
code.gitea.io/gitea-vet v0.2.3
|
||||||
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
|
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
|
||||||
gitea.dev/sdk v1.2.0
|
gitea.dev/sdk v1.3.0
|
||||||
github.com/adrg/xdg v0.5.3
|
github.com/adrg/xdg v0.5.3
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||||
github.com/enescakir/emoji v1.0.0
|
github.com/enescakir/emoji v1.0.0
|
||||||
github.com/go-signet/sdk-go v1.1.0
|
github.com/go-signet/sdk-go v1.1.0
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/olekukonko/tablewriter v1.1.4
|
github.com/olekukonko/tablewriter v1.1.4
|
||||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/urfave/cli-docs/v3 v3.1.0
|
github.com/urfave/cli-docs/v3 v3.1.0
|
||||||
github.com/urfave/cli/v3 v3.10.1
|
github.com/urfave/cli/v3 v3.10.1
|
||||||
golang.org/x/crypto v0.56.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.48.0
|
||||||
golang.org/x/term v0.45.0
|
golang.org/x/term v0.46.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ require (
|
|||||||
charm.land/bubbles/v2 v2.1.0 // indirect
|
charm.land/bubbles/v2 v2.1.0 // indirect
|
||||||
charm.land/bubbletea/v2 v2.0.6 // indirect
|
charm.land/bubbletea/v2 v2.0.6 // indirect
|
||||||
github.com/42wim/httpsig v1.2.4 // indirect
|
github.com/42wim/httpsig v1.2.4 // indirect
|
||||||
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
@@ -42,7 +43,7 @@ require (
|
|||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260511125431-fe5d686e0c99 // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
@@ -51,37 +52,35 @@ require (
|
|||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||||
github.com/olekukonko/errors v1.3.0 // indirect
|
github.com/olekukonko/errors v1.2.0 // indirect
|
||||||
github.com/olekukonko/ll v0.1.8 // indirect
|
github.com/olekukonko/ll v0.1.6 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||||
github.com/zalando/go-keyring v0.2.8 // indirect
|
github.com/zalando/go-keyring v0.2.8 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
golang.org/x/net v0.57.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
golang.org/x/tools v0.48.0 // indirect
|
golang.org/x/tools v0.49.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
retract v1.3.3 // accidental release, tag deleted
|
retract v1.3.3 // accidental release, tag deleted
|
||||||
@@ -12,20 +12,20 @@ code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
|
|||||||
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
|
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
|
||||||
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA=
|
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA=
|
||||||
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI=
|
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI=
|
||||||
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo=
|
gitea.dev/sdk v1.3.0 h1:4U95+Ou6WHCQiRPDEyAzvl1Q7cxg5RUTWkqmOXr5w4I=
|
||||||
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE=
|
gitea.dev/sdk v1.3.0/go.mod h1:85EBqSAqUn1KlFCk40y3sTjWjBsB/KouL3slGXMA/2o=
|
||||||
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||||
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||||
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
@@ -54,8 +54,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g
|
|||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260511125431-fe5d686e0c99 h1:e4VttUIAVgO4neqnJG80U4BE//1kcvyOrJ5utftPXQE=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260511125431-fe5d686e0c99/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||||
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
|
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
|
||||||
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
@@ -77,22 +77,18 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
|
|||||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog=
|
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/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.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/go-signet/sdk-go v1.1.0 h1:wHKg9P+goQ14A1Q0gtC6m3mCzRFWwL1peAGz/zhmAZQ=
|
github.com/go-signet/sdk-go v1.1.0 h1:wHKg9P+goQ14A1Q0gtC6m3mCzRFWwL1peAGz/zhmAZQ=
|
||||||
github.com/go-signet/sdk-go v1.1.0/go.mod h1:bmi7nDAu7o6MQnUE3K7ZNEKU4xqh3u/SMbPC5GanOR8=
|
github.com/go-signet/sdk-go v1.1.0/go.mod h1:bmi7nDAu7o6MQnUE3K7ZNEKU4xqh3u/SMbPC5GanOR8=
|
||||||
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=
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
@@ -103,12 +99,14 @@ github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaX
|
|||||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
@@ -122,13 +120,12 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
|||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||||
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
|
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
|
||||||
github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||||
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
|
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
|
||||||
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
|
||||||
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
||||||
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
@@ -141,11 +138,11 @@ github.com/seletskiy/tplutil v0.0.0-20200921103632-f880f6245597/go.mod h1:F8CBHS
|
|||||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw=
|
github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw=
|
||||||
github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to=
|
github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to=
|
||||||
github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY=
|
github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY=
|
||||||
@@ -153,51 +150,55 @@ github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoM
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package aliases expands built-in command shortcuts without executing a shell.
|
||||||
|
package aliases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kballard/go-shellquote"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
placeholder = regexp.MustCompile(`\$([1-9][0-9]*)`)
|
||||||
|
namePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxDepth bounds alias expansion even when configuration is edited by hand.
|
||||||
|
const maxDepth = 16
|
||||||
|
|
||||||
|
// validate checks names, quoting, and unsupported shell syntax.
|
||||||
|
func validate(name, expansion string, builtins map[string]bool) error {
|
||||||
|
if !namePattern.MatchString(name) {
|
||||||
|
return fmt.Errorf("invalid alias name %q", name)
|
||||||
|
}
|
||||||
|
if builtins[name] {
|
||||||
|
return fmt.Errorf("alias %q collides with a built-in command", name)
|
||||||
|
}
|
||||||
|
words, err := shellquote.Split(expansion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid alias expansion: %w", err)
|
||||||
|
}
|
||||||
|
if len(words) == 0 {
|
||||||
|
return fmt.Errorf("alias expansion cannot be empty")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(words[0], "!") {
|
||||||
|
return fmt.Errorf("shell aliases are not supported")
|
||||||
|
}
|
||||||
|
// Expansion never runs a shell; rejecting common operators only flags misleading shortcuts.
|
||||||
|
for _, word := range words {
|
||||||
|
switch word {
|
||||||
|
case "|", "||", "&", "&&", ";", "<", ">", ">>", "2>":
|
||||||
|
return fmt.Errorf("shell aliases are not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check validates an alias and every alias it expands through until a built-in.
|
||||||
|
func Check(name string, aliases map[string]string, builtins map[string]bool) error {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for current := name; ; {
|
||||||
|
expansion, ok := aliases[current]
|
||||||
|
// A built-in always wins over an alias, except for the invoked name itself, which
|
||||||
|
// validate reports as a collision.
|
||||||
|
if !ok || (current != name && builtins[current]) {
|
||||||
|
if !builtins[current] {
|
||||||
|
return fmt.Errorf("alias %q targets unknown command %q", name, current)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if seen[current] {
|
||||||
|
return fmt.Errorf("alias cycle involving %q", current)
|
||||||
|
}
|
||||||
|
if len(seen) >= maxDepth {
|
||||||
|
return fmt.Errorf("alias nesting exceeds %d", maxDepth)
|
||||||
|
}
|
||||||
|
if err := validate(current, expansion, builtins); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen[current] = true
|
||||||
|
// validate parsed this expansion without error.
|
||||||
|
words, _ := shellquote.Split(expansion)
|
||||||
|
current = words[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand substitutes arguments without re-parsing or executing user input.
|
||||||
|
// Only the invoked alias is checked, so one broken entry cannot block other commands.
|
||||||
|
func Expand(args []string, aliases map[string]string, builtins map[string]bool) ([]string, error) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
if _, ok := aliases[args[0]]; ok && !builtins[args[0]] {
|
||||||
|
if err := Check(args[0], aliases, builtins); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
expansion, exists := aliases[args[0]]
|
||||||
|
if !exists || builtins[args[0]] {
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
// Check validated every expansion reachable from the invoked alias.
|
||||||
|
words, _ := shellquote.Split(expansion)
|
||||||
|
supplied := args[1:]
|
||||||
|
consumed := map[int]bool{}
|
||||||
|
for i, word := range words {
|
||||||
|
var substitutionErr error
|
||||||
|
words[i] = placeholder.ReplaceAllStringFunc(word, func(match string) string {
|
||||||
|
n, err := strconv.Atoi(match[1:])
|
||||||
|
if err != nil || n > len(supplied) {
|
||||||
|
substitutionErr = fmt.Errorf("alias requires argument %s", match)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
consumed[n-1] = true
|
||||||
|
return supplied[n-1]
|
||||||
|
})
|
||||||
|
if substitutionErr != nil {
|
||||||
|
return nil, substitutionErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, arg := range supplied {
|
||||||
|
if !consumed[i] {
|
||||||
|
words = append(words, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args = words
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandCompletion keeps the in-progress word last, as Cobra's completion protocol requires.
|
||||||
|
// Fixed flags after a substituted argument must not become the completion prefix.
|
||||||
|
func ExpandCompletion(args []string, definitions map[string]string, builtins map[string]bool) ([]string, error) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
return append([]string{}, args...), nil
|
||||||
|
}
|
||||||
|
const marker = "\x00tea-completion\x00"
|
||||||
|
marked := append([]string{}, args...)
|
||||||
|
current := marked[len(marked)-1]
|
||||||
|
marked[len(marked)-1] = marker
|
||||||
|
expanded, err := Expand(marked, definitions, builtins)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
position := -1
|
||||||
|
for index, word := range expanded {
|
||||||
|
if strings.Contains(word, marker) {
|
||||||
|
if word != marker || position != -1 {
|
||||||
|
return nil, fmt.Errorf("completion of embedded or repeated placeholders is ambiguous")
|
||||||
|
}
|
||||||
|
position = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if position == -1 {
|
||||||
|
return nil, fmt.Errorf("alias completion lost its current argument")
|
||||||
|
}
|
||||||
|
result := append([]string{}, expanded[:position]...)
|
||||||
|
result = append(result, expanded[position+1:]...)
|
||||||
|
return append(result, current), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package aliases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExpand(t *testing.T) {
|
||||||
|
builtins := map[string]bool{"issue": true, "pr": true}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
expansion string
|
||||||
|
args, want []string
|
||||||
|
}{
|
||||||
|
{"issue list --label 'good first issue'", []string{"bugs", "--limit", "5"}, []string{"issue", "list", "--label", "good first issue", "--limit", "5"}},
|
||||||
|
{"issue view $2", []string{"bugs", "left", "123", "--json"}, []string{"issue", "view", "123", "left", "--json"}},
|
||||||
|
{"issue create --title $1", []string{"bugs", "$(never execute)"}, []string{"issue", "create", "--title", "$(never execute)"}},
|
||||||
|
{"issue list --search '!wip'", []string{"bugs"}, []string{"issue", "list", "--search", "!wip"}},
|
||||||
|
{"issue list", []string{}, []string{}},
|
||||||
|
} {
|
||||||
|
got, err := Expand(tc.args, map[string]string{"bugs": tc.expansion}, builtins)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc.want, got)
|
||||||
|
}
|
||||||
|
got, err := Expand([]string{"a", "42"}, map[string]string{"a": "b", "b": "pr view"}, builtins)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"pr", "view", "42"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectInvalidAliases(t *testing.T) {
|
||||||
|
builtins := map[string]bool{"issue": true}
|
||||||
|
for _, aliases := range []map[string]string{{"bad": ""}, {"bad": "issue '"}, {"bad": "a", "a": "bad"}, {"bad": "issue list | cat"}, {"bad": "issue list >> out"}, {"bad": "!echo hi"}, {"bad": "unknown list"}, {"bad": "$1 list"}} {
|
||||||
|
require.Error(t, Check("bad", aliases, builtins))
|
||||||
|
_, err := Expand([]string{"bad"}, aliases, builtins)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
require.Error(t, Check("issue", map[string]string{"issue": "issue list"}, builtins))
|
||||||
|
_, err := Expand([]string{"a"}, map[string]string{"a": "issue view $1"}, builtins)
|
||||||
|
require.Error(t, err)
|
||||||
|
// A broken or colliding entry only fails when invoked; built-ins always win.
|
||||||
|
broken := map[string]string{"issue": "pr list", "bad": "unknown", "ok": "issue list"}
|
||||||
|
got, err := Expand([]string{"ok"}, broken, builtins)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"issue", "list"}, got)
|
||||||
|
got, err = Expand([]string{"issue", "view"}, broken, builtins)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"issue", "view"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletionPreservesCurrentWordAfterFixedFlags(t *testing.T) {
|
||||||
|
definitions := map[string]string{"inspect": "pr view $1 --json number", "nested": "inspect $1"}
|
||||||
|
builtins := map[string]bool{"pr": true}
|
||||||
|
expanded, err := ExpandCompletion([]string{"nested", "7"}, definitions, builtins)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"pr", "view", "--json", "number", "7"}, expanded)
|
||||||
|
expanded, err = ExpandCompletion([]string{"inspect"}, definitions, builtins)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"inspect"}, expanded)
|
||||||
|
expanded, err = ExpandCompletion([]string{"nested", "7", "--j"}, definitions, builtins)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"pr", "view", "7", "--json", "number", "--j"}, expanded)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package clierr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Public exit codes for errors returned by commands.
|
||||||
|
const (
|
||||||
|
Failure = 1
|
||||||
|
Usage = 2
|
||||||
|
Canceled = 3
|
||||||
|
Authentication = 4
|
||||||
|
Interrupted = 130
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error associates an operation error with a public CLI exit code.
|
||||||
|
type Error struct {
|
||||||
|
Code int
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string { return e.Err.Error() }
|
||||||
|
func (e *Error) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
// Wrap preserves the underlying error while assigning an exit code.
|
||||||
|
func Wrap(code int, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Error{Code: code, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExitCode translates returned errors into the executable exit contract.
|
||||||
|
func ExitCode(err error) int {
|
||||||
|
if err == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
typed, ok := errors.AsType[*Error](err)
|
||||||
|
if ok && typed.Code == Interrupted {
|
||||||
|
return Interrupted
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return Canceled
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return typed.Code
|
||||||
|
}
|
||||||
|
return Failure
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package clierr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExitCodeDistinguishesCancellationAndInterruption(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
err error
|
||||||
|
code int
|
||||||
|
}{
|
||||||
|
{nil, 0},
|
||||||
|
{errors.New("operation failed"), Failure},
|
||||||
|
{Wrap(Usage, errors.New("missing input")), Usage},
|
||||||
|
{Wrap(Authentication, errors.New("login required")), Authentication},
|
||||||
|
{context.Canceled, Canceled},
|
||||||
|
{context.DeadlineExceeded, Canceled},
|
||||||
|
{Wrap(Authentication, context.Canceled), Canceled},
|
||||||
|
{fmt.Errorf("draft preserved: %w", context.DeadlineExceeded), Canceled},
|
||||||
|
{Wrap(Interrupted, context.Canceled), Interrupted},
|
||||||
|
{errors.Join(errors.New("partial failure"), context.Canceled), Canceled},
|
||||||
|
} {
|
||||||
|
assert.Equal(t, test.code, ExitCode(test.err), "%v", test.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package filelock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultTimeout is the default timeout for acquiring a file lock.
|
||||||
|
DefaultTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// FileLockPollInterval is how often to retry acquiring the file lock.
|
||||||
|
FileLockPollInterval = 50 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Locker provides file-based locking with timeout.
|
||||||
|
type Locker struct {
|
||||||
|
path string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Locker for the given lock file path.
|
||||||
|
func New(lockPath string, timeout time.Duration) *Locker {
|
||||||
|
return &Locker{
|
||||||
|
path: lockPath,
|
||||||
|
timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLock executes fn while holding the lock.
|
||||||
|
func (l *Locker) WithLock(fn func() error) (retErr error) {
|
||||||
|
unlock, err := l.Acquire()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if unlockErr := unlock(); unlockErr != nil && retErr == nil {
|
||||||
|
retErr = fmt.Errorf("failed to release file lock: %w", unlockErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire acquires the file lock and returns an unlock function.
|
||||||
|
// The caller must call the unlock function to release the lock.
|
||||||
|
func (l *Locker) Acquire() (unlock func() error, err error) {
|
||||||
|
file, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open lock file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lockFile(file, l.timeout); err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, fmt.Errorf("failed to acquire file lock: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() error {
|
||||||
|
unlockErr := unlockFile(file)
|
||||||
|
closeErr := file.Close()
|
||||||
|
if unlockErr != nil {
|
||||||
|
return unlockErr
|
||||||
|
}
|
||||||
|
return closeErr
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package filelock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocker_WithLock(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
lockPath := filepath.Join(tmpDir, "test.lock")
|
||||||
|
|
||||||
|
locker := New(lockPath, DefaultTimeout)
|
||||||
|
|
||||||
|
counter := 0
|
||||||
|
err := locker.WithLock(func() error {
|
||||||
|
counter++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithLock failed: %v", err)
|
||||||
|
}
|
||||||
|
if counter != 1 {
|
||||||
|
t.Errorf("Expected counter to be 1, got %d", counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock file should have been created
|
||||||
|
if _, err := os.Stat(lockPath); os.IsNotExist(err) {
|
||||||
|
t.Error("Lock file should have been created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocker_Acquire(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
lockPath := filepath.Join(tmpDir, "test.lock")
|
||||||
|
|
||||||
|
locker := New(lockPath, DefaultTimeout)
|
||||||
|
|
||||||
|
unlock, err := locker.Acquire()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Acquire failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock should be held
|
||||||
|
if unlock == nil {
|
||||||
|
t.Fatal("unlock function should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release the lock
|
||||||
|
if err := unlock(); err != nil {
|
||||||
|
t.Fatalf("unlock failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocker_ConcurrentAccess(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
lockPath := filepath.Join(tmpDir, "test.lock")
|
||||||
|
|
||||||
|
locker := New(lockPath, 5*time.Second)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var counter, active atomic.Int32
|
||||||
|
start := make(chan struct{})
|
||||||
|
numGoroutines := 10
|
||||||
|
|
||||||
|
for range numGoroutines {
|
||||||
|
wg.Go(func() {
|
||||||
|
<-start
|
||||||
|
err := locker.WithLock(func() error {
|
||||||
|
// OS file locks are not synchronization events for the race detector.
|
||||||
|
if active.Add(1) != 1 {
|
||||||
|
t.Error("lock allowed overlapping critical sections")
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
counter.Add(1)
|
||||||
|
active.Add(-1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("WithLock failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if counter.Load() != int32(numGoroutines) {
|
||||||
|
t.Errorf("Expected counter to be %d, got %d", numGoroutines, counter.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocker_Timeout(t *testing.T) {
|
||||||
|
lockPath := filepath.Join(t.TempDir(), "test.lock")
|
||||||
|
unlock, err := New(lockPath, DefaultTimeout).Acquire()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Acquire failed: %v", err)
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err = New(lockPath, 100*time.Millisecond).WithLock(func() error {
|
||||||
|
t.Error("lock acquired while another locker held it")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "timeout waiting for file lock") {
|
||||||
|
t.Fatalf("expected a lock timeout, got %v", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed < 100*time.Millisecond || elapsed > 2*time.Second {
|
||||||
|
t.Errorf("timeout took %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package filelock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lockFile acquires an exclusive lock on the file using flock.
|
||||||
|
// It polls with non-blocking flock until timeout.
|
||||||
|
func lockFile(file *os.File, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
|
||||||
|
for {
|
||||||
|
err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != syscall.EWOULDBLOCK {
|
||||||
|
return fmt.Errorf("flock failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("timeout waiting for file lock")
|
||||||
|
}
|
||||||
|
time.Sleep(FileLockPollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlockFile releases the lock on the file.
|
||||||
|
func unlockFile(file *os.File) error {
|
||||||
|
return syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package filelock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lockFile acquires an exclusive lock on the file using LockFileEx.
|
||||||
|
// It polls with non-blocking LockFileEx until timeout.
|
||||||
|
func lockFile(file *os.File, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
handle := windows.Handle(file.Fd())
|
||||||
|
|
||||||
|
// LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY
|
||||||
|
const flags = windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Lock the first byte (advisory lock)
|
||||||
|
var overlapped windows.Overlapped
|
||||||
|
err := windows.LockFileEx(handle, flags, 0, 1, 0, &overlapped)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != windows.ERROR_LOCK_VIOLATION {
|
||||||
|
return fmt.Errorf("LockFileEx failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("timeout waiting for file lock")
|
||||||
|
}
|
||||||
|
time.Sleep(FileLockPollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlockFile releases the lock on the file.
|
||||||
|
func unlockFile(file *os.File) error {
|
||||||
|
handle := windows.Handle(file.Fd())
|
||||||
|
var overlapped windows.Overlapped
|
||||||
|
return windows.UnlockFileEx(handle, 0, 1, 0, &overlapped)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoteIsSSH reports whether a Git remote uses SSH: an ssh:// URL or an scp-style user@host:path.
|
||||||
|
func RemoteIsSSH(remote string) bool {
|
||||||
|
return strings.HasPrefix(remote, "ssh://") || scpLike(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scpLike(remote string) bool {
|
||||||
|
return !strings.Contains(remote, "://") && strings.Contains(remote, "@")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRemote parses a Git remote URL, reading scp-style user@host:path as ssh://user@host/path
|
||||||
|
// without interpreting shell syntax.
|
||||||
|
func ParseRemote(raw string) (*url.URL, error) {
|
||||||
|
if user, path, ok := strings.Cut(raw, ":"); ok && scpLike(raw) {
|
||||||
|
raw = "ssh://" + user + "/" + path
|
||||||
|
}
|
||||||
|
return url.Parse(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepositoryURL resolves a repository URL or Git remote to a configured instance and its owner/name.
|
||||||
|
// Unconfigured HTTP(S) URLs take the final two path segments as the repository.
|
||||||
|
func RepositoryURL(raw string, hosts map[string]Host) (string, string, error) {
|
||||||
|
u, err := ParseRemote(raw)
|
||||||
|
if err != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http" && u.Scheme != "ssh") {
|
||||||
|
return "", "", errors.New("invalid repository URL")
|
||||||
|
}
|
||||||
|
password := false
|
||||||
|
if u.User != nil {
|
||||||
|
_, password = u.User.Password()
|
||||||
|
}
|
||||||
|
if u.User != nil && (u.Scheme != "ssh" || password) {
|
||||||
|
return "", "", errors.New("repository URLs must not embed credentials")
|
||||||
|
}
|
||||||
|
slug, cloneURL := strings.CutSuffix(strings.Trim(u.Path, "/"), ".git")
|
||||||
|
bestHost, bestRepo, bestLen := "", "", -1
|
||||||
|
matchedHost := false
|
||||||
|
remoteSSH, remoteErr := SSHEndpoint(u.Host)
|
||||||
|
for instance, host := range hosts {
|
||||||
|
base, _ := url.Parse(instance)
|
||||||
|
sshHost := host.SSHHost
|
||||||
|
if sshHost == "" {
|
||||||
|
sshHost = base.Hostname()
|
||||||
|
if strings.Contains(sshHost, ":") {
|
||||||
|
sshHost = "[" + sshHost + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix := strings.Trim(base.Path, "/")
|
||||||
|
configuredSSH, configuredErr := SSHEndpoint(sshHost)
|
||||||
|
match := (u.Scheme == "ssh" && remoteErr == nil && configuredErr == nil && remoteSSH == configuredSSH) || (u.Scheme == base.Scheme && strings.EqualFold(u.Host, base.Host))
|
||||||
|
if !match {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchedHost = true
|
||||||
|
candidate := slug
|
||||||
|
if u.Scheme != "ssh" && prefix != "" {
|
||||||
|
if !strings.HasPrefix(slug, prefix+"/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate = strings.TrimPrefix(slug, prefix+"/")
|
||||||
|
}
|
||||||
|
if !ValidSlug(candidate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bestHost != "" && u.Scheme == "ssh" && bestHost != instance {
|
||||||
|
return "", "", errors.New("SSH remote matches multiple instances; select --host")
|
||||||
|
}
|
||||||
|
if len(prefix) == bestLen && bestHost != instance {
|
||||||
|
return "", "", errors.New("repository URL matches multiple instances; select --host")
|
||||||
|
}
|
||||||
|
if len(prefix) > bestLen {
|
||||||
|
bestHost, bestRepo, bestLen = instance, candidate, len(prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bestHost != "" {
|
||||||
|
return bestHost, bestRepo, nil
|
||||||
|
}
|
||||||
|
if u.Scheme == "ssh" {
|
||||||
|
return "", "", errors.New("SSH remote does not match a configured instance")
|
||||||
|
}
|
||||||
|
if matchedHost {
|
||||||
|
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
|
||||||
|
}
|
||||||
|
parts := strings.Split(slug, "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", "", errors.New("repository URL requires owner/repository")
|
||||||
|
}
|
||||||
|
repo := strings.Join(parts[len(parts)-2:], "/")
|
||||||
|
// A .git suffix marks a clone URL, whose repository may share its name with a web route.
|
||||||
|
if !ValidSlug(repo) || (!cloneURL && looksLikeWebResourcePath(parts)) {
|
||||||
|
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
|
||||||
|
}
|
||||||
|
u.Path = "/" + strings.Join(parts[:len(parts)-2], "/")
|
||||||
|
u.RawPath, u.RawQuery, u.Fragment, u.User = "", "", "", nil
|
||||||
|
instance, err := NormalizeHost(u.String())
|
||||||
|
return instance, repo, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeWebResourcePath detects Gitea HTML routes such as owner/repo/issues/7, which have a
|
||||||
|
// web route keyword in a segment after the possible owner/repo. Taking the final two segments
|
||||||
|
// from those paths would invent the wrong repository.
|
||||||
|
func looksLikeWebResourcePath(parts []string) bool {
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, part := range parts[2:] {
|
||||||
|
switch strings.ToLower(part) {
|
||||||
|
case "issues", "pulls", "pull", "commit", "commits", "src", "tree", "blob", "raw",
|
||||||
|
"compare", "releases", "tags", "wiki", "actions", "settings", "projects",
|
||||||
|
"packages", "labels", "milestones", "activities", "forks", "watchers", "stars":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidSlug reports whether slug is exactly owner/name.
|
||||||
|
func ValidSlug(slug string) bool {
|
||||||
|
pieces := strings.Split(slug, "/")
|
||||||
|
if len(pieces) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, piece := range pieces {
|
||||||
|
// ':' keeps scp-style remotes such as git@host:owner/name.git from passing as a slug.
|
||||||
|
if piece == "" || piece == "." || piece == ".." || strings.ContainsAny(piece, ":?#\\ \t\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepositoryURL(t *testing.T) {
|
||||||
|
ports := map[string]Host{"https://example.org:3000/base": {}, "https://example.org:3000": {}}
|
||||||
|
bases := map[string]Host{"https://example.org": {}, "https://example.org/base": {}}
|
||||||
|
sshPorts := map[string]Host{
|
||||||
|
"https://example.org/first": {SSHHost: "git.example.org:2222"},
|
||||||
|
"https://example.org/second": {SSHHost: "git.example.org:2223"},
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
raw string
|
||||||
|
hosts map[string]Host
|
||||||
|
wantHost, wantRepo string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{raw: "https://example.org:3000/base/alice/project.git", hosts: ports, wantHost: "https://example.org:3000/base", wantRepo: "alice/project"},
|
||||||
|
{raw: "https://unconfigured.example.org/group/alice/project.git", wantHost: "https://unconfigured.example.org/group", wantRepo: "alice/project"},
|
||||||
|
{raw: "ssh://git@git.example.org:2223/alice/project.git", hosts: sshPorts, wantHost: "https://example.org/second", wantRepo: "alice/project"},
|
||||||
|
{raw: "git@git.example.org:alice/project.git", hosts: sshPorts, wantErr: "does not match"},
|
||||||
|
{raw: "git@example.org:owner/repo.git", hosts: bases, wantErr: "multiple instances"},
|
||||||
|
{raw: "https://example.org/alice/project/issues/7", hosts: bases, wantErr: "ambiguous"},
|
||||||
|
{raw: "https://example.org/alice/project/pulls/3", hosts: bases, wantErr: "ambiguous"},
|
||||||
|
{raw: "https://example.org/base/alice/project/src/branch/main", hosts: bases, wantErr: "ambiguous"},
|
||||||
|
{raw: "https://example.org/alice/project/actions/runs/9", hosts: bases, wantErr: "ambiguous"},
|
||||||
|
{raw: "https://unconfigured.example.org/alice/project/issues/7", wantErr: "ambiguous"},
|
||||||
|
{raw: "https://unconfigured.example.org/alice/project/issues", wantErr: "ambiguous"},
|
||||||
|
{raw: "https://unconfigured.example.org/alice/project/pulls", wantErr: "ambiguous"},
|
||||||
|
{raw: "https://unconfigured.example.org/alice/project/src/branch/main", wantErr: "ambiguous"},
|
||||||
|
{raw: "https://unconfigured.example.org/group/alice/issues.git", wantHost: "https://unconfigured.example.org/group", wantRepo: "alice/issues"},
|
||||||
|
{raw: "ssh://git:private-test-password@example.org/alice/project.git", wantErr: "must not embed credentials"},
|
||||||
|
} {
|
||||||
|
host, repo, err := RepositoryURL(test.raw, test.hosts)
|
||||||
|
if test.wantErr != "" {
|
||||||
|
require.ErrorContains(t, err, test.wantErr, test.raw)
|
||||||
|
require.NotContains(t, err.Error(), "private-test-password")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
require.NoError(t, err, test.raw)
|
||||||
|
require.Equal(t, test.wantHost, host, test.raw)
|
||||||
|
require.Equal(t, test.wantRepo, repo, test.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteIsSSH(t *testing.T) {
|
||||||
|
for remote, want := range map[string]bool{
|
||||||
|
"ssh://git@example.org/alice/project.git": true,
|
||||||
|
"git@example.org:alice/project.git": true,
|
||||||
|
"https://example.org/alice/project.git": false,
|
||||||
|
"http://example.org/alice/project.git": false,
|
||||||
|
"file:///tmp/repo.git": false,
|
||||||
|
} {
|
||||||
|
require.Equal(t, want, RemoteIsSSH(remote), remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRemoteReadsSCPStyleRemotes(t *testing.T) {
|
||||||
|
u, err := ParseRemote("git@git.example.org:alice/project.git")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "ssh://git@git.example.org/alice/project.git", u.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidSlugRejectsSCPStyleRemotes(t *testing.T) {
|
||||||
|
require.True(t, ValidSlug("alice/project"))
|
||||||
|
require.False(t, ValidSlug("git@example.org:alice/project.git"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHEndpoint normalizes a host or host:port, including bracketed IPv6 addresses.
|
||||||
|
func SSHEndpoint(authority string) (string, error) {
|
||||||
|
parsed, err := url.Parse("ssh://" + authority)
|
||||||
|
if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.ContainsAny(authority, "\\ \t\r\n") {
|
||||||
|
return "", errors.New("ssh_host must be a hostname or host:port without user, path, or credentials")
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(authority, ":") || (strings.Contains(parsed.Hostname(), ":") && net.ParseIP(parsed.Hostname()) == nil) {
|
||||||
|
return "", errors.New("ssh_host has an invalid host or port")
|
||||||
|
}
|
||||||
|
port := parsed.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = "22"
|
||||||
|
}
|
||||||
|
number, err := strconv.Atoi(port)
|
||||||
|
if err != nil || number < 1 || number > 65535 {
|
||||||
|
return "", errors.New("SSH port must be between 1 and 65535")
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(number)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSHEndpointNormalization(t *testing.T) {
|
||||||
|
for input, want := range map[string]string{"Git.Example.org": "git.example.org:22", "git.example.org:2222": "git.example.org:2222", "[::1]": "[::1]:22", "[::1]:2222": "[::1]:2222"} {
|
||||||
|
got, err := SSHEndpoint(input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
for _, input := range []string{"", "user@host", "host/path", "host?secret=x", "host#secret", "host:0", "host:65536", "host:bad", "host\\path", "host:22:", "::1", "host:"} {
|
||||||
|
_, err := SSHEndpoint(input)
|
||||||
|
require.Error(t, err, input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidSSHSettingsCannotBePublished(t *testing.T) {
|
||||||
|
store := New(t.TempDir())
|
||||||
|
err := store.Update(TargetHosts, func(s *Snapshot) error {
|
||||||
|
s.Hosts.Hosts["https://example.org"] = Host{SSHHost: "user@host:2222"}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "invalid ssh_host")
|
||||||
|
snapshot, err := store.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, snapshot.Hosts.Hosts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package settings persists preferences and instance metadata, never credentials.
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/tea/internal/filelock"
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version identifies the split configuration schema.
|
||||||
|
const Version = "1"
|
||||||
|
|
||||||
|
// LockTimeout bounds the wait for another process's settings update or token refresh.
|
||||||
|
const LockTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// ErrLegacy prevents overwriting the old combined login configuration.
|
||||||
|
var ErrLegacy = errors.New("legacy tea configuration: archive the old config.yml or select a separate TEA_CONFIG_DIR, then run tea auth login; existing files have not been changed")
|
||||||
|
|
||||||
|
// Authentication methods and credential stores recorded for accounts.
|
||||||
|
const (
|
||||||
|
AuthToken = "token"
|
||||||
|
AuthOAuth = "oauth"
|
||||||
|
AuthSSH = "ssh"
|
||||||
|
AuthHTTPSign = "httpsign"
|
||||||
|
|
||||||
|
StoreKeyring = "keyring"
|
||||||
|
StoreFile = "file"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preferences contains global, non-secret command defaults.
|
||||||
|
type Preferences struct {
|
||||||
|
Version string `yaml:"version" json:"version"`
|
||||||
|
DefaultHost string `yaml:"default_host,omitempty" json:"default_host"`
|
||||||
|
GitProtocol string `yaml:"git_protocol,omitempty" json:"git_protocol"`
|
||||||
|
Editor string `yaml:"editor,omitempty" json:"editor"`
|
||||||
|
Prompt string `yaml:"prompt,omitempty" json:"prompt"`
|
||||||
|
Pager string `yaml:"pager,omitempty" json:"pager"`
|
||||||
|
Browser string `yaml:"browser,omitempty" json:"browser"`
|
||||||
|
Color string `yaml:"color,omitempty" json:"color"`
|
||||||
|
Remote string `yaml:"remote,omitempty" json:"remote"`
|
||||||
|
Aliases map[string]string `yaml:"aliases,omitempty" json:"aliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account contains account-specific authentication metadata, never token values.
|
||||||
|
type Account struct {
|
||||||
|
AuthMethod string `yaml:"auth_method" json:"auth_method"`
|
||||||
|
CredentialStore string `yaml:"credential_store,omitempty" json:"credential_store,omitempty"`
|
||||||
|
SSHKey string `yaml:"ssh_key,omitempty" json:"ssh_key,omitempty"`
|
||||||
|
SSHAgent bool `yaml:"ssh_agent,omitempty" json:"ssh_agent,omitempty"`
|
||||||
|
SSHCertPrincipal string `yaml:"ssh_certificate_principal,omitempty" json:"ssh_certificate_principal,omitempty"`
|
||||||
|
SSHKeyFingerprint string `yaml:"ssh_key_fingerprint,omitempty" json:"ssh_key_fingerprint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokenless reports whether the account signs requests instead of storing a token.
|
||||||
|
func (a Account) Tokenless() bool {
|
||||||
|
return a.AuthMethod == AuthSSH || a.AuthMethod == AuthHTTPSign
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host contains shared connection settings and the active account.
|
||||||
|
type Host struct {
|
||||||
|
User string `yaml:"user,omitempty" json:"user"`
|
||||||
|
SSHHost string `yaml:"ssh_host,omitempty" json:"ssh_host,omitempty"`
|
||||||
|
Insecure bool `yaml:"insecure,omitempty" json:"insecure"`
|
||||||
|
GitProtocol string `yaml:"git_protocol,omitempty" json:"git_protocol,omitempty"`
|
||||||
|
OAuthClientID string `yaml:"oauth_client_id,omitempty" json:"oauth_client_id,omitempty"`
|
||||||
|
Users map[string]Account `yaml:"users" json:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts stores instances keyed by their normalized installation URLs.
|
||||||
|
type Hosts struct {
|
||||||
|
Version string `yaml:"version" json:"version"`
|
||||||
|
Hosts map[string]Host `yaml:"hosts" json:"hosts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is one independently loaded configuration generation.
|
||||||
|
type Snapshot struct {
|
||||||
|
Preferences Preferences
|
||||||
|
Hosts Hosts
|
||||||
|
// Configured distinguishes explicitly stored settings from built-in defaults.
|
||||||
|
Configured map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store coordinates configuration reads and locked atomic updates.
|
||||||
|
type Store struct {
|
||||||
|
Dir string
|
||||||
|
LegacyPath string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// New selects an explicit directory or the platform configuration directory.
|
||||||
|
func New(dir string) *Store {
|
||||||
|
legacy := ""
|
||||||
|
if dir == "" {
|
||||||
|
dir = filepath.Join(xdg.ConfigHome, "tea")
|
||||||
|
legacy = filepath.Join(xdg.Home, ".tea", "tea.yml")
|
||||||
|
}
|
||||||
|
return &Store{Dir: dir, LegacyPath: legacy}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults returns a fresh preferences value with built-in settings.
|
||||||
|
func Defaults() Preferences {
|
||||||
|
return Preferences{Version: Version, GitProtocol: "https", Prompt: "enabled", Color: "auto", Aliases: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stored omits built-in defaults so that only explicit settings are persisted.
|
||||||
|
func (p Preferences) stored() Preferences {
|
||||||
|
defaults := Defaults()
|
||||||
|
if p.GitProtocol == defaults.GitProtocol {
|
||||||
|
p.GitProtocol = ""
|
||||||
|
}
|
||||||
|
if p.Prompt == defaults.Prompt {
|
||||||
|
p.Prompt = ""
|
||||||
|
}
|
||||||
|
if p.Color == defaults.Color {
|
||||||
|
p.Color = ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeHost retains ports and installation paths as part of instance identity.
|
||||||
|
func NormalizeHost(raw string) (string, error) {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Hostname() == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" {
|
||||||
|
return "", fmt.Errorf("invalid instance URL %q: use http(s)://host[:port][/base-path]", raw)
|
||||||
|
}
|
||||||
|
u.Scheme = strings.ToLower(u.Scheme)
|
||||||
|
u.Host = strings.ToLower(u.Host)
|
||||||
|
if (u.Scheme == "https" && u.Port() == "443") || (u.Scheme == "http" && u.Port() == "80") {
|
||||||
|
u.Host = u.Hostname()
|
||||||
|
if strings.Contains(u.Host, ":") {
|
||||||
|
u.Host = "[" + u.Host + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Encoded separators cannot identify an installation unambiguously.
|
||||||
|
if strings.Contains(strings.ToLower(u.EscapedPath()), "%2f") || strings.Contains(strings.ToLower(u.EscapedPath()), "%5c") || strings.Contains(u.Path, "\\") {
|
||||||
|
return "", fmt.Errorf("invalid instance path in %q", raw)
|
||||||
|
}
|
||||||
|
u.Path = strings.TrimSuffix(path.Clean("/"+u.Path), "/")
|
||||||
|
u.RawPath = ""
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(file string, data []byte, target any) error {
|
||||||
|
d := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
d.KnownFields(true)
|
||||||
|
if err := d.Decode(target); err != nil {
|
||||||
|
return fmt.Errorf("parse %s: %w", file, err)
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := d.Decode(&extra); err != io.EOF {
|
||||||
|
return fmt.Errorf("%s must contain exactly one YAML document", file)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads both configuration files without creating paths or mutating globals.
|
||||||
|
func (s *Store) Load() (Snapshot, error) {
|
||||||
|
snapshot := Snapshot{Preferences: Defaults(), Hosts: Hosts{Version: Version, Hosts: map[string]Host{}}, Configured: map[string]bool{}}
|
||||||
|
configPath := filepath.Join(s.Dir, "config.yml")
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return snapshot, err
|
||||||
|
}
|
||||||
|
if errors.Is(err, os.ErrNotExist) && s.LegacyPath != "" {
|
||||||
|
if _, legacyErr := os.Stat(s.LegacyPath); legacyErr == nil {
|
||||||
|
return snapshot, ErrLegacy
|
||||||
|
} else if !errors.Is(legacyErr, os.ErrNotExist) {
|
||||||
|
return snapshot, legacyErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
var keys map[string]yaml.Node
|
||||||
|
if err := yaml.Unmarshal(data, &keys); err != nil {
|
||||||
|
return snapshot, fmt.Errorf("parse %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
if _, ok := keys["logins"]; ok {
|
||||||
|
return snapshot, ErrLegacy
|
||||||
|
}
|
||||||
|
if _, ok := keys["preferences"]; ok {
|
||||||
|
return snapshot, ErrLegacy
|
||||||
|
}
|
||||||
|
version, ok := keys["version"]
|
||||||
|
if !ok || version.Value != Version {
|
||||||
|
return snapshot, fmt.Errorf("unsupported configuration version in %s", configPath)
|
||||||
|
}
|
||||||
|
for key := range keys {
|
||||||
|
snapshot.Configured[key] = true
|
||||||
|
}
|
||||||
|
if err := decode(configPath, data, &snapshot.Preferences); err != nil {
|
||||||
|
return snapshot, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hostsPath := filepath.Join(s.Dir, "hosts.yml")
|
||||||
|
hostData, err := os.ReadFile(hostsPath)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return snapshot, fmt.Errorf("read %s: %w", hostsPath, err)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if err := decode(hostsPath, hostData, &snapshot.Hosts); err != nil {
|
||||||
|
return snapshot, err
|
||||||
|
}
|
||||||
|
var keys map[string]yaml.Node
|
||||||
|
if err := yaml.Unmarshal(hostData, &keys); err != nil {
|
||||||
|
return snapshot, err
|
||||||
|
}
|
||||||
|
version, ok := keys["version"]
|
||||||
|
if !ok || version.Value != Version {
|
||||||
|
return snapshot, errors.New("unsupported hosts.yml version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshot.Hosts.Hosts == nil {
|
||||||
|
snapshot.Hosts.Hosts = map[string]Host{}
|
||||||
|
}
|
||||||
|
if snapshot.Preferences.Aliases == nil {
|
||||||
|
snapshot.Preferences.Aliases = map[string]string{}
|
||||||
|
}
|
||||||
|
return snapshot, snapshot.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rejects unsupported versions and inconsistent instance/account settings.
|
||||||
|
func (s Snapshot) Validate() error {
|
||||||
|
p := s.Preferences
|
||||||
|
if p.Version != Version || s.Hosts.Version != Version {
|
||||||
|
return errors.New("unsupported settings version")
|
||||||
|
}
|
||||||
|
if p.GitProtocol != "https" && p.GitProtocol != "ssh" {
|
||||||
|
return errors.New("git_protocol must be https or ssh")
|
||||||
|
}
|
||||||
|
if p.Prompt != "enabled" && p.Prompt != "disabled" {
|
||||||
|
return errors.New("prompt must be enabled or disabled")
|
||||||
|
}
|
||||||
|
if p.Color != "auto" && p.Color != "always" && p.Color != "never" {
|
||||||
|
return errors.New("color must be auto, always, or never")
|
||||||
|
}
|
||||||
|
if p.DefaultHost != "" {
|
||||||
|
normalized, err := NormalizeHost(p.DefaultHost)
|
||||||
|
if err != nil || normalized != p.DefaultHost {
|
||||||
|
return errors.New("default_host must be a normalized instance URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for instance, host := range s.Hosts.Hosts {
|
||||||
|
normalized, err := NormalizeHost(instance)
|
||||||
|
if err != nil || normalized != instance {
|
||||||
|
return fmt.Errorf("instance %q must be a normalized URL", instance)
|
||||||
|
}
|
||||||
|
if host.SSHHost != "" {
|
||||||
|
if _, err := SSHEndpoint(host.SSHHost); err != nil {
|
||||||
|
return fmt.Errorf("invalid ssh_host for %s: %w", instance, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if host.GitProtocol != "" && host.GitProtocol != "https" && host.GitProtocol != "ssh" {
|
||||||
|
return fmt.Errorf("invalid git_protocol for %s", instance)
|
||||||
|
}
|
||||||
|
if host.User != "" {
|
||||||
|
if _, exists := host.Users[host.User]; !exists {
|
||||||
|
return fmt.Errorf("active user %q is missing on %s", host.User, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, account := range host.Users {
|
||||||
|
if strings.ContainsAny(account.SSHKey+account.SSHCertPrincipal+account.SSHKeyFingerprint, "\x00\r\n") {
|
||||||
|
return errors.New("account SSH settings cannot contain NUL or line breaks")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return errors.New("account name cannot be empty")
|
||||||
|
}
|
||||||
|
if account.AuthMethod != AuthToken && account.AuthMethod != AuthOAuth && !account.Tokenless() {
|
||||||
|
return fmt.Errorf("invalid authentication method for %s on %s", name, instance)
|
||||||
|
}
|
||||||
|
if account.Tokenless() {
|
||||||
|
if account.CredentialStore != "" {
|
||||||
|
return fmt.Errorf("SSH and HTTP signature accounts cannot use a credential store for %s on %s", name, instance)
|
||||||
|
}
|
||||||
|
} else if account.CredentialStore != StoreKeyring && account.CredentialStore != StoreFile {
|
||||||
|
return fmt.Errorf("invalid credential store for %s on %s", name, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target selects which settings file an update publishes.
|
||||||
|
type Target int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TargetPreferences publishes config.yml, the global non-secret defaults.
|
||||||
|
TargetPreferences Target = iota
|
||||||
|
// TargetHosts publishes hosts.yml, the instance and account metadata.
|
||||||
|
TargetHosts
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update serializes writers, reloads current settings, and changes exactly the target file.
|
||||||
|
// Cross-file transactions are not exposed.
|
||||||
|
func (s *Store) Update(target Target, update func(*Snapshot) error) error {
|
||||||
|
return s.Transaction(target, func(snapshot *Snapshot) (func() error, error) {
|
||||||
|
return nil, update(snapshot)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction runs a compensating action under the lock if publishing the target file fails.
|
||||||
|
// It is used to keep credential storage and account metadata consistent.
|
||||||
|
func (s *Store) Transaction(target Target, update func(*Snapshot) (func() error, error)) error {
|
||||||
|
return s.Locked(func(snapshot Snapshot) error {
|
||||||
|
unpublished := func() ([]byte, error) {
|
||||||
|
if target == TargetHosts {
|
||||||
|
return yaml.Marshal(snapshot.Preferences)
|
||||||
|
}
|
||||||
|
return yaml.Marshal(snapshot.Hosts)
|
||||||
|
}
|
||||||
|
before, err := unpublished()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rollback, err := update(&snapshot)
|
||||||
|
undo := func(cause error) error {
|
||||||
|
if cause != nil && rollback != nil {
|
||||||
|
return errors.Join(cause, rollback())
|
||||||
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return undo(err)
|
||||||
|
}
|
||||||
|
if after, err := unpublished(); err != nil || !bytes.Equal(before, after) {
|
||||||
|
return undo(errors.Join(errors.New("settings update changed a file it does not write"), err))
|
||||||
|
}
|
||||||
|
if err := snapshot.Validate(); err != nil {
|
||||||
|
return undo(err)
|
||||||
|
}
|
||||||
|
filename := "config.yml"
|
||||||
|
var value any = snapshot.Preferences.stored()
|
||||||
|
if target == TargetHosts {
|
||||||
|
filename = "hosts.yml"
|
||||||
|
value = snapshot.Hosts
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return undo(err)
|
||||||
|
}
|
||||||
|
return undo(AtomicWrite(filepath.Join(s.Dir, filename), data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locked coordinates account changes and token refresh without rewriting settings.
|
||||||
|
func (s *Store) Locked(fn func(Snapshot) error) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, err := s.Load(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return filelock.New(filepath.Join(s.Dir, "settings.lock"), LockTimeout).WithLock(func() error {
|
||||||
|
snapshot, err := s.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fn(snapshot)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AtomicWrite publishes a complete private file after flushing its contents.
|
||||||
|
func AtomicWrite(filename string, data []byte) error {
|
||||||
|
temp, err := os.CreateTemp(filepath.Dir(filename), ".tea-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = temp.Close(); _ = os.Remove(temp.Name()) }()
|
||||||
|
if err := temp.Chmod(0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := temp.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := temp.Sync(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := temp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(temp.Name(), filename)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadDoesNotCreateFiles(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "missing")
|
||||||
|
s := New(dir)
|
||||||
|
snapshot, err := s.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "enabled", snapshot.Preferences.Prompt)
|
||||||
|
_, err = os.Stat(dir)
|
||||||
|
require.True(t, errors.Is(err, os.ErrNotExist))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyAndMalformedFilesAreNeverOverwritten(t *testing.T) {
|
||||||
|
for _, input := range []string{"logins: []\npreferences: {}\n", "version: '2'\n", "version: '1'\nunknown: true\n", "version: '1'\n---\nversion: '1'\n", "version: '1'\nprompt: nonsense\n", "bad: ["} {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
s := New(t.TempDir())
|
||||||
|
file := filepath.Join(s.Dir, "config.yml")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte(input), 0o600))
|
||||||
|
_, err := s.Load()
|
||||||
|
require.Error(t, err)
|
||||||
|
err = s.Update(TargetPreferences, func(snapshot *Snapshot) error { snapshot.Preferences.Editor = "vi"; return nil })
|
||||||
|
require.Error(t, err)
|
||||||
|
actual, err := os.ReadFile(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, input, string(actual))
|
||||||
|
_, err = os.Stat(filepath.Join(s.Dir, "settings.lock"))
|
||||||
|
require.True(t, errors.Is(err, os.ErrNotExist))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("legacy tea.yml", func(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
legacy := filepath.Join(home, ".tea", "tea.yml")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(legacy), 0o700))
|
||||||
|
require.NoError(t, os.WriteFile(legacy, []byte("logins: []\n"), 0o600))
|
||||||
|
s := &Store{Dir: filepath.Join(home, "config", "tea"), LegacyPath: legacy}
|
||||||
|
_, err := s.Load()
|
||||||
|
require.ErrorIs(t, err, ErrLegacy)
|
||||||
|
require.ErrorIs(t, s.Update(TargetPreferences, func(snapshot *Snapshot) error { snapshot.Preferences.Editor = "vi"; return nil }), ErrLegacy)
|
||||||
|
_, err = os.Stat(s.Dir)
|
||||||
|
require.True(t, errors.Is(err, os.ErrNotExist))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeHost(t *testing.T) {
|
||||||
|
for raw, want := range map[string]string{"https://EXAMPLE.org:443/": "https://example.org", "https://example.org:3000/gitea/": "https://example.org:3000/gitea", "http://example.org:80": "http://example.org", "https://[::1]:443/": "https://[::1]"} {
|
||||||
|
actual, err := NormalizeHost(raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, want, actual)
|
||||||
|
}
|
||||||
|
for _, raw := range []string{"example.org", "ftp://example.org", "https://user:password@example.org", "https://example.org?q=1", "https://example.org/#fragment", "https://example.org/a%2fb"} {
|
||||||
|
_, err := NormalizeHost(raw)
|
||||||
|
require.Error(t, err, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentUpdatesAndHostSeparation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
failures := make(chan error, 8)
|
||||||
|
for i := range 8 {
|
||||||
|
wg.Go(func() {
|
||||||
|
store := New(dir)
|
||||||
|
err := store.Update(TargetPreferences, func(snapshot *Snapshot) error {
|
||||||
|
snapshot.Preferences.Aliases[string(rune('a'+i))] = "issue list"
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
failures <- err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(failures)
|
||||||
|
for err := range failures {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
store := New(dir)
|
||||||
|
snapshot, err := store.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Preferences.Aliases, 8)
|
||||||
|
require.NoError(t, store.Update(TargetHosts, func(s *Snapshot) error {
|
||||||
|
s.Hosts.Hosts["https://example.org/base"] = Host{User: "alice", Users: map[string]Account{"alice": {AuthMethod: "token", CredentialStore: "file"}, "bob": {AuthMethod: "oauth", CredentialStore: "keyring"}}}
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
snapshot, err = store.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshot.Hosts.Hosts, 1)
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "config.yml"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(data), "alice")
|
||||||
|
info, err := os.Stat(filepath.Join(dir, "hosts.yml"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if runtime.GOOS != "windows" { // Windows reports 0666 regardless of the requested mode
|
||||||
|
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
||||||
|
}
|
||||||
|
// A change to the file an update does not write fails instead of being dropped.
|
||||||
|
require.Error(t, store.Update(TargetHosts, func(s *Snapshot) error { s.Preferences.Editor = "vi"; return nil }))
|
||||||
|
require.Error(t, store.Update(TargetPreferences, func(s *Snapshot) error { delete(s.Hosts.Hosts, "https://example.org/base"); return nil }))
|
||||||
|
snapshot, err = store.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, snapshot.Preferences.Editor)
|
||||||
|
require.Len(t, snapshot.Hosts.Hosts, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePersistsOnlyExplicitPreferences(t *testing.T) {
|
||||||
|
s := New(t.TempDir())
|
||||||
|
require.NoError(t, s.Update(TargetPreferences, func(snapshot *Snapshot) error { snapshot.Preferences.Editor = "vi"; return nil }))
|
||||||
|
snapshot, err := s.Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, snapshot.Configured["editor"])
|
||||||
|
for _, key := range []string{"git_protocol", "prompt", "color", "aliases"} {
|
||||||
|
require.False(t, snapshot.Configured[key], key)
|
||||||
|
}
|
||||||
|
require.Equal(t, Defaults().GitProtocol, snapshot.Preferences.GitProtocol)
|
||||||
|
require.NoError(t, s.Update(TargetPreferences, func(snapshot *Snapshot) error { snapshot.Preferences.Color = "never"; return nil }))
|
||||||
|
data, err := os.ReadFile(filepath.Join(s.Dir, "config.yml"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "version: \"1\"\neditor: vi\ncolor: never\n", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenlessAccounts(t *testing.T) {
|
||||||
|
for method, want := range map[string]bool{AuthToken: false, AuthOAuth: false, AuthSSH: true, AuthHTTPSign: true} {
|
||||||
|
require.Equal(t, want, Account{AuthMethod: method}.Tokenless(), method)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API returns an HTTP client for requests to this instance. It attaches the credential and Sudo
|
||||||
|
// header to every attempt, retries only body-free reads, never follows redirects, and records
|
||||||
|
// the sanitized error of a failed response for CaptureErrors.
|
||||||
|
func (c *Client) API() *http.Client {
|
||||||
|
base := c.HTTP.Transport
|
||||||
|
if base == nil {
|
||||||
|
base = http.DefaultTransport
|
||||||
|
}
|
||||||
|
chain := captureTransport{
|
||||||
|
base: retryTransport{base: authTransport{base: base, token: c.Token, sudo: c.Sudo, signer: c.Signer}, retries: c.Retries},
|
||||||
|
token: c.Token,
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Transport: chain,
|
||||||
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
Jar: c.HTTP.Jar,
|
||||||
|
Timeout: c.HTTP.Timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type capturedErrorKey struct{}
|
||||||
|
|
||||||
|
// CaptureErrors returns a context under which API clients record the sanitized error of each
|
||||||
|
// response outside 2xx, and a function returning the last one recorded.
|
||||||
|
func CaptureErrors(ctx context.Context) (context.Context, func() *APIError) {
|
||||||
|
holder := new(atomic.Pointer[APIError])
|
||||||
|
return context.WithValue(ctx, capturedErrorKey{}, holder), holder.Load
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureTransport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t captureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := t.base.RoundTrip(req)
|
||||||
|
holder, _ := req.Context().Value(capturedErrorKey{}).(*atomic.Pointer[APIError])
|
||||||
|
if err != nil || holder == nil || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
|
||||||
|
holder.Store(newAPIError(resp.StatusCode, req.Method, req.URL.Path, t.token, data))
|
||||||
|
resp.Body = struct {
|
||||||
|
io.Reader
|
||||||
|
io.Closer
|
||||||
|
}{io.MultiReader(bytes.NewReader(data), resp.Body), resp.Body}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type retryTransport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
retries int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
safe := (req.Method == http.MethodGet || req.Method == http.MethodHead) && (req.Body == nil || req.Body == http.NoBody)
|
||||||
|
for attempt := 0; ; attempt++ {
|
||||||
|
resp, err := t.base.RoundTrip(req)
|
||||||
|
if !safe || attempt >= t.retries || (err == nil && resp.StatusCode != http.StatusBadGateway && resp.StatusCode != http.StatusServiceUnavailable && resp.StatusCode != http.StatusGatewayTimeout) {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(time.Duration(1<<attempt) * 100 * time.Millisecond)
|
||||||
|
select {
|
||||||
|
case <-req.Context().Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, req.Context().Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type authTransport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
token, sudo string
|
||||||
|
signer RequestSigner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
signed := req.Clone(req.Context())
|
||||||
|
if t.sudo != "" {
|
||||||
|
signed.Header.Set("Sudo", t.sudo)
|
||||||
|
}
|
||||||
|
if t.token != "" {
|
||||||
|
signed.Header.Set("Authorization", "token "+t.token)
|
||||||
|
} else if t.signer != nil {
|
||||||
|
body, err := signingBody(signed)
|
||||||
|
if err == nil {
|
||||||
|
err = t.signer.SignRequest(signed, body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if req.Body != nil {
|
||||||
|
_ = req.Body.Close()
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.base.RoundTrip(signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// signingBody returns the request body to sign, buffering a body that cannot be replayed.
|
||||||
|
func signingBody(req *http.Request) ([]byte, error) {
|
||||||
|
if req.Body == nil || req.Body == http.NoBody {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
body := req.Body
|
||||||
|
if req.GetBody != nil {
|
||||||
|
var err error
|
||||||
|
if body, err = req.GetBody(); err != nil {
|
||||||
|
return nil, errors.New("cannot read API request body for signing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(body)
|
||||||
|
_ = body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("cannot read API request body for signing")
|
||||||
|
}
|
||||||
|
if req.GetBody == nil {
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(data))
|
||||||
|
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIRetriesOnlyBodyFreeReads(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, method string
|
||||||
|
body io.Reader
|
||||||
|
status int
|
||||||
|
calls int
|
||||||
|
}{
|
||||||
|
{"get 503", http.MethodGet, nil, http.StatusServiceUnavailable, 3},
|
||||||
|
{"head 502", http.MethodHead, nil, http.StatusBadGateway, 3},
|
||||||
|
{"get 504", http.MethodGet, http.NoBody, http.StatusGatewayTimeout, 3},
|
||||||
|
{"get 500", http.MethodGet, nil, http.StatusInternalServerError, 1},
|
||||||
|
{"get with body", http.MethodGet, strings.NewReader(`{}`), http.StatusServiceUnavailable, 1},
|
||||||
|
{"post", http.MethodPost, nil, http.StatusServiceUnavailable, 1},
|
||||||
|
{"delete", http.MethodDelete, nil, http.StatusServiceUnavailable, 1},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
calls++
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
req, err := http.NewRequest(tc.method, server.URL+"/api/v1/user", tc.body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := New(server.URL, "", false).API().Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, tc.status, resp.StatusCode)
|
||||||
|
require.Equal(t, tc.calls, calls)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIRetryRecoversAndStopsOnCancellation(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(server.URL, "", false)
|
||||||
|
resp, err := client.API().Get(server.URL + "/api/v1/user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
require.Equal(t, 2, calls)
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
unavailable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
calls++
|
||||||
|
cancel()
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer unavailable.Close()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, unavailable.URL+"/api/v1/user", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = New(unavailable.URL, "", false).API().Do(req)
|
||||||
|
require.ErrorIs(t, err, context.Canceled)
|
||||||
|
require.Equal(t, 1, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIAuthenticatesEveryAttempt(t *testing.T) {
|
||||||
|
type seen struct{ authorization, sudo string }
|
||||||
|
requests := []seen{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests = append(requests, seen{r.Header.Get("Authorization"), r.Header.Get("Sudo")})
|
||||||
|
if len(requests) == 1 {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(server.URL, "secret", false)
|
||||||
|
client.Sudo = "bob"
|
||||||
|
resp, err := client.API().Get(server.URL + "/api/v1/user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, []seen{{"token secret", "bob"}, {"token secret", "bob"}}, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
type bodySigner struct{ bodies []string }
|
||||||
|
|
||||||
|
func (s *bodySigner) SignRequest(r *http.Request, contents []byte) error {
|
||||||
|
s.bodies = append(s.bodies, string(contents))
|
||||||
|
r.Header.Set("Signature", "signed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISignsRequestBodies(t *testing.T) {
|
||||||
|
received := []string{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "signed", r.Header.Get("Signature"))
|
||||||
|
require.Empty(t, r.Header.Get("Authorization"))
|
||||||
|
data, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
received = append(received, string(data))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
signer := &bodySigner{}
|
||||||
|
client := New(server.URL, "", false)
|
||||||
|
client.Signer = signer
|
||||||
|
for _, body := range []io.Reader{strings.NewReader(`{"replayable":true}`), io.MultiReader(strings.NewReader(`{"streamed":true}`)), nil} {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/user/keys", body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := client.API().Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
}
|
||||||
|
require.Equal(t, []string{`{"replayable":true}`, `{"streamed":true}`, ""}, signer.bodies)
|
||||||
|
require.Equal(t, signer.bodies, received)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPINeverFollowsRedirects(t *testing.T) {
|
||||||
|
leaked := false
|
||||||
|
outside := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { leaked = true }))
|
||||||
|
defer outside.Close()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, outside.URL, http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(server.URL, "secret", false)
|
||||||
|
client.HTTP = server.Client()
|
||||||
|
resp, err := client.API().Get(server.URL + "/api/v1/user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusFound, resp.StatusCode)
|
||||||
|
require.False(t, leaked)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureErrorsSanitizesAndKeepsBodyReadable(t *testing.T) {
|
||||||
|
long := `{"message":"` + strings.Repeat("y", 5000) + `"}`
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body, message string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"message", `{"message":"branch exists"}`, "branch exists", http.StatusUnprocessableEntity},
|
||||||
|
{"oversized", long, "", http.StatusInternalServerError},
|
||||||
|
{"redirect", ``, "", http.StatusMovedPermanently},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
_, _ = w.Write([]byte(tc.body))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
ctx, failure := CaptureErrors(context.Background())
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, server.URL+"/api/v1/repos/a/b?token=secret", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := New(server.URL, "secret", false).API().Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, tc.body, string(data))
|
||||||
|
require.Equal(t, &APIError{Status: tc.status, Method: http.MethodPatch, Endpoint: "/api/v1/repos/a/b", Message: tc.message}, failure())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureErrorsIgnoresSuccessAndUncapturedRequests(t *testing.T) {
|
||||||
|
status := http.StatusOK
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"ignored"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(server.URL, "", false)
|
||||||
|
ctx, failure := CaptureErrors(context.Background())
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/api/v1/user", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := client.API().Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Nil(t, failure())
|
||||||
|
|
||||||
|
status = http.StatusNotFound
|
||||||
|
resp, err = client.API().Get(server.URL + "/api/v1/user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.JSONEq(t, `{"message":"ignored"}`, string(data))
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package transport provides scoped, cancellable Gitea API requests.
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitea.dev/tea/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestSigner attaches HTTP signature headers for SSH/HTTPSign authentication.
|
||||||
|
type RequestSigner interface {
|
||||||
|
SignRequest(r *http.Request, contents []byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError reports request failure without including credentials or raw response bodies.
|
||||||
|
// Message holds only the sanitized "message" field of a JSON error body.
|
||||||
|
type APIError struct {
|
||||||
|
Status int
|
||||||
|
Method, Endpoint, Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
var text string
|
||||||
|
switch e.Status {
|
||||||
|
case 401:
|
||||||
|
text = "authentication rejected; run tea auth login"
|
||||||
|
case 403:
|
||||||
|
text = "permission denied by the Gitea instance"
|
||||||
|
case 404:
|
||||||
|
text = "resource or API endpoint unavailable on the selected Gitea instance"
|
||||||
|
case 429:
|
||||||
|
text = "Gitea rate limit exceeded; try again later"
|
||||||
|
default:
|
||||||
|
text = fmt.Sprintf("Gitea API returned HTTP %d for %s %s", e.Status, e.Method, e.Endpoint)
|
||||||
|
}
|
||||||
|
if e.Message != "" {
|
||||||
|
text += ": " + e.Message
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxErrorBody = 4 << 10
|
||||||
|
maxErrorMessage = 300
|
||||||
|
)
|
||||||
|
|
||||||
|
// newAPIError keeps only a bounded, printable JSON "message" field of a failed response body.
|
||||||
|
// Authentication failures and messages echoing the token keep no server text.
|
||||||
|
func newAPIError(status int, method, endpoint, token string, data []byte) *APIError {
|
||||||
|
failure := &APIError{Status: status, Method: method, Endpoint: endpoint}
|
||||||
|
if status == http.StatusUnauthorized {
|
||||||
|
return failure
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, &body) != nil || (token != "" && strings.Contains(body.Message, token)) {
|
||||||
|
return failure
|
||||||
|
}
|
||||||
|
message := strings.Join(strings.FieldsFunc(body.Message, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) || unicode.Is(unicode.Cf, r) }), " ")
|
||||||
|
if runes := []rune(message); len(runes) > maxErrorMessage {
|
||||||
|
message = string(runes[:maxErrorMessage]) + "..."
|
||||||
|
}
|
||||||
|
failure.Message = message
|
||||||
|
return failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client binds HTTP requests to one instance and credential.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
// Sudo asks the instance to perform requests as this user (administrators only).
|
||||||
|
Sudo string
|
||||||
|
Signer RequestSigner
|
||||||
|
HTTP *http.Client
|
||||||
|
Retries int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAgent identifies tea to instances, proxies, and server logs.
|
||||||
|
var UserAgent = "tea/" + version.Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")"
|
||||||
|
|
||||||
|
type userAgentTransport struct{ base http.RoundTripper }
|
||||||
|
|
||||||
|
func (t userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req = req.Clone(req.Context())
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
}
|
||||||
|
return t.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs an instance-scoped client with no redirects. Only connection setup and the wait
|
||||||
|
// for response headers are bounded, so slow but progressing transfers are not cut off.
|
||||||
|
func New(instance, token string, insecure bool) *Client {
|
||||||
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
tr.DialContext = (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||||
|
tr.TLSHandshakeTimeout = 10 * time.Second
|
||||||
|
tr.ResponseHeaderTimeout = 120 * time.Second
|
||||||
|
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecure} // user-controlled, instance-scoped
|
||||||
|
return &Client{BaseURL: instance, Token: token, Retries: 2, HTTP: &http.Client{Transport: userAgentTransport{tr}, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTimeout returns an independent HTTP policy while sharing the connection transport.
|
||||||
|
func (c *Client) WithTimeout(timeout time.Duration) *Client {
|
||||||
|
clone := *c
|
||||||
|
httpClient := *c.HTTP
|
||||||
|
httpClient.Timeout = timeout
|
||||||
|
clone.HTTP = &httpClient
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoped parses raw for this instance, rejecting userinfo, fragments, dot
|
||||||
|
// segments, and backslashes. kind names the reference in error messages.
|
||||||
|
func (c *Client) scoped(raw, kind string) (*url.URL, *url.URL, error) {
|
||||||
|
base, err := url.Parse(c.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.New("invalid instance URL")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.User != nil || u.Fragment != "" {
|
||||||
|
return nil, nil, errors.New("invalid " + kind)
|
||||||
|
}
|
||||||
|
for segment := range strings.SplitSeq(u.Path, "/") {
|
||||||
|
if segment == ".." || segment == "." {
|
||||||
|
return nil, nil, errors.New(kind + " cannot contain path traversal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(u.Path, "\\") {
|
||||||
|
return nil, nil, errors.New(kind + " cannot contain backslashes")
|
||||||
|
}
|
||||||
|
return base, u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// within reports whether u uses the instance scheme and host and lies below its base path plus under.
|
||||||
|
func within(base, u *url.URL, under string) bool {
|
||||||
|
return u.Scheme == base.Scheme && strings.EqualFold(u.Host, base.Host) && strings.HasPrefix(u.Path, strings.TrimRight(base.Path, "/")+under)
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL accepts only endpoints within the selected installation API.
|
||||||
|
func (c *Client) URL(endpoint string) (string, error) {
|
||||||
|
base, u, err := c.scoped(endpoint, "API endpoint")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if u.IsAbs() || u.Host != "" {
|
||||||
|
if !within(base, u, "/api/") {
|
||||||
|
return "", errors.New("API URL must belong to the selected instance and its API base path")
|
||||||
|
}
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
endpointPath := u.Path
|
||||||
|
escapedEndpointPath := u.EscapedPath()
|
||||||
|
if !strings.HasPrefix(endpointPath, "/api/") {
|
||||||
|
endpointPath = "/api/v1/" + strings.TrimLeft(endpointPath, "/")
|
||||||
|
escapedEndpointPath = "/api/v1/" + strings.TrimLeft(escapedEndpointPath, "/")
|
||||||
|
}
|
||||||
|
base.RawPath = strings.TrimRight(base.EscapedPath(), "/") + escapedEndpointPath
|
||||||
|
base.Path = strings.TrimRight(base.Path, "/") + endpointPath
|
||||||
|
base.RawQuery = u.RawQuery
|
||||||
|
return base.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestError keeps the network or TLS cause of a failed request. The cause of a *url.Error
|
||||||
|
// omits the request URL, whose query may carry secrets.
|
||||||
|
func RequestError(ctx context.Context, action string, err error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if urlErr, ok := errors.AsType[*url.Error](err); ok {
|
||||||
|
return fmt.Errorf("%s: %w", action, urlErr.Err)
|
||||||
|
}
|
||||||
|
return errors.New(action + "; check connectivity and TLS settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do executes a cancellable request, retrying only safe body-free reads.
|
||||||
|
// A response outside 2xx is closed and returned as an *APIError.
|
||||||
|
func (c *Client) Do(ctx context.Context, method, endpoint string, body io.Reader, headers http.Header) (*http.Response, error) {
|
||||||
|
ctx, failure := CaptureErrors(ctx)
|
||||||
|
resp, err := c.Send(ctx, method, endpoint, body, headers)
|
||||||
|
if err != nil || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return nil, failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send is Do without converting responses outside 2xx into errors.
|
||||||
|
func (c *Client) Send(ctx context.Context, method, endpoint string, body io.Reader, headers http.Header) (*http.Response, error) {
|
||||||
|
requestURL, err := c.URL(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, requestURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("cannot construct API request")
|
||||||
|
}
|
||||||
|
req.Header = headers.Clone()
|
||||||
|
if req.Header == nil {
|
||||||
|
req.Header = http.Header{}
|
||||||
|
}
|
||||||
|
if body != nil && req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if req.Header.Get("Accept") == "" {
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := c.API().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, RequestError(ctx, "API request failed", err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/tea/internal/version"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstanceScopeAndRedirect(t *testing.T) {
|
||||||
|
leaked := false
|
||||||
|
outside := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leaked = true; w.WriteHeader(200) }))
|
||||||
|
defer outside.Close()
|
||||||
|
selected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/base/api/v1/user", r.URL.Path)
|
||||||
|
require.Equal(t, "token secret", r.Header.Get("Authorization"))
|
||||||
|
http.Redirect(w, r, outside.URL, http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer selected.Close()
|
||||||
|
client := New(selected.URL+"/base", "secret", false)
|
||||||
|
_, err := client.Do(context.Background(), http.MethodGet, "user", nil, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.False(t, leaked)
|
||||||
|
for _, endpoint := range []string{outside.URL + "/api/v1/user", strings.Replace(selected.URL, "http:", "https:", 1) + "/base/api/v1/user", selected.URL + "/api/v1/user", "../user", selected.URL + "/base/api/v1/../../other", "//other.example/api/v1/user"} {
|
||||||
|
_, err := client.URL(endpoint)
|
||||||
|
require.Error(t, err, endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
client := New("https://example.org", "", false)
|
||||||
|
_, err := client.Do(ctx, http.MethodGet, "user", nil, nil)
|
||||||
|
require.ErrorIs(t, err, context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLPreservesEscapedSegments(t *testing.T) {
|
||||||
|
client := New("https://example.test/base%20path", "", false)
|
||||||
|
resolved, err := client.URL("repos/alice/project/releases/tags/release%2Fv1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "https://example.test/base%20path/api/v1/repos/alice/project/releases/tags/release%2Fv1", resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIErrorIncludesOnlySanitizedServerMessage(t *testing.T) {
|
||||||
|
const merge = "Gitea API returned HTTP %d for POST /api/v1/repos/a/b/pulls/1/merge"
|
||||||
|
long := strings.Repeat("x", 400)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body, want string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"merge refusal", `{"message":"Please try again later","url":"https://leak.example/?key=abc"}`, fmt.Sprintf(merge, 405) + ": Please try again later", 405},
|
||||||
|
{"token scope", `{"message":"token does not have at least one of required scope(s): [read:user]"}`, "permission denied by the Gitea instance: token does not have at least one of required scope(s): [read:user]", 403},
|
||||||
|
{"control characters", "{\"message\":\"bad\\u001b[31m\\nnews\\u202e\"}", fmt.Sprintf(merge, 422) + ": bad [31m news", 422},
|
||||||
|
{"capped", `{"message":"` + long + `"}`, fmt.Sprintf(merge, 500) + ": " + long[:300] + "...", 500},
|
||||||
|
{"not json", `<html>key=abc</html>`, fmt.Sprintf(merge, 500), 500},
|
||||||
|
{"other fields", `{"errors":["abc"]}`, fmt.Sprintf(merge, 500), 500},
|
||||||
|
{"oversized body", `{"message":"` + strings.Repeat("y", 5000) + `"}`, fmt.Sprintf(merge, 500), 500},
|
||||||
|
{"token echo", `{"message":"bad token secret"}`, fmt.Sprintf(merge, 400), 400},
|
||||||
|
{"authentication", `{"message":"user alice not found"}`, "authentication rejected; run tea auth login", 401},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
_, _ = w.Write([]byte(tc.body))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
_, err := New(server.URL, "secret", false).Do(context.Background(), http.MethodPost, "repos/a/b/pulls/1/merge?key=abc", strings.NewReader(`{}`), nil)
|
||||||
|
var apiErr *APIError
|
||||||
|
require.ErrorAs(t, err, &apiErr)
|
||||||
|
require.Equal(t, tc.status, apiErr.Status)
|
||||||
|
require.Equal(t, tc.want, err.Error())
|
||||||
|
require.NotContains(t, err.Error(), "abc")
|
||||||
|
require.NotContains(t, err.Error(), "secret")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSudoHeaderOnAPIAndDownloadRequests(t *testing.T) {
|
||||||
|
seen := []string{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seen = append(seen, r.URL.Path+" "+r.Header.Get("Sudo"))
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(server.URL, "secret", false)
|
||||||
|
for _, sudo := range []string{"", "bob"} {
|
||||||
|
client.Sudo = sudo
|
||||||
|
response, err := client.Do(context.Background(), http.MethodGet, "user", nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, response.Body.Close())
|
||||||
|
}
|
||||||
|
response, err := client.Download(context.Background(), server.URL+"/file")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, response.Body.Close())
|
||||||
|
require.Equal(t, []string{"/api/v1/user ", "/api/v1/user bob", "/file bob"}, seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportBoundsOnlySetupAndSendsUserAgent(t *testing.T) {
|
||||||
|
agents := []string{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
agents = append(agents, r.Header.Get("User-Agent"))
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(server.URL, "secret", false)
|
||||||
|
// A whole-request timeout would cut off long uploads and downloads that are still progressing.
|
||||||
|
require.Zero(t, client.HTTP.Timeout)
|
||||||
|
tr := client.HTTP.Transport.(userAgentTransport).base.(*http.Transport)
|
||||||
|
require.Equal(t, 120*time.Second, tr.ResponseHeaderTimeout)
|
||||||
|
require.Equal(t, 10*time.Second, tr.TLSHandshakeTimeout)
|
||||||
|
for _, open := range []func() (*http.Response, error){
|
||||||
|
func() (*http.Response, error) {
|
||||||
|
return client.Do(context.Background(), http.MethodGet, "user", nil, nil)
|
||||||
|
},
|
||||||
|
func() (*http.Response, error) { return client.Download(context.Background(), server.URL+"/file") },
|
||||||
|
} {
|
||||||
|
response, err := open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, response.Body.Close())
|
||||||
|
}
|
||||||
|
want := "tea/" + version.Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")"
|
||||||
|
require.Equal(t, []string{want, want}, agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetworkErrorsKeepCauseWithoutURL(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
server.Close()
|
||||||
|
client := New(server.URL, "secret", false)
|
||||||
|
_, err := client.Do(context.Background(), http.MethodPost, "user?token=query-secret", strings.NewReader(`{}`), nil)
|
||||||
|
var opErr *net.OpError
|
||||||
|
require.ErrorAs(t, err, &opErr)
|
||||||
|
require.ErrorContains(t, err, "API request failed: ")
|
||||||
|
require.NotContains(t, err.Error(), "query-secret")
|
||||||
|
_, err = client.Download(context.Background(), server.URL+"/file?signature=query-secret")
|
||||||
|
require.ErrorAs(t, err, &opErr)
|
||||||
|
require.ErrorContains(t, err, "download failed: ")
|
||||||
|
require.NotContains(t, err.Error(), "query-secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebugLogsRequestsWithoutSecrets(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
var log bytes.Buffer
|
||||||
|
client := New(server.URL, "secret-token", false).Debug(&log)
|
||||||
|
_, err := client.Do(context.Background(), http.MethodGet, "repos/a/b?page=2&token=query-secret", nil, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Regexp(t, `^\[debug\] GET `+regexp.QuoteMeta(server.URL)+`/api/v1/repos/a/b\?page=REDACTED&token=REDACTED -> 418 I'm a teapot \(\d+(\.\d+)?[µm]?s\)\n$`, log.String())
|
||||||
|
require.NotContains(t, log.String(), "secret")
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type debugTransport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
out io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t debugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := t.base.RoundTrip(req)
|
||||||
|
var outcome string
|
||||||
|
if err != nil {
|
||||||
|
outcome = "error: " + err.Error()
|
||||||
|
} else {
|
||||||
|
outcome = resp.Status
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(t.out, "[debug] %s %s -> %s (%s)\n", req.Method, redactURL(req.URL), outcome, time.Since(start).Round(time.Millisecond))
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactURL drops userinfo and query values, which can carry tokens or signatures.
|
||||||
|
func redactURL(u *url.URL) string {
|
||||||
|
clean := *u
|
||||||
|
clean.User = nil
|
||||||
|
if clean.RawQuery != "" {
|
||||||
|
query := clean.Query()
|
||||||
|
for key := range query {
|
||||||
|
query[key] = []string{"REDACTED"}
|
||||||
|
}
|
||||||
|
clean.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
return clean.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug returns a copy of c that logs the method, redacted URL, status, and duration of each
|
||||||
|
// request to w. Headers and bodies are never logged.
|
||||||
|
func (c *Client) Debug(w io.Writer) *Client {
|
||||||
|
clone := *c
|
||||||
|
httpClient := *c.HTTP
|
||||||
|
base := httpClient.Transport
|
||||||
|
if base == nil {
|
||||||
|
base = http.DefaultTransport
|
||||||
|
}
|
||||||
|
httpClient.Transport = debugTransport{base: base, out: w}
|
||||||
|
clone.HTTP = &httpClient
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Download opens an instance-scoped file URL without following redirects.
|
||||||
|
func (c *Client) Download(ctx context.Context, raw string) (*http.Response, error) {
|
||||||
|
_, target, err := c.downloadTarget(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.get(ctx, target, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadTarget returns the parsed instance URL and raw, which must lie within its base path.
|
||||||
|
func (c *Client) downloadTarget(raw string) (*url.URL, *url.URL, error) {
|
||||||
|
base, target, err := c.scoped(raw, "download URL")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if !within(base, target, "/") {
|
||||||
|
return nil, nil, errors.New("download URL must belong to the selected instance and base path")
|
||||||
|
}
|
||||||
|
return base, target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(ctx context.Context, target *url.URL, allowRedirect bool) (*http.Response, error) {
|
||||||
|
ctx, failure := CaptureErrors(ctx)
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("cannot construct download request")
|
||||||
|
}
|
||||||
|
response, err := c.API().Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, RequestError(ctx, "download failed", err)
|
||||||
|
}
|
||||||
|
if (allowRedirect && redirect(response.StatusCode)) || (response.StatusCode >= 200 && response.StatusCode < 300) {
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
_ = response.Body.Close()
|
||||||
|
return nil, failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirect(status int) bool {
|
||||||
|
switch status {
|
||||||
|
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadSigned opens an instance-scoped file URL and follows at most one redirect without
|
||||||
|
// credentials, such as Gitea's redirect to signed object-storage URLs. The redirect target may be
|
||||||
|
// another host, but must use HTTPS or the instance's scheme. Automatic redirects remain disabled,
|
||||||
|
// including on an injected HTTP client.
|
||||||
|
func (c *Client) DownloadSigned(ctx context.Context, raw string) (*http.Response, error) {
|
||||||
|
base, target, err := c.downloadTarget(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
response, err := c.get(ctx, target, true)
|
||||||
|
if err != nil || !redirect(response.StatusCode) {
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
_ = response.Body.Close()
|
||||||
|
location, err := response.Location()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("download returned an invalid redirect")
|
||||||
|
}
|
||||||
|
if location.Scheme != "https" && location.Scheme != base.Scheme {
|
||||||
|
return nil, errors.New("download redirect must use HTTPS or the instance's scheme")
|
||||||
|
}
|
||||||
|
unsigned := *c
|
||||||
|
unsigned.Token, unsigned.Signer, unsigned.Sudo = "", nil, ""
|
||||||
|
return unsigned.get(ctx, location, false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadScopeAndRedirects(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests++
|
||||||
|
require.Equal(t, "token sensitive", r.Header.Get("Authorization"))
|
||||||
|
if r.URL.Path == "/base/redirect" {
|
||||||
|
http.Redirect(w, r, "/outside", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("binary bytes"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(server.URL+"/base", "sensitive", false)
|
||||||
|
// Even a substituted HTTP client cannot re-enable automatic redirects.
|
||||||
|
client.HTTP = server.Client()
|
||||||
|
for _, raw := range []string{server.URL + "/outside", server.URL + "/base/../outside", server.URL + "/base/%2e%2e/outside", "https://example.test/base/file"} {
|
||||||
|
_, err := client.Download(context.Background(), raw)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
require.Zero(t, requests)
|
||||||
|
_, err := client.Download(context.Background(), server.URL+"/base/redirect")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, 1, requests)
|
||||||
|
response, err := client.Download(context.Background(), server.URL+"/base/file")
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, response.Body.Close())
|
||||||
|
require.Equal(t, "binary bytes", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignedDownloadsFollowOneRedirectWithoutCredentials(t *testing.T) {
|
||||||
|
storage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Empty(t, r.Header.Get("Authorization"))
|
||||||
|
require.Empty(t, r.Header.Get("Sudo"))
|
||||||
|
_, _ = w.Write([]byte("archive"))
|
||||||
|
}))
|
||||||
|
defer storage.Close()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
destination string
|
||||||
|
status int
|
||||||
|
ok bool
|
||||||
|
requests int
|
||||||
|
}{
|
||||||
|
{"/base/signed?signature=private-value", http.StatusFound, true, 1},
|
||||||
|
{"/outside?signature=private-value", http.StatusFound, true, 1},
|
||||||
|
{storage.URL + "/bucket/file?signature=private-value", http.StatusSeeOther, true, 0},
|
||||||
|
{storage.URL + "/bucket/file?signature=private-value", http.StatusTemporaryRedirect, true, 0},
|
||||||
|
{"/base/again", http.StatusFound, false, 1},
|
||||||
|
{"ftp://storage.example.test/file?signature=private-value", http.StatusFound, false, 0},
|
||||||
|
} {
|
||||||
|
t.Run(tc.destination, func(t *testing.T) {
|
||||||
|
signedRequests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/base/archive" {
|
||||||
|
require.Equal(t, "token sensitive", r.Header.Get("Authorization"))
|
||||||
|
require.Equal(t, "bob", r.Header.Get("Sudo"))
|
||||||
|
http.Redirect(w, r, tc.destination, tc.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signedRequests++
|
||||||
|
require.Empty(t, r.Header.Get("Authorization"))
|
||||||
|
require.Empty(t, r.Header.Get("Sudo"))
|
||||||
|
if r.URL.Path == "/base/again" {
|
||||||
|
http.Redirect(w, r, "/base/signed", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("archive"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(server.URL+"/base", "sensitive", false)
|
||||||
|
client.Sudo = "bob"
|
||||||
|
client.HTTP = server.Client() // The implementation must still suppress automatic redirects.
|
||||||
|
response, err := client.DownloadSigned(context.Background(), server.URL+"/base/archive")
|
||||||
|
require.Equal(t, tc.requests, signedRequests)
|
||||||
|
if !tc.ok {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotContains(t, err.Error(), "private-value")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer response.Body.Close()
|
||||||
|
data, err := io.ReadAll(response.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "archive", string(data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSigner struct{ calls int }
|
||||||
|
|
||||||
|
func (s *fakeSigner) SignRequest(r *http.Request, _ []byte) error {
|
||||||
|
s.calls++
|
||||||
|
r.Header.Set("Signature", "signed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadsSignOnlyInstanceRequest(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/base/archive" {
|
||||||
|
require.Equal(t, "signed", r.Header.Get("Signature"))
|
||||||
|
http.Redirect(w, r, "/base/signed?signature=private-value", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Empty(t, r.Header.Get("Signature"))
|
||||||
|
require.Empty(t, r.Header.Get("Authorization"))
|
||||||
|
_, _ = w.Write([]byte("archive"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
signer := &fakeSigner{}
|
||||||
|
client := New(server.URL+"/base", "", false)
|
||||||
|
client.HTTP = server.Client()
|
||||||
|
client.Signer = signer
|
||||||
|
response, err := client.DownloadSigned(context.Background(), server.URL+"/base/archive")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, response.Body.Close())
|
||||||
|
require.Equal(t, 1, signer.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLAndDownloadShareScopeRejections(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests++ }))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(server.URL+"/base", "sensitive", false)
|
||||||
|
client.HTTP = server.Client()
|
||||||
|
host := strings.TrimPrefix(server.URL, "http://")
|
||||||
|
for _, raw := range []string{
|
||||||
|
"http://user:pass@" + host + "/base/api/v1/user", server.URL + "/base/api/v1/user#fragment", server.URL + "/base/api/v1/a\\b",
|
||||||
|
server.URL + "/base/api/v1/./user", server.URL + "/base/api/v1/%2e%2e/user", server.URL + "/basement/api/v1/user", "//" + host + "/base/api/v1/user",
|
||||||
|
} {
|
||||||
|
_, err := client.URL(raw)
|
||||||
|
require.Error(t, err, raw)
|
||||||
|
_, err = client.Download(context.Background(), raw)
|
||||||
|
require.Error(t, err, raw)
|
||||||
|
}
|
||||||
|
require.Zero(t, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignedDownloadRejectsInvalidInstanceURL(t *testing.T) {
|
||||||
|
_, err := New("http://[invalid", "sensitive", false).DownloadSigned(context.Background(), "http://example.test/file")
|
||||||
|
require.ErrorContains(t, err, "invalid instance URL")
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version holds the current tea version.
|
||||||
|
// This is set at build time via ldflags.
|
||||||
|
// If the Version is moved to another package or name changed,
|
||||||
|
// build flags in .goreleaser.yaml or Makefile need to be updated accordingly.
|
||||||
|
var Version = "development"
|
||||||
|
|
||||||
|
// Tags holds the build tags used
|
||||||
|
var Tags = ""
|
||||||
|
|
||||||
|
// Format returns a human-readable version string including
|
||||||
|
// Go version and build tags when available.
|
||||||
|
func Format() string {
|
||||||
|
s := fmt.Sprintf("Version: %s\tgolang: %s",
|
||||||
|
Version,
|
||||||
|
strings.ReplaceAll(runtime.Version(), "go", ""))
|
||||||
|
|
||||||
|
if len(Tags) != 0 {
|
||||||
|
s += fmt.Sprintf("\tbuilt with: %s", strings.ReplaceAll(Tags, " ", ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -51,7 +51,9 @@ func (r *fakeRepoBackend) DeleteLocalBranch(branchName string) error { return ni
|
|||||||
func (r *fakeRepoBackend) DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
|
func (r *fakeRepoBackend) DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepoBackend) ListReferences(prefixes ...string) ([]*Reference, error) { return nil, nil }
|
func (r *fakeRepoBackend) ListReferences(prefixes ...string) ([]*Reference, error) { return nil, nil }
|
||||||
|
|
||||||
func (r *fakeRepoBackend) PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error {
|
func (r *fakeRepoBackend) PushToAgitFlowPR(remoteName, head, base, topic string, pushOptions map[string]string, auth *AuthMethod) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func promptIssueProperties(ctx context.Context, login *config.Login, owner, repo
|
|||||||
var milestoneName string
|
var milestoneName string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
selectableChan := make(chan (issueSelectables), 1)
|
selectableChan := make(chan issueSelectables, 1)
|
||||||
go fetchIssueSelectables(ctx, login, owner, repo, selectableChan)
|
go fetchIssueSelectables(ctx, login, owner, repo, selectableChan)
|
||||||
|
|
||||||
// title
|
// title
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex
|
|||||||
var labelsSelected []string
|
var labelsSelected []string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
selectableChan := make(chan (issueSelectables), 1)
|
selectableChan := make(chan issueSelectables, 1)
|
||||||
go fetchIssueSelectables(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, selectableChan)
|
go fetchIssueSelectables(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, selectableChan)
|
||||||
|
|
||||||
// title
|
// title
|
||||||
|
|||||||
Reference in new issue
Block a user