fix: address verified review findings (api-transport-download-1)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 17:38:51 +02:00
1 parent 45c564811e
commit 6b847058eb
11 files changed
+192 -11

No files matched your search

+20 -2
View File
@@ -14,7 +14,8 @@ import (
)
// 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
// header to every attempt, retries only body-free reads, follows redirects (a renamed or
// transferred repository) only within this instance's API, and records
// the sanitized error of a failed response for CaptureErrors.
func (c *Client) API() *http.Client {
base := c.HTTP.Transport
@@ -27,12 +28,29 @@ func (c *Client) API() *http.Client {
}
return &http.Client{
Transport: chain,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
CheckRedirect: c.checkRedirect,
Jar: c.HTTP.Jar,
Timeout: c.HTTP.Timeout,
}
}
// maxRedirects bounds the redirects followed for one request.
const maxRedirects = 5
// checkRedirect follows a redirect when it stays within the API of this instance and keeps the
// method, as the 301 of a renamed repository does for a read. Anything else returns the 3xx
// response, so a credential is never sent elsewhere and a POST that Go would turn into a GET is
// not silently dropped.
func (c *Client) checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) > maxRedirects || req.Method != via[0].Method {
return http.ErrUseLastResponse
}
if _, err := c.URL(req.URL.String()); err != nil {
return http.ErrUseLastResponse
}
return nil
}
type capturedErrorKey struct{}
// CaptureErrors returns a context under which API clients record the sanitized error of each
+40
View File
@@ -206,3 +206,43 @@ func TestCaptureErrorsIgnoresSuccessAndUncapturedRequests(t *testing.T) {
require.NoError(t, resp.Body.Close())
require.JSONEq(t, `{"message":"ignored"}`, string(data))
}
func TestAPIFollowsRedirectsWithinTheInstanceAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/old/issues":
http.Redirect(w, r, "/api/v1/repos/alice/new/issues", http.StatusMovedPermanently)
case "/api/v1/loop":
http.Redirect(w, r, "/api/v1/loop", http.StatusMovedPermanently)
case "/web":
http.Redirect(w, r, "/user/login", http.StatusFound)
default:
require.Equal(t, "token secret", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(r.Method + " " + r.URL.Path))
}
}))
defer server.Close()
client := New(server.URL, "secret", false)
for _, tc := range []struct {
method, path, body string
status int
}{
{http.MethodGet, "/api/v1/repos/alice/old/issues", "GET /api/v1/repos/alice/new/issues", http.StatusOK},
{http.MethodHead, "/api/v1/repos/alice/old/issues", "", http.StatusOK},
{http.MethodPost, "/api/v1/repos/alice/old/issues", "", http.StatusMovedPermanently},
{http.MethodGet, "/web", "", http.StatusFound},
{http.MethodGet, "/api/v1/loop", "", http.StatusMovedPermanently},
} {
req, err := http.NewRequest(tc.method, server.URL+tc.path, nil)
require.NoError(t, err)
resp, err := client.API().Do(req)
require.NoError(t, err, tc.path)
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, tc.status, resp.StatusCode, tc.method+" "+tc.path)
if tc.status == http.StatusOK {
require.Equal(t, tc.body, string(data))
}
}
}
+19
View File
@@ -213,3 +213,22 @@ func TestDebugRedactsOnlySensitiveQueryValues(t *testing.T) {
require.Equal(t, tc.redacted, sensitiveQuery(tc.path, tc.key), "%s %s", tc.path, tc.key)
}
}
func TestDebugStripsControlsFromTheStatusLine(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
conn, rw, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer conn.Close()
_, _ = rw.WriteString("HTTP/1.1 200 OK\x1b]0;PWNED\x07\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
_ = rw.Flush()
}))
defer server.Close()
var log bytes.Buffer
_, err := New(server.URL, "t", false).Debug(&log).Do(context.Background(), http.MethodGet, "version", nil, nil)
require.NoError(t, err)
require.NotContains(t, log.String(), "\x1b")
require.NotContains(t, log.String(), "\x07")
require.Contains(t, log.String(), "-> 200 OK")
}
+8 -1
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"strings"
"time"
"unicode"
)
type debugTransport struct {
@@ -25,7 +26,7 @@ func (t debugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if err != nil {
outcome = "error: " + err.Error()
} else {
outcome = resp.Status
outcome = plainText(resp.Status)
message = failureMessage(req, resp)
}
line := fmt.Sprintf("[debug] %s %s -> %s (%s)", req.Method, redactURL(req.URL), outcome, time.Since(start).Round(time.Millisecond))
@@ -36,6 +37,12 @@ func (t debugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return resp, err
}
// plainText drops control and format characters, which a server can put in the reason phrase
// to drive the terminal the log is printed to.
func plainText(s string) string {
return strings.Join(strings.FieldsFunc(s, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) || unicode.Is(unicode.Cf, r) }), " ")
}
// failureMessage returns the sanitized server message of a failed response and keeps its body
// readable for the caller.
func failureMessage(req *http.Request, resp *http.Response) string {