From 2b218a5bae9662417f4375c12d4580327542fc33 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 17:06:38 +0000 Subject: [PATCH] refactor: add settings, transport and alias packages (#1122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- Makefile | 4 +- flake.nix | 2 +- go.mod | 45 ++- go.sum | 99 +++---- internal/aliases/aliases.go | 154 ++++++++++ internal/aliases/aliases_test.go | 65 +++++ internal/clierr/errors.go | 53 ++++ internal/clierr/errors_test.go | 33 +++ internal/filelock/filelock.go | 70 +++++ internal/filelock/filelock_test.go | 119 ++++++++ internal/filelock/filelock_unix.go | 39 +++ internal/filelock/filelock_windows.go | 48 ++++ internal/settings/remote.go | 139 +++++++++ internal/settings/remote_test.go | 74 +++++ internal/settings/ssh.go | 32 +++ internal/settings/ssh_test.go | 34 +++ internal/settings/store.go | 397 ++++++++++++++++++++++++++ internal/settings/store_test.go | 137 +++++++++ internal/transport/api.go | 140 +++++++++ internal/transport/api_test.go | 203 +++++++++++++ internal/transport/client.go | 227 +++++++++++++++ internal/transport/client_test.go | 166 +++++++++++ internal/transport/debug.go | 58 ++++ internal/transport/download.go | 83 ++++++ internal/transport/download_test.go | 158 ++++++++++ internal/version/version.go | 33 +++ modules/git/backend_test.go | 2 + modules/interact/issue_create.go | 2 +- modules/interact/issue_edit.go | 2 +- 29 files changed, 2541 insertions(+), 77 deletions(-) create mode 100644 internal/aliases/aliases.go create mode 100644 internal/aliases/aliases_test.go create mode 100644 internal/clierr/errors.go create mode 100644 internal/clierr/errors_test.go create mode 100644 internal/filelock/filelock.go create mode 100644 internal/filelock/filelock_test.go create mode 100644 internal/filelock/filelock_unix.go create mode 100644 internal/filelock/filelock_windows.go create mode 100644 internal/settings/remote.go create mode 100644 internal/settings/remote_test.go create mode 100644 internal/settings/ssh.go create mode 100644 internal/settings/ssh_test.go create mode 100644 internal/settings/store.go create mode 100644 internal/settings/store_test.go create mode 100644 internal/transport/api.go create mode 100644 internal/transport/api_test.go create mode 100644 internal/transport/client.go create mode 100644 internal/transport/client_test.go create mode 100644 internal/transport/debug.go create mode 100644 internal/transport/download.go create mode 100644 internal/transport/download_test.go create mode 100644 internal/version/version.go diff --git a/Makefile b/Makefile index 0376dbdf..b253d0ea 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,8 @@ export PATH := $($(GO) env GOPATH)/bin:$(PATH) GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go") # Tool packages with pinned versions -GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 +GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.12.0 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 ifneq ($(DRONE_TAG),) VERSION ?= $(subst v,,$(DRONE_TAG)) diff --git a/flake.nix b/flake.nix index d3e49287..aa222a4c 100644 --- a/flake.nix +++ b/flake.nix @@ -15,7 +15,7 @@ devShells.default = pkgs.mkShell { name = "tea-dev-environment"; buildInputs = with pkgs; [ - go_1_25 + go_1_27 gopls gnumake # Add other dependencies here if needed diff --git a/go.mod b/go.mod index 9bcda1b6..fab4fe0d 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module gitea.dev/tea -go 1.26.0 +go 1.27.0 -toolchain go1.26.6 +toolchain go1.27.1 require ( charm.land/glamour/v2 v2.0.1 @@ -10,21 +10,22 @@ require ( charm.land/lipgloss/v2 v2.0.5 code.gitea.io/gitea-vet v0.2.3 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/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.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/olekukonko/tablewriter v1.1.4 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/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/sys v0.47.0 - golang.org/x/term v0.45.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -32,7 +33,7 @@ require ( charm.land/bubbles/v2 v2.1.0 // indirect charm.land/bubbletea/v2 v2.0.6 // 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/aymanbagabas/go-osc52/v2 v2.0.1 // 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/x/ansi v0.11.7 // 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/term v0.2.2 // 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/cpuguy83/go-md2man/v2 v2.0.7 // 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/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/fatih/color v1.19.0 // indirect - github.com/goccy/go-json v0.10.6 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.9.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-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/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect - github.com/olekukonko/errors v1.3.0 // indirect - github.com/olekukonko/ll v0.1.8 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/olekukonko/errors v1.2.0 // indirect + github.com/olekukonko/ll v0.1.6 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.8.2 // indirect - github.com/yuin/goldmark-emoji v1.0.6 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // 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/net v0.57.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/text v0.41.0 // indirect - golang.org/x/tools v0.48.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/tools v0.49.0 // indirect ) retract v1.3.3 // accidental release, tag deleted diff --git a/go.sum b/go.sum index 05e7ea1d..00a409f6 100644 --- a/go.sum +++ b/go.sum @@ -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= 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.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo= -gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE= +gitea.dev/sdk v1.3.0 h1:4U95+Ou6WHCQiRPDEyAzvl1Q7cxg5RUTWkqmOXr5w4I= +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/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 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/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.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.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= -github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +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/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= 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/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/slice v0.0.0-20260511125431-fe5d686e0c99 h1:e4VttUIAVgO4neqnJG80U4BE//1kcvyOrJ5utftPXQE= -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 h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +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/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= 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/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.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/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= -github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= -github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog= github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +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/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/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= 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/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/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U= -github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= -github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= +github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= +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/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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 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.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +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/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= 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/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= -github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +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/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-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.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +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/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= 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.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +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/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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +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-20190412213103-97732733099d/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.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +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-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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/aliases/aliases.go b/internal/aliases/aliases.go new file mode 100644 index 00000000..57a2db09 --- /dev/null +++ b/internal/aliases/aliases.go @@ -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 +} diff --git a/internal/aliases/aliases_test.go b/internal/aliases/aliases_test.go new file mode 100644 index 00000000..ba29c922 --- /dev/null +++ b/internal/aliases/aliases_test.go @@ -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) +} diff --git a/internal/clierr/errors.go b/internal/clierr/errors.go new file mode 100644 index 00000000..1a6c0367 --- /dev/null +++ b/internal/clierr/errors.go @@ -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 +} diff --git a/internal/clierr/errors_test.go b/internal/clierr/errors_test.go new file mode 100644 index 00000000..eb76b24a --- /dev/null +++ b/internal/clierr/errors_test.go @@ -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) + } +} diff --git a/internal/filelock/filelock.go b/internal/filelock/filelock.go new file mode 100644 index 00000000..a4f58bac --- /dev/null +++ b/internal/filelock/filelock.go @@ -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 +} diff --git a/internal/filelock/filelock_test.go b/internal/filelock/filelock_test.go new file mode 100644 index 00000000..63b0f5b6 --- /dev/null +++ b/internal/filelock/filelock_test.go @@ -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) + } +} diff --git a/internal/filelock/filelock_unix.go b/internal/filelock/filelock_unix.go new file mode 100644 index 00000000..d5f165d7 --- /dev/null +++ b/internal/filelock/filelock_unix.go @@ -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) +} diff --git a/internal/filelock/filelock_windows.go b/internal/filelock/filelock_windows.go new file mode 100644 index 00000000..89ed1dfb --- /dev/null +++ b/internal/filelock/filelock_windows.go @@ -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) +} diff --git a/internal/settings/remote.go b/internal/settings/remote.go new file mode 100644 index 00000000..a982d4e2 --- /dev/null +++ b/internal/settings/remote.go @@ -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 +} diff --git a/internal/settings/remote_test.go b/internal/settings/remote_test.go new file mode 100644 index 00000000..1bf546b5 --- /dev/null +++ b/internal/settings/remote_test.go @@ -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")) +} diff --git a/internal/settings/ssh.go b/internal/settings/ssh.go new file mode 100644 index 00000000..5474e394 --- /dev/null +++ b/internal/settings/ssh.go @@ -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 +} diff --git a/internal/settings/ssh_test.go b/internal/settings/ssh_test.go new file mode 100644 index 00000000..46120de5 --- /dev/null +++ b/internal/settings/ssh_test.go @@ -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) +} diff --git a/internal/settings/store.go b/internal/settings/store.go new file mode 100644 index 00000000..4145ac3d --- /dev/null +++ b/internal/settings/store.go @@ -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) +} diff --git a/internal/settings/store_test.go b/internal/settings/store_test.go new file mode 100644 index 00000000..bc6564f5 --- /dev/null +++ b/internal/settings/store_test.go @@ -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) + } +} diff --git a/internal/transport/api.go b/internal/transport/api.go new file mode 100644 index 00000000..e49f7ed6 --- /dev/null +++ b/internal/transport/api.go @@ -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< 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 +} diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go new file mode 100644 index 00000000..357d147d --- /dev/null +++ b/internal/transport/client_test.go @@ -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", `key=abc`, 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") +} diff --git a/internal/transport/debug.go b/internal/transport/debug.go new file mode 100644 index 00000000..34e913d5 --- /dev/null +++ b/internal/transport/debug.go @@ -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 +} diff --git a/internal/transport/download.go b/internal/transport/download.go new file mode 100644 index 00000000..7c92ed81 --- /dev/null +++ b/internal/transport/download.go @@ -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) +} diff --git a/internal/transport/download_test.go b/internal/transport/download_test.go new file mode 100644 index 00000000..e2f7c5a2 --- /dev/null +++ b/internal/transport/download_test.go @@ -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") +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 00000000..22cf1646 --- /dev/null +++ b/internal/version/version.go @@ -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 +} diff --git a/modules/git/backend_test.go b/modules/git/backend_test.go index b514c5f0..55b2ecab 100644 --- a/modules/git/backend_test.go +++ b/modules/git/backend_test.go @@ -51,7 +51,9 @@ func (r *fakeRepoBackend) DeleteLocalBranch(branchName string) error { return ni func (r *fakeRepoBackend) DeleteRemoteBranch(remoteName, remoteBranch string, auth *AuthMethod) error { return 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 { return nil } diff --git a/modules/interact/issue_create.go b/modules/interact/issue_create.go index c1202499..d13335f0 100644 --- a/modules/interact/issue_create.go +++ b/modules/interact/issue_create.go @@ -52,7 +52,7 @@ func promptIssueProperties(ctx context.Context, login *config.Login, owner, repo var milestoneName string var err error - selectableChan := make(chan (issueSelectables), 1) + selectableChan := make(chan issueSelectables, 1) go fetchIssueSelectables(ctx, login, owner, repo, selectableChan) // title diff --git a/modules/interact/issue_edit.go b/modules/interact/issue_edit.go index 4fdd4378..240ad2a8 100644 --- a/modules/interact/issue_edit.go +++ b/modules/interact/issue_edit.go @@ -70,7 +70,7 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex var labelsSelected []string var err error - selectableChan := make(chan (issueSelectables), 1) + selectableChan := make(chan issueSelectables, 1) go fetchIssueSelectables(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, selectableChan) // title