mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
refactor: add settings, transport and alias packages (#1122)
Part 1/5 of the tea 1.0 rewrite. **Stack** (split from #1121; each PR targets `main` and builds, tests and lints on its own — the shipped CLI only changes in part 5): 1. #1122 Toolchain and foundation packages ← this PR 2. #1123 Credentials and Git 3. #1124 Terminal 4. #1125 Services 5. #1126 CLI wiring, docs, removal of the old tree ## Changes - **build:** require Go 1.27; bump gofumpt and golangci-lint to releases that can read Go 1.27 export data (own commit). - `internal/clierr`: typed errors and executable exit codes. - `internal/version`: build version and tags. - `internal/filelock`: moved unchanged from `modules/filelock`. - `internal/aliases`: user-defined command aliases. - `internal/transport`: the HTTP client the Gitea SDK runs on (part 4): token, Sudo and SSH-signature auth, retries for safe reads, connect/TLS/response-header timeouts, typed API errors that keep Gitea's message, and a redacted request log for `--debug`; plus raw requests for `tea api`, signed downloads that follow object-storage redirects, and log streaming. - `internal/settings`: split `config.yml` / `hosts.yml` with locked transactions, plus remote URL parsing. Nothing imports these packages yet. Note: `flake.nix` now asks for `go_1_27`; the pinned nixpkgs in `flake.lock` predates Go 1.27 and still needs a `nix flake update nixpkgs`. Reviewed-on: https://gitea.com/gitea/tea/pulls/1122 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
bcd62a1fb2
commit
2b218a5bae
29 files changed
+2541
-77
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// API returns an HTTP client for requests to this instance. It attaches the credential and Sudo
|
||||
// header to every attempt, retries only body-free reads, never follows redirects, and records
|
||||
// the sanitized error of a failed response for CaptureErrors.
|
||||
func (c *Client) API() *http.Client {
|
||||
base := c.HTTP.Transport
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
chain := captureTransport{
|
||||
base: retryTransport{base: authTransport{base: base, token: c.Token, sudo: c.Sudo, signer: c.Signer}, retries: c.Retries},
|
||||
token: c.Token,
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: chain,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
|
||||
Jar: c.HTTP.Jar,
|
||||
Timeout: c.HTTP.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
type capturedErrorKey struct{}
|
||||
|
||||
// CaptureErrors returns a context under which API clients record the sanitized error of each
|
||||
// response outside 2xx, and a function returning the last one recorded.
|
||||
func CaptureErrors(ctx context.Context) (context.Context, func() *APIError) {
|
||||
holder := new(atomic.Pointer[APIError])
|
||||
return context.WithValue(ctx, capturedErrorKey{}, holder), holder.Load
|
||||
}
|
||||
|
||||
type captureTransport struct {
|
||||
base http.RoundTripper
|
||||
token string
|
||||
}
|
||||
|
||||
func (t captureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
holder, _ := req.Context().Value(capturedErrorKey{}).(*atomic.Pointer[APIError])
|
||||
if err != nil || holder == nil || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
||||
return resp, err
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
|
||||
holder.Store(newAPIError(resp.StatusCode, req.Method, req.URL.Path, t.token, data))
|
||||
resp.Body = struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{io.MultiReader(bytes.NewReader(data), resp.Body), resp.Body}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type retryTransport struct {
|
||||
base http.RoundTripper
|
||||
retries int
|
||||
}
|
||||
|
||||
func (t retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
safe := (req.Method == http.MethodGet || req.Method == http.MethodHead) && (req.Body == nil || req.Body == http.NoBody)
|
||||
for attempt := 0; ; attempt++ {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if !safe || attempt >= t.retries || (err == nil && resp.StatusCode != http.StatusBadGateway && resp.StatusCode != http.StatusServiceUnavailable && resp.StatusCode != http.StatusGatewayTimeout) {
|
||||
return resp, err
|
||||
}
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(1<<attempt) * 100 * time.Millisecond)
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
timer.Stop()
|
||||
return nil, req.Context().Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type authTransport struct {
|
||||
base http.RoundTripper
|
||||
token, sudo string
|
||||
signer RequestSigner
|
||||
}
|
||||
|
||||
func (t authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
signed := req.Clone(req.Context())
|
||||
if t.sudo != "" {
|
||||
signed.Header.Set("Sudo", t.sudo)
|
||||
}
|
||||
if t.token != "" {
|
||||
signed.Header.Set("Authorization", "token "+t.token)
|
||||
} else if t.signer != nil {
|
||||
body, err := signingBody(signed)
|
||||
if err == nil {
|
||||
err = t.signer.SignRequest(signed, body)
|
||||
}
|
||||
if err != nil {
|
||||
if req.Body != nil {
|
||||
_ = req.Body.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return t.base.RoundTrip(signed)
|
||||
}
|
||||
|
||||
// signingBody returns the request body to sign, buffering a body that cannot be replayed.
|
||||
func signingBody(req *http.Request) ([]byte, error) {
|
||||
if req.Body == nil || req.Body == http.NoBody {
|
||||
return nil, nil
|
||||
}
|
||||
body := req.Body
|
||||
if req.GetBody != nil {
|
||||
var err error
|
||||
if body, err = req.GetBody(); err != nil {
|
||||
return nil, errors.New("cannot read API request body for signing")
|
||||
}
|
||||
}
|
||||
data, err := io.ReadAll(body)
|
||||
_ = body.Close()
|
||||
if err != nil {
|
||||
return nil, errors.New("cannot read API request body for signing")
|
||||
}
|
||||
if req.GetBody == nil {
|
||||
req.Body = io.NopCloser(bytes.NewReader(data))
|
||||
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAPIRetriesOnlyBodyFreeReads(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, method string
|
||||
body io.Reader
|
||||
status int
|
||||
calls int
|
||||
}{
|
||||
{"get 503", http.MethodGet, nil, http.StatusServiceUnavailable, 3},
|
||||
{"head 502", http.MethodHead, nil, http.StatusBadGateway, 3},
|
||||
{"get 504", http.MethodGet, http.NoBody, http.StatusGatewayTimeout, 3},
|
||||
{"get 500", http.MethodGet, nil, http.StatusInternalServerError, 1},
|
||||
{"get with body", http.MethodGet, strings.NewReader(`{}`), http.StatusServiceUnavailable, 1},
|
||||
{"post", http.MethodPost, nil, http.StatusServiceUnavailable, 1},
|
||||
{"delete", http.MethodDelete, nil, http.StatusServiceUnavailable, 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(tc.status)
|
||||
}))
|
||||
defer server.Close()
|
||||
req, err := http.NewRequest(tc.method, server.URL+"/api/v1/user", tc.body)
|
||||
require.NoError(t, err)
|
||||
resp, err := New(server.URL, "", false).API().Do(req)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, tc.status, resp.StatusCode)
|
||||
require.Equal(t, tc.calls, calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRetryRecoversAndStopsOnCancellation(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := New(server.URL, "", false)
|
||||
resp, err := client.API().Get(server.URL + "/api/v1/user")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 2, calls)
|
||||
|
||||
calls = 0
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
unavailable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
cancel()
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer unavailable.Close()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, unavailable.URL+"/api/v1/user", nil)
|
||||
require.NoError(t, err)
|
||||
_, err = New(unavailable.URL, "", false).API().Do(req)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
require.Equal(t, 1, calls)
|
||||
}
|
||||
|
||||
func TestAPIAuthenticatesEveryAttempt(t *testing.T) {
|
||||
type seen struct{ authorization, sudo string }
|
||||
requests := []seen{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests = append(requests, seen{r.Header.Get("Authorization"), r.Header.Get("Sudo")})
|
||||
if len(requests) == 1 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := New(server.URL, "secret", false)
|
||||
client.Sudo = "bob"
|
||||
resp, err := client.API().Get(server.URL + "/api/v1/user")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, []seen{{"token secret", "bob"}, {"token secret", "bob"}}, requests)
|
||||
}
|
||||
|
||||
type bodySigner struct{ bodies []string }
|
||||
|
||||
func (s *bodySigner) SignRequest(r *http.Request, contents []byte) error {
|
||||
s.bodies = append(s.bodies, string(contents))
|
||||
r.Header.Set("Signature", "signed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAPISignsRequestBodies(t *testing.T) {
|
||||
received := []string{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "signed", r.Header.Get("Signature"))
|
||||
require.Empty(t, r.Header.Get("Authorization"))
|
||||
data, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
received = append(received, string(data))
|
||||
}))
|
||||
defer server.Close()
|
||||
signer := &bodySigner{}
|
||||
client := New(server.URL, "", false)
|
||||
client.Signer = signer
|
||||
for _, body := range []io.Reader{strings.NewReader(`{"replayable":true}`), io.MultiReader(strings.NewReader(`{"streamed":true}`)), nil} {
|
||||
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/user/keys", body)
|
||||
require.NoError(t, err)
|
||||
resp, err := client.API().Do(req)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
}
|
||||
require.Equal(t, []string{`{"replayable":true}`, `{"streamed":true}`, ""}, signer.bodies)
|
||||
require.Equal(t, signer.bodies, received)
|
||||
}
|
||||
|
||||
func TestAPINeverFollowsRedirects(t *testing.T) {
|
||||
leaked := false
|
||||
outside := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { leaked = true }))
|
||||
defer outside.Close()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, outside.URL, http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := New(server.URL, "secret", false)
|
||||
client.HTTP = server.Client()
|
||||
resp, err := client.API().Get(server.URL + "/api/v1/user")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusFound, resp.StatusCode)
|
||||
require.False(t, leaked)
|
||||
}
|
||||
|
||||
func TestCaptureErrorsSanitizesAndKeepsBodyReadable(t *testing.T) {
|
||||
long := `{"message":"` + strings.Repeat("y", 5000) + `"}`
|
||||
for _, tc := range []struct {
|
||||
name, body, message string
|
||||
status int
|
||||
}{
|
||||
{"message", `{"message":"branch exists"}`, "branch exists", http.StatusUnprocessableEntity},
|
||||
{"oversized", long, "", http.StatusInternalServerError},
|
||||
{"redirect", ``, "", http.StatusMovedPermanently},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
ctx, failure := CaptureErrors(context.Background())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, server.URL+"/api/v1/repos/a/b?token=secret", nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := New(server.URL, "secret", false).API().Do(req)
|
||||
require.NoError(t, err)
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, tc.body, string(data))
|
||||
require.Equal(t, &APIError{Status: tc.status, Method: http.MethodPatch, Endpoint: "/api/v1/repos/a/b", Message: tc.message}, failure())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureErrorsIgnoresSuccessAndUncapturedRequests(t *testing.T) {
|
||||
status := http.StatusOK
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(`{"message":"ignored"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := New(server.URL, "", false)
|
||||
ctx, failure := CaptureErrors(context.Background())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/api/v1/user", nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := client.API().Do(req)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Nil(t, failure())
|
||||
|
||||
status = http.StatusNotFound
|
||||
resp, err = client.API().Get(server.URL + "/api/v1/user")
|
||||
require.NoError(t, err)
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.JSONEq(t, `{"message":"ignored"}`, string(data))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package transport provides scoped, cancellable Gitea API requests.
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.dev/tea/internal/version"
|
||||
)
|
||||
|
||||
// RequestSigner attaches HTTP signature headers for SSH/HTTPSign authentication.
|
||||
type RequestSigner interface {
|
||||
SignRequest(r *http.Request, contents []byte) error
|
||||
}
|
||||
|
||||
// APIError reports request failure without including credentials or raw response bodies.
|
||||
// Message holds only the sanitized "message" field of a JSON error body.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Method, Endpoint, Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
var text string
|
||||
switch e.Status {
|
||||
case 401:
|
||||
text = "authentication rejected; run tea auth login"
|
||||
case 403:
|
||||
text = "permission denied by the Gitea instance"
|
||||
case 404:
|
||||
text = "resource or API endpoint unavailable on the selected Gitea instance"
|
||||
case 429:
|
||||
text = "Gitea rate limit exceeded; try again later"
|
||||
default:
|
||||
text = fmt.Sprintf("Gitea API returned HTTP %d for %s %s", e.Status, e.Method, e.Endpoint)
|
||||
}
|
||||
if e.Message != "" {
|
||||
text += ": " + e.Message
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const (
|
||||
maxErrorBody = 4 << 10
|
||||
maxErrorMessage = 300
|
||||
)
|
||||
|
||||
// newAPIError keeps only a bounded, printable JSON "message" field of a failed response body.
|
||||
// Authentication failures and messages echoing the token keep no server text.
|
||||
func newAPIError(status int, method, endpoint, token string, data []byte) *APIError {
|
||||
failure := &APIError{Status: status, Method: method, Endpoint: endpoint}
|
||||
if status == http.StatusUnauthorized {
|
||||
return failure
|
||||
}
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(data, &body) != nil || (token != "" && strings.Contains(body.Message, token)) {
|
||||
return failure
|
||||
}
|
||||
message := strings.Join(strings.FieldsFunc(body.Message, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) || unicode.Is(unicode.Cf, r) }), " ")
|
||||
if runes := []rune(message); len(runes) > maxErrorMessage {
|
||||
message = string(runes[:maxErrorMessage]) + "..."
|
||||
}
|
||||
failure.Message = message
|
||||
return failure
|
||||
}
|
||||
|
||||
// Client binds HTTP requests to one instance and credential.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
// Sudo asks the instance to perform requests as this user (administrators only).
|
||||
Sudo string
|
||||
Signer RequestSigner
|
||||
HTTP *http.Client
|
||||
Retries int
|
||||
}
|
||||
|
||||
// UserAgent identifies tea to instances, proxies, and server logs.
|
||||
var UserAgent = "tea/" + version.Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")"
|
||||
|
||||
type userAgentTransport struct{ base http.RoundTripper }
|
||||
|
||||
func (t userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// New constructs an instance-scoped client with no redirects. Only connection setup and the wait
|
||||
// for response headers are bounded, so slow but progressing transfers are not cut off.
|
||||
func New(instance, token string, insecure bool) *Client {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DialContext = (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
tr.TLSHandshakeTimeout = 10 * time.Second
|
||||
tr.ResponseHeaderTimeout = 120 * time.Second
|
||||
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecure} // user-controlled, instance-scoped
|
||||
return &Client{BaseURL: instance, Token: token, Retries: 2, HTTP: &http.Client{Transport: userAgentTransport{tr}, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
|
||||
// WithTimeout returns an independent HTTP policy while sharing the connection transport.
|
||||
func (c *Client) WithTimeout(timeout time.Duration) *Client {
|
||||
clone := *c
|
||||
httpClient := *c.HTTP
|
||||
httpClient.Timeout = timeout
|
||||
clone.HTTP = &httpClient
|
||||
return &clone
|
||||
}
|
||||
|
||||
// scoped parses raw for this instance, rejecting userinfo, fragments, dot
|
||||
// segments, and backslashes. kind names the reference in error messages.
|
||||
func (c *Client) scoped(raw, kind string) (*url.URL, *url.URL, error) {
|
||||
base, err := url.Parse(c.BaseURL)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("invalid instance URL")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.User != nil || u.Fragment != "" {
|
||||
return nil, nil, errors.New("invalid " + kind)
|
||||
}
|
||||
for segment := range strings.SplitSeq(u.Path, "/") {
|
||||
if segment == ".." || segment == "." {
|
||||
return nil, nil, errors.New(kind + " cannot contain path traversal")
|
||||
}
|
||||
}
|
||||
if strings.Contains(u.Path, "\\") {
|
||||
return nil, nil, errors.New(kind + " cannot contain backslashes")
|
||||
}
|
||||
return base, u, nil
|
||||
}
|
||||
|
||||
// within reports whether u uses the instance scheme and host and lies below its base path plus under.
|
||||
func within(base, u *url.URL, under string) bool {
|
||||
return u.Scheme == base.Scheme && strings.EqualFold(u.Host, base.Host) && strings.HasPrefix(u.Path, strings.TrimRight(base.Path, "/")+under)
|
||||
}
|
||||
|
||||
// URL accepts only endpoints within the selected installation API.
|
||||
func (c *Client) URL(endpoint string) (string, error) {
|
||||
base, u, err := c.scoped(endpoint, "API endpoint")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.IsAbs() || u.Host != "" {
|
||||
if !within(base, u, "/api/") {
|
||||
return "", errors.New("API URL must belong to the selected instance and its API base path")
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
endpointPath := u.Path
|
||||
escapedEndpointPath := u.EscapedPath()
|
||||
if !strings.HasPrefix(endpointPath, "/api/") {
|
||||
endpointPath = "/api/v1/" + strings.TrimLeft(endpointPath, "/")
|
||||
escapedEndpointPath = "/api/v1/" + strings.TrimLeft(escapedEndpointPath, "/")
|
||||
}
|
||||
base.RawPath = strings.TrimRight(base.EscapedPath(), "/") + escapedEndpointPath
|
||||
base.Path = strings.TrimRight(base.Path, "/") + endpointPath
|
||||
base.RawQuery = u.RawQuery
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
// RequestError keeps the network or TLS cause of a failed request. The cause of a *url.Error
|
||||
// omits the request URL, whose query may carry secrets.
|
||||
func RequestError(ctx context.Context, action string, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if urlErr, ok := errors.AsType[*url.Error](err); ok {
|
||||
return fmt.Errorf("%s: %w", action, urlErr.Err)
|
||||
}
|
||||
return errors.New(action + "; check connectivity and TLS settings")
|
||||
}
|
||||
|
||||
// Do executes a cancellable request, retrying only safe body-free reads.
|
||||
// A response outside 2xx is closed and returned as an *APIError.
|
||||
func (c *Client) Do(ctx context.Context, method, endpoint string, body io.Reader, headers http.Header) (*http.Response, error) {
|
||||
ctx, failure := CaptureErrors(ctx)
|
||||
resp, err := c.Send(ctx, method, endpoint, body, headers)
|
||||
if err != nil || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
||||
return resp, err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return nil, failure()
|
||||
}
|
||||
|
||||
// Send is Do without converting responses outside 2xx into errors.
|
||||
func (c *Client) Send(ctx context.Context, method, endpoint string, body io.Reader, headers http.Header) (*http.Response, error) {
|
||||
requestURL, err := c.URL(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, requestURL, body)
|
||||
if err != nil {
|
||||
return nil, errors.New("cannot construct API request")
|
||||
}
|
||||
req.Header = headers.Clone()
|
||||
if req.Header == nil {
|
||||
req.Header = http.Header{}
|
||||
}
|
||||
if body != nil && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if req.Header.Get("Accept") == "" {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
resp, err := c.API().Do(req)
|
||||
if err != nil {
|
||||
return nil, RequestError(ctx, "API request failed", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/version"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInstanceScopeAndRedirect(t *testing.T) {
|
||||
leaked := false
|
||||
outside := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leaked = true; w.WriteHeader(200) }))
|
||||
defer outside.Close()
|
||||
selected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/base/api/v1/user", r.URL.Path)
|
||||
require.Equal(t, "token secret", r.Header.Get("Authorization"))
|
||||
http.Redirect(w, r, outside.URL, http.StatusFound)
|
||||
}))
|
||||
defer selected.Close()
|
||||
client := New(selected.URL+"/base", "secret", false)
|
||||
_, err := client.Do(context.Background(), http.MethodGet, "user", nil, nil)
|
||||
require.Error(t, err)
|
||||
require.False(t, leaked)
|
||||
for _, endpoint := range []string{outside.URL + "/api/v1/user", strings.Replace(selected.URL, "http:", "https:", 1) + "/base/api/v1/user", selected.URL + "/api/v1/user", "../user", selected.URL + "/base/api/v1/../../other", "//other.example/api/v1/user"} {
|
||||
_, err := client.URL(endpoint)
|
||||
require.Error(t, err, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
client := New("https://example.org", "", false)
|
||||
_, err := client.Do(ctx, http.MethodGet, "user", nil, nil)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestURLPreservesEscapedSegments(t *testing.T) {
|
||||
client := New("https://example.test/base%20path", "", false)
|
||||
resolved, err := client.URL("repos/alice/project/releases/tags/release%2Fv1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.test/base%20path/api/v1/repos/alice/project/releases/tags/release%2Fv1", resolved)
|
||||
}
|
||||
|
||||
func TestAPIErrorIncludesOnlySanitizedServerMessage(t *testing.T) {
|
||||
const merge = "Gitea API returned HTTP %d for POST /api/v1/repos/a/b/pulls/1/merge"
|
||||
long := strings.Repeat("x", 400)
|
||||
for _, tc := range []struct {
|
||||
name, body, want string
|
||||
status int
|
||||
}{
|
||||
{"merge refusal", `{"message":"Please try again later","url":"https://leak.example/?key=abc"}`, fmt.Sprintf(merge, 405) + ": Please try again later", 405},
|
||||
{"token scope", `{"message":"token does not have at least one of required scope(s): [read:user]"}`, "permission denied by the Gitea instance: token does not have at least one of required scope(s): [read:user]", 403},
|
||||
{"control characters", "{\"message\":\"bad\\u001b[31m\\nnews\\u202e\"}", fmt.Sprintf(merge, 422) + ": bad [31m news", 422},
|
||||
{"capped", `{"message":"` + long + `"}`, fmt.Sprintf(merge, 500) + ": " + long[:300] + "...", 500},
|
||||
{"not json", `<html>key=abc</html>`, fmt.Sprintf(merge, 500), 500},
|
||||
{"other fields", `{"errors":["abc"]}`, fmt.Sprintf(merge, 500), 500},
|
||||
{"oversized body", `{"message":"` + strings.Repeat("y", 5000) + `"}`, fmt.Sprintf(merge, 500), 500},
|
||||
{"token echo", `{"message":"bad token secret"}`, fmt.Sprintf(merge, 400), 400},
|
||||
{"authentication", `{"message":"user alice not found"}`, "authentication rejected; run tea auth login", 401},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
_, err := New(server.URL, "secret", false).Do(context.Background(), http.MethodPost, "repos/a/b/pulls/1/merge?key=abc", strings.NewReader(`{}`), nil)
|
||||
var apiErr *APIError
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, tc.status, apiErr.Status)
|
||||
require.Equal(t, tc.want, err.Error())
|
||||
require.NotContains(t, err.Error(), "abc")
|
||||
require.NotContains(t, err.Error(), "secret")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSudoHeaderOnAPIAndDownloadRequests(t *testing.T) {
|
||||
seen := []string{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = append(seen, r.URL.Path+" "+r.Header.Get("Sudo"))
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := New(server.URL, "secret", false)
|
||||
for _, sudo := range []string{"", "bob"} {
|
||||
client.Sudo = sudo
|
||||
response, err := client.Do(context.Background(), http.MethodGet, "user", nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, response.Body.Close())
|
||||
}
|
||||
response, err := client.Download(context.Background(), server.URL+"/file")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, response.Body.Close())
|
||||
require.Equal(t, []string{"/api/v1/user ", "/api/v1/user bob", "/file bob"}, seen)
|
||||
}
|
||||
|
||||
func TestTransportBoundsOnlySetupAndSendsUserAgent(t *testing.T) {
|
||||
agents := []string{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
agents = append(agents, r.Header.Get("User-Agent"))
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := New(server.URL, "secret", false)
|
||||
// A whole-request timeout would cut off long uploads and downloads that are still progressing.
|
||||
require.Zero(t, client.HTTP.Timeout)
|
||||
tr := client.HTTP.Transport.(userAgentTransport).base.(*http.Transport)
|
||||
require.Equal(t, 120*time.Second, tr.ResponseHeaderTimeout)
|
||||
require.Equal(t, 10*time.Second, tr.TLSHandshakeTimeout)
|
||||
for _, open := range []func() (*http.Response, error){
|
||||
func() (*http.Response, error) {
|
||||
return client.Do(context.Background(), http.MethodGet, "user", nil, nil)
|
||||
},
|
||||
func() (*http.Response, error) { return client.Download(context.Background(), server.URL+"/file") },
|
||||
} {
|
||||
response, err := open()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, response.Body.Close())
|
||||
}
|
||||
want := "tea/" + version.Version + " (" + runtime.GOOS + "/" + runtime.GOARCH + ")"
|
||||
require.Equal(t, []string{want, want}, agents)
|
||||
}
|
||||
|
||||
func TestNetworkErrorsKeepCauseWithoutURL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
server.Close()
|
||||
client := New(server.URL, "secret", false)
|
||||
_, err := client.Do(context.Background(), http.MethodPost, "user?token=query-secret", strings.NewReader(`{}`), nil)
|
||||
var opErr *net.OpError
|
||||
require.ErrorAs(t, err, &opErr)
|
||||
require.ErrorContains(t, err, "API request failed: ")
|
||||
require.NotContains(t, err.Error(), "query-secret")
|
||||
_, err = client.Download(context.Background(), server.URL+"/file?signature=query-secret")
|
||||
require.ErrorAs(t, err, &opErr)
|
||||
require.ErrorContains(t, err, "download failed: ")
|
||||
require.NotContains(t, err.Error(), "query-secret")
|
||||
}
|
||||
|
||||
func TestDebugLogsRequestsWithoutSecrets(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
defer server.Close()
|
||||
var log bytes.Buffer
|
||||
client := New(server.URL, "secret-token", false).Debug(&log)
|
||||
_, err := client.Do(context.Background(), http.MethodGet, "repos/a/b?page=2&token=query-secret", nil, nil)
|
||||
require.Error(t, err)
|
||||
require.Regexp(t, `^\[debug\] GET `+regexp.QuoteMeta(server.URL)+`/api/v1/repos/a/b\?page=REDACTED&token=REDACTED -> 418 I'm a teapot \(\d+(\.\d+)?[µm]?s\)\n$`, log.String())
|
||||
require.NotContains(t, log.String(), "secret")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type debugTransport struct {
|
||||
base http.RoundTripper
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
func (t debugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
start := time.Now()
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
var outcome string
|
||||
if err != nil {
|
||||
outcome = "error: " + err.Error()
|
||||
} else {
|
||||
outcome = resp.Status
|
||||
}
|
||||
_, _ = fmt.Fprintf(t.out, "[debug] %s %s -> %s (%s)\n", req.Method, redactURL(req.URL), outcome, time.Since(start).Round(time.Millisecond))
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// redactURL drops userinfo and query values, which can carry tokens or signatures.
|
||||
func redactURL(u *url.URL) string {
|
||||
clean := *u
|
||||
clean.User = nil
|
||||
if clean.RawQuery != "" {
|
||||
query := clean.Query()
|
||||
for key := range query {
|
||||
query[key] = []string{"REDACTED"}
|
||||
}
|
||||
clean.RawQuery = query.Encode()
|
||||
}
|
||||
return clean.String()
|
||||
}
|
||||
|
||||
// Debug returns a copy of c that logs the method, redacted URL, status, and duration of each
|
||||
// request to w. Headers and bodies are never logged.
|
||||
func (c *Client) Debug(w io.Writer) *Client {
|
||||
clone := *c
|
||||
httpClient := *c.HTTP
|
||||
base := httpClient.Transport
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
httpClient.Transport = debugTransport{base: base, out: w}
|
||||
clone.HTTP = &httpClient
|
||||
return &clone
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Download opens an instance-scoped file URL without following redirects.
|
||||
func (c *Client) Download(ctx context.Context, raw string) (*http.Response, error) {
|
||||
_, target, err := c.downloadTarget(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.get(ctx, target, false)
|
||||
}
|
||||
|
||||
// downloadTarget returns the parsed instance URL and raw, which must lie within its base path.
|
||||
func (c *Client) downloadTarget(raw string) (*url.URL, *url.URL, error) {
|
||||
base, target, err := c.scoped(raw, "download URL")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !within(base, target, "/") {
|
||||
return nil, nil, errors.New("download URL must belong to the selected instance and base path")
|
||||
}
|
||||
return base, target, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, target *url.URL, allowRedirect bool) (*http.Response, error) {
|
||||
ctx, failure := CaptureErrors(ctx)
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("cannot construct download request")
|
||||
}
|
||||
response, err := c.API().Do(request)
|
||||
if err != nil {
|
||||
return nil, RequestError(ctx, "download failed", err)
|
||||
}
|
||||
if (allowRedirect && redirect(response.StatusCode)) || (response.StatusCode >= 200 && response.StatusCode < 300) {
|
||||
return response, nil
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
return nil, failure()
|
||||
}
|
||||
|
||||
func redirect(status int) bool {
|
||||
switch status {
|
||||
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DownloadSigned opens an instance-scoped file URL and follows at most one redirect without
|
||||
// credentials, such as Gitea's redirect to signed object-storage URLs. The redirect target may be
|
||||
// another host, but must use HTTPS or the instance's scheme. Automatic redirects remain disabled,
|
||||
// including on an injected HTTP client.
|
||||
func (c *Client) DownloadSigned(ctx context.Context, raw string) (*http.Response, error) {
|
||||
base, target, err := c.downloadTarget(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := c.get(ctx, target, true)
|
||||
if err != nil || !redirect(response.StatusCode) {
|
||||
return response, err
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
location, err := response.Location()
|
||||
if err != nil {
|
||||
return nil, errors.New("download returned an invalid redirect")
|
||||
}
|
||||
if location.Scheme != "https" && location.Scheme != base.Scheme {
|
||||
return nil, errors.New("download redirect must use HTTPS or the instance's scheme")
|
||||
}
|
||||
unsigned := *c
|
||||
unsigned.Token, unsigned.Signer, unsigned.Sudo = "", nil, ""
|
||||
return unsigned.get(ctx, location, false)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDownloadScopeAndRedirects(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
require.Equal(t, "token sensitive", r.Header.Get("Authorization"))
|
||||
if r.URL.Path == "/base/redirect" {
|
||||
http.Redirect(w, r, "/outside", http.StatusFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("binary bytes"))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := New(server.URL+"/base", "sensitive", false)
|
||||
// Even a substituted HTTP client cannot re-enable automatic redirects.
|
||||
client.HTTP = server.Client()
|
||||
for _, raw := range []string{server.URL + "/outside", server.URL + "/base/../outside", server.URL + "/base/%2e%2e/outside", "https://example.test/base/file"} {
|
||||
_, err := client.Download(context.Background(), raw)
|
||||
require.Error(t, err)
|
||||
}
|
||||
require.Zero(t, requests)
|
||||
_, err := client.Download(context.Background(), server.URL+"/base/redirect")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 1, requests)
|
||||
response, err := client.Download(context.Background(), server.URL+"/base/file")
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, response.Body.Close())
|
||||
require.Equal(t, "binary bytes", string(body))
|
||||
}
|
||||
|
||||
func TestSignedDownloadsFollowOneRedirectWithoutCredentials(t *testing.T) {
|
||||
storage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Empty(t, r.Header.Get("Authorization"))
|
||||
require.Empty(t, r.Header.Get("Sudo"))
|
||||
_, _ = w.Write([]byte("archive"))
|
||||
}))
|
||||
defer storage.Close()
|
||||
for _, tc := range []struct {
|
||||
destination string
|
||||
status int
|
||||
ok bool
|
||||
requests int
|
||||
}{
|
||||
{"/base/signed?signature=private-value", http.StatusFound, true, 1},
|
||||
{"/outside?signature=private-value", http.StatusFound, true, 1},
|
||||
{storage.URL + "/bucket/file?signature=private-value", http.StatusSeeOther, true, 0},
|
||||
{storage.URL + "/bucket/file?signature=private-value", http.StatusTemporaryRedirect, true, 0},
|
||||
{"/base/again", http.StatusFound, false, 1},
|
||||
{"ftp://storage.example.test/file?signature=private-value", http.StatusFound, false, 0},
|
||||
} {
|
||||
t.Run(tc.destination, func(t *testing.T) {
|
||||
signedRequests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/base/archive" {
|
||||
require.Equal(t, "token sensitive", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "bob", r.Header.Get("Sudo"))
|
||||
http.Redirect(w, r, tc.destination, tc.status)
|
||||
return
|
||||
}
|
||||
signedRequests++
|
||||
require.Empty(t, r.Header.Get("Authorization"))
|
||||
require.Empty(t, r.Header.Get("Sudo"))
|
||||
if r.URL.Path == "/base/again" {
|
||||
http.Redirect(w, r, "/base/signed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("archive"))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := New(server.URL+"/base", "sensitive", false)
|
||||
client.Sudo = "bob"
|
||||
client.HTTP = server.Client() // The implementation must still suppress automatic redirects.
|
||||
response, err := client.DownloadSigned(context.Background(), server.URL+"/base/archive")
|
||||
require.Equal(t, tc.requests, signedRequests)
|
||||
if !tc.ok {
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), "private-value")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
data, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "archive", string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSigner struct{ calls int }
|
||||
|
||||
func (s *fakeSigner) SignRequest(r *http.Request, _ []byte) error {
|
||||
s.calls++
|
||||
r.Header.Set("Signature", "signed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDownloadsSignOnlyInstanceRequest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/base/archive" {
|
||||
require.Equal(t, "signed", r.Header.Get("Signature"))
|
||||
http.Redirect(w, r, "/base/signed?signature=private-value", http.StatusFound)
|
||||
return
|
||||
}
|
||||
require.Empty(t, r.Header.Get("Signature"))
|
||||
require.Empty(t, r.Header.Get("Authorization"))
|
||||
_, _ = w.Write([]byte("archive"))
|
||||
}))
|
||||
defer server.Close()
|
||||
signer := &fakeSigner{}
|
||||
client := New(server.URL+"/base", "", false)
|
||||
client.HTTP = server.Client()
|
||||
client.Signer = signer
|
||||
response, err := client.DownloadSigned(context.Background(), server.URL+"/base/archive")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, response.Body.Close())
|
||||
require.Equal(t, 1, signer.calls)
|
||||
}
|
||||
|
||||
func TestURLAndDownloadShareScopeRejections(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests++ }))
|
||||
defer server.Close()
|
||||
client := New(server.URL+"/base", "sensitive", false)
|
||||
client.HTTP = server.Client()
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
for _, raw := range []string{
|
||||
"http://user:pass@" + host + "/base/api/v1/user", server.URL + "/base/api/v1/user#fragment", server.URL + "/base/api/v1/a\\b",
|
||||
server.URL + "/base/api/v1/./user", server.URL + "/base/api/v1/%2e%2e/user", server.URL + "/basement/api/v1/user", "//" + host + "/base/api/v1/user",
|
||||
} {
|
||||
_, err := client.URL(raw)
|
||||
require.Error(t, err, raw)
|
||||
_, err = client.Download(context.Background(), raw)
|
||||
require.Error(t, err, raw)
|
||||
}
|
||||
require.Zero(t, requests)
|
||||
}
|
||||
|
||||
func TestSignedDownloadRejectsInvalidInstanceURL(t *testing.T) {
|
||||
_, err := New("http://[invalid", "sensitive", false).DownloadSigned(context.Background(), "http://example.test/file")
|
||||
require.ErrorContains(t, err, "invalid instance URL")
|
||||
}
|
||||
Reference in new issue
Block a user