Take the signature, the expiry and the URL scheme off the image request (closes #314)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
This commit is contained in:
@@ -51,12 +51,12 @@ func TestDialContextReplacesOnlyTheDialer(t *testing.T) {
|
||||
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||
}
|
||||
|
||||
_, err := f.Fetch(testContext(t), "http://127.0.0.1/image")
|
||||
_, err := f.Fetch(testContext(t), imageSourceOn("127.0.0.1"))
|
||||
if !errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Errorf("Fetch(loopback URL) error = %v, want ErrSSRFBlocked", err)
|
||||
}
|
||||
|
||||
_, err = f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
||||
_, err = f.Fetch(testContext(t), upstreamSource("/redirect/private"))
|
||||
if !errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Errorf("Fetch(/redirect/private) error = %v, want ErrSSRFBlocked", err)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
|
||||
@@ -29,9 +31,9 @@ const imagePayload = "fake-jpeg-bytes"
|
||||
// would mean SSRF protection let a forbidden target reach the transport.
|
||||
var errUnexpectedDial = errors.New("unexpected dial target")
|
||||
|
||||
// upstreamURL builds a fetch URL on the fake public host for the given path.
|
||||
func upstreamURL(path string) string {
|
||||
return "http://" + testPublicHost + path
|
||||
// upstreamSource returns the source at path on the fake public host.
|
||||
func upstreamSource(path string) imagerequest.Source {
|
||||
return imagerequest.Source{Host: testPublicHost, Path: path}
|
||||
}
|
||||
|
||||
// recordingDialer records every address the transport asks it to dial and
|
||||
@@ -158,7 +160,7 @@ func testContext(t *testing.T) context.Context {
|
||||
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
||||
t.Helper()
|
||||
|
||||
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||
res, err := f.Fetch(testContext(t), upstreamSource(path))
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch(%s) error = %v", path, err)
|
||||
}
|
||||
@@ -170,7 +172,7 @@ func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
||||
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
|
||||
t.Helper()
|
||||
|
||||
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||
res, err := f.Fetch(testContext(t), upstreamSource(path))
|
||||
if err == nil {
|
||||
_ = res.Content.Close()
|
||||
|
||||
@@ -222,7 +224,7 @@ func TestFetchRedirectToPrivateIPBlocked(t *testing.T) {
|
||||
srv := startUpstream(t)
|
||||
f, dialer := newServerFetcher(t, srv, nil)
|
||||
|
||||
_, err := f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
||||
_, err := f.Fetch(testContext(t), upstreamSource("/redirect/private"))
|
||||
if !errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
|
||||
}
|
||||
@@ -590,3 +592,53 @@ func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
|
||||
t.Errorf("public target was SSRF-blocked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchSchemeFollowsAllowHTTP checks that the fetcher fetches a source
|
||||
// over https, on port 443, unless AllowHTTP is set, and then over http, on
|
||||
// port 80. The test server speaks plain HTTP, so only the fetch over http
|
||||
// succeeds.
|
||||
func TestFetchSchemeFollowsAllowHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
allowHTTP bool
|
||||
wantAddr string
|
||||
}{
|
||||
{false, net.JoinHostPort(testPublicHost, "443")},
|
||||
{true, net.JoinHostPort(testPublicHost, "80")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("AllowHTTP %v", tt.allowHTTP), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := startUpstream(t)
|
||||
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
||||
|
||||
f := New(&Config{
|
||||
Timeout: 30 * time.Second,
|
||||
MaxResponseSize: 50 << 20,
|
||||
AllowHTTP: tt.allowHTTP,
|
||||
MaxConnectionsPerHost: 20,
|
||||
MaxConnections: 64,
|
||||
DialContext: dialer.dialContext,
|
||||
})
|
||||
|
||||
res, err := f.Fetch(testContext(t), upstreamSource("/image"))
|
||||
if err == nil {
|
||||
_ = res.Content.Close()
|
||||
}
|
||||
|
||||
dialed := dialer.dialedAddrs()
|
||||
t.Logf("Fetch(): error %v, dialed %v", err, dialed)
|
||||
|
||||
if served := err == nil; served != tt.allowHTTP {
|
||||
t.Errorf("served = %v, want %v", served, tt.allowHTTP)
|
||||
}
|
||||
|
||||
if len(dialed) != 1 || dialed[0] != tt.wantAddr {
|
||||
t.Errorf("dialed %v, want only %s", dialed, tt.wantAddr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// Fetcher configuration constants.
|
||||
@@ -91,8 +92,8 @@ var (
|
||||
|
||||
// Fetcher retrieves content from upstream origins.
|
||||
type Fetcher interface {
|
||||
// Fetch retrieves content from the given URL.
|
||||
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
||||
// Fetch retrieves the content of the source image.
|
||||
Fetch(ctx context.Context, source imagerequest.Source) (*FetchResult, error)
|
||||
}
|
||||
|
||||
// FetchResult contains the result of fetching from upstream.
|
||||
@@ -139,7 +140,8 @@ type Config struct {
|
||||
Timeout time.Duration
|
||||
// MaxResponseSize is the maximum allowed response body size.
|
||||
MaxResponseSize int64
|
||||
// AllowHTTP allows non-TLS connections (for testing only).
|
||||
// AllowHTTP makes the fetcher fetch sources over http instead of https,
|
||||
// and follow redirects to http (for testing only).
|
||||
AllowHTTP bool
|
||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||
MaxConnectionsPerHost int
|
||||
@@ -211,7 +213,7 @@ func New(config *Config) *HTTPFetcher {
|
||||
}
|
||||
|
||||
// Validate the redirect target
|
||||
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
||||
err := validateURL(req.Context(), req.URL, config.AllowHTTP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("redirect blocked: %w", err)
|
||||
}
|
||||
@@ -229,18 +231,22 @@ func New(config *Config) *HTTPFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch retrieves content from the given URL with SSRF protection. When
|
||||
// MaxConnections fetches are already in progress, it waits up to
|
||||
// ConnectionWaitTimeout for one to finish, then fails with
|
||||
// ErrTooManyConnections.
|
||||
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||
// Fetch retrieves the source image with SSRF protection, over https, or over
|
||||
// http when AllowHTTP is set. When MaxConnections fetches are already in
|
||||
// progress, it waits up to ConnectionWaitTimeout for one to finish, then
|
||||
// fails with ErrTooManyConnections.
|
||||
func (f *HTTPFetcher) Fetch(
|
||||
ctx context.Context, source imagerequest.Source,
|
||||
) (*FetchResult, error) {
|
||||
sourceURL := source.URL(f.config.AllowHTTP)
|
||||
|
||||
// Validate URL before making request
|
||||
err := validateURL(ctx, url, f.config.AllowHTTP)
|
||||
err := validateURL(ctx, sourceURL, f.config.AllowHTTP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
release, err := f.acquireConnection(ctx, extractHost(url))
|
||||
release, err := f.acquireConnection(ctx, source.Host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -254,14 +260,9 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
}
|
||||
}()
|
||||
|
||||
parsedURL, err := neturl.Parse(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
||||
}
|
||||
|
||||
req := &http.Request{
|
||||
Method: http.MethodGet,
|
||||
URL: parsedURL,
|
||||
URL: sourceURL,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
@@ -455,19 +456,14 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
||||
}
|
||||
|
||||
// validateURL checks if a URL is safe to fetch (not internal/private).
|
||||
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
||||
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
||||
func validateURL(ctx context.Context, fetchURL *neturl.URL, allowHTTP bool) error {
|
||||
if !allowHTTP && fetchURL.Scheme != "https" {
|
||||
return ErrUnsupportedScheme
|
||||
}
|
||||
|
||||
parsedURL, err := neturl.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidHost, err)
|
||||
}
|
||||
|
||||
// Hostname drops any port, and the brackets of an IPv6 address whether
|
||||
// or not a port follows them
|
||||
host := parsedURL.Hostname()
|
||||
host := fetchURL.Hostname()
|
||||
if host == "" {
|
||||
return ErrInvalidHost
|
||||
}
|
||||
@@ -493,25 +489,6 @@ func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractHost extracts the host from a URL string.
|
||||
func extractHost(rawURL string) string {
|
||||
// Simple extraction without full URL parsing
|
||||
url := rawURL
|
||||
if idx := strings.Index(url, "://"); idx != -1 {
|
||||
url = url[idx+3:]
|
||||
}
|
||||
|
||||
if idx := strings.Index(url, "/"); idx != -1 {
|
||||
url = url[:idx]
|
||||
}
|
||||
|
||||
if idx := strings.Index(url, "?"); idx != -1 {
|
||||
url = url[:idx]
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// isLocalhost checks if the host is localhost.
|
||||
func isLocalhost(host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// testHost is the hostname used by mock fetch tests.
|
||||
@@ -49,33 +52,6 @@ func TestIsAllowedContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/path", testHost},
|
||||
{"http://example.com:8080/path", "example.com:8080"},
|
||||
{"https://example.com", testHost},
|
||||
{"https://example.com?q=1", testHost},
|
||||
{"example.com/path", testHost},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.url, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := extractHost(tc.url)
|
||||
if got != tc.want {
|
||||
t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLocalhost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -188,7 +164,7 @@ func TestIsPrivateIPJudgesEmbeddedIPv4Address(t *testing.T) {
|
||||
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateURL(t.Context(), "http://example.com/path", false)
|
||||
err := validateURL(t.Context(), &url.URL{Scheme: "http", Host: testHost}, false)
|
||||
if !errors.Is(err, ErrUnsupportedScheme) {
|
||||
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
||||
}
|
||||
@@ -198,7 +174,8 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
|
||||
err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
|
||||
err := validateURL(t.Context(),
|
||||
&url.URL{Scheme: "http", Host: "nonexistent.invalid"}, true)
|
||||
// We expect a host resolution error, not ErrUnsupportedScheme.
|
||||
if errors.Is(err, ErrUnsupportedScheme) {
|
||||
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
||||
@@ -208,7 +185,7 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
||||
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateURL(t.Context(), "https://localhost/path", false)
|
||||
err := validateURL(t.Context(), &url.URL{Scheme: "https", Host: "localhost"}, false)
|
||||
if !errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
||||
}
|
||||
@@ -217,7 +194,7 @@ func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
||||
func TestValidateURL_EmptyHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateURL(t.Context(), "https:///path", false)
|
||||
err := validateURL(t.Context(), &url.URL{Scheme: "https"}, false)
|
||||
if !errors.Is(err, ErrInvalidHost) {
|
||||
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
||||
}
|
||||
@@ -232,7 +209,8 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
|
||||
|
||||
m := NewMock(mockFS)
|
||||
|
||||
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
|
||||
result, err := m.Fetch(context.Background(),
|
||||
imagerequest.Source{Host: testHost, Path: "/images/photo.jpg"})
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch() error = %v", err)
|
||||
}
|
||||
@@ -263,7 +241,8 @@ func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
||||
mockFS := fstest.MapFS{}
|
||||
m := NewMock(mockFS)
|
||||
|
||||
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
|
||||
_, err := m.Fetch(context.Background(),
|
||||
imagerequest.Source{Host: testHost, Path: "/missing.jpg"})
|
||||
if !errors.Is(err, ErrUpstreamError) {
|
||||
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||
}
|
||||
@@ -280,7 +259,7 @@ func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
|
||||
_, err := m.Fetch(ctx, imagerequest.Source{Host: testHost, Path: "/photo.jpg"})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
||||
}
|
||||
|
||||
@@ -8,14 +8,20 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// imageURLOnPort is the fake upstream's image route on testPublicHost at
|
||||
// imageSourceOn is the fake upstream's image route on host.
|
||||
func imageSourceOn(host string) imagerequest.Source {
|
||||
return imagerequest.Source{Host: host, Path: "/image"}
|
||||
}
|
||||
|
||||
// imageSourceOnPort is the fake upstream's image route on testPublicHost at
|
||||
// port. Each port is a different host to the per-host limit, while the test
|
||||
// dialer sends every port to the one test server.
|
||||
func imageURLOnPort(port int) string {
|
||||
return "http://" + net.JoinHostPort(testPublicHost, strconv.Itoa(port)) +
|
||||
"/image"
|
||||
func imageSourceOnPort(port int) imagerequest.Source {
|
||||
return imageSourceOn(net.JoinHostPort(testPublicHost, strconv.Itoa(port)))
|
||||
}
|
||||
|
||||
// TestFetchLimitsConnectionsToAllHostsTogether checks that MaxConnections
|
||||
@@ -39,12 +45,12 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
||||
f, _ := newServerFetcher(t, srv, cfg)
|
||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||
|
||||
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
||||
first, err := f.Fetch(testContext(t), imageSourceOnPort(81))
|
||||
if err != nil {
|
||||
t.Fatalf("first Fetch() error = %v", err)
|
||||
}
|
||||
|
||||
second, err := f.Fetch(testContext(t), imageURLOnPort(82))
|
||||
second, err := f.Fetch(testContext(t), imageSourceOnPort(82))
|
||||
if err != nil {
|
||||
t.Fatalf("second Fetch() error = %v", err)
|
||||
}
|
||||
@@ -53,7 +59,7 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
||||
|
||||
start := time.Now()
|
||||
|
||||
_, err = f.Fetch(testContext(t), imageURLOnPort(83))
|
||||
_, err = f.Fetch(testContext(t), imageSourceOnPort(83))
|
||||
if !errors.Is(err, ErrTooManyConnections) {
|
||||
t.Fatalf("third Fetch() error = %v, want ErrTooManyConnections", err)
|
||||
}
|
||||
@@ -72,7 +78,7 @@ func TestFetchLimitsConnectionsToAllHostsTogether(t *testing.T) {
|
||||
t.Fatalf("close first body: %v", err)
|
||||
}
|
||||
|
||||
third, err := f.Fetch(testContext(t), imageURLOnPort(83))
|
||||
third, err := f.Fetch(testContext(t), imageSourceOnPort(83))
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch() after a response was closed: error = %v", err)
|
||||
}
|
||||
@@ -99,7 +105,7 @@ func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
||||
|
||||
f, _ := newServerFetcher(t, srv, cfg)
|
||||
|
||||
first, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
||||
first, err := f.Fetch(testContext(t), imageSourceOnPort(81))
|
||||
if err != nil {
|
||||
t.Fatalf("first Fetch() error = %v", err)
|
||||
}
|
||||
@@ -109,7 +115,7 @@ func TestFetchFreesHostSlotWhenContextEndsWaitingForConnection(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = f.Fetch(ctx, imageURLOnPort(82))
|
||||
_, err = f.Fetch(ctx, imageSourceOnPort(82))
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("second Fetch() error = %v, want context.DeadlineExceeded", err)
|
||||
}
|
||||
@@ -134,7 +140,7 @@ func TestFetchRemovesIdleHostSemaphores(t *testing.T) {
|
||||
|
||||
for i := range 100 {
|
||||
wg.Go(func() {
|
||||
res, err := f.Fetch(ctx, imageURLOnPort(1+i%50))
|
||||
res, err := f.Fetch(ctx, imageSourceOnPort(1+i%50))
|
||||
if err != nil {
|
||||
t.Errorf("Fetch() error = %v", err)
|
||||
|
||||
@@ -173,12 +179,12 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
||||
f, _ := newServerFetcher(t, srv, cfg)
|
||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||
|
||||
open, err := f.Fetch(testContext(t), imageURLOnPort(81))
|
||||
open, err := f.Fetch(testContext(t), imageSourceOnPort(81))
|
||||
if err != nil {
|
||||
t.Fatalf("first Fetch() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = f.Fetch(testContext(t), imageURLOnPort(82))
|
||||
_, err = f.Fetch(testContext(t), imageSourceOnPort(82))
|
||||
if !errors.Is(err, ErrTooManyConnections) {
|
||||
t.Fatalf("Fetch() from another host: error = %v, "+
|
||||
"want ErrTooManyConnections", err)
|
||||
@@ -187,7 +193,7 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = f.Fetch(ctx, imageURLOnPort(81))
|
||||
_, err = f.Fetch(ctx, imageSourceOnPort(81))
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("Fetch() from the busy host: error = %v, "+
|
||||
"want context.DeadlineExceeded", err)
|
||||
@@ -199,7 +205,7 @@ func TestFetchRemovesHostSemaphoreWhenNoConnection(t *testing.T) {
|
||||
ctx, cancel = context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = f.Fetch(ctx, imageURLOnPort(83))
|
||||
_, err = f.Fetch(ctx, imageSourceOnPort(83))
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("Fetch() from a host with nothing open: error = %v, "+
|
||||
"want context.DeadlineExceeded", err)
|
||||
@@ -222,15 +228,15 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want error
|
||||
name string
|
||||
source imagerequest.Source
|
||||
want error
|
||||
}{
|
||||
{"upstream answers 500", upstreamURL("/status/500"), ErrUpstreamError},
|
||||
{"upstream sends HTML", upstreamURL("/html"), ErrInvalidContentType},
|
||||
{"upstream answers 500", upstreamSource("/status/500"), ErrUpstreamError},
|
||||
{"upstream sends HTML", upstreamSource("/html"), ErrInvalidContentType},
|
||||
// 198.51.100.7 (TEST-NET-2) passes the SSRF checks, and the test
|
||||
// dialer refuses every host but testPublicHost.
|
||||
{"connecting fails", "http://198.51.100.7/image", errUnexpectedDial},
|
||||
{"connecting fails", imageSourceOn("198.51.100.7"), errUnexpectedDial},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -249,7 +255,7 @@ func TestFetchReleasesConnectionOnError(t *testing.T) {
|
||||
f, _ := newServerFetcher(t, srv, cfg)
|
||||
f.connectionWaitTimeout = 100 * time.Millisecond
|
||||
|
||||
_, err := f.Fetch(testContext(t), tc.url)
|
||||
_, err := f.Fetch(testContext(t), tc.source)
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("Fetch() error = %v, want %v", err, tc.want)
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errEmptyURLPath is returned when a mock URL has no usable path.
|
||||
var errEmptyURLPath = errors.New("empty URL path")
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// MockFetcher implements Fetcher using an embedded filesystem.
|
||||
// Files are organized as: hostname/path/to/file.ext
|
||||
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
|
||||
// The source with host example.com and path /images/photo.jpg maps to
|
||||
// example.com/images/photo.jpg; the source query is ignored.
|
||||
type MockFetcher struct {
|
||||
fs fs.FS
|
||||
}
|
||||
@@ -24,8 +24,10 @@ func NewMock(fsys fs.FS) *MockFetcher {
|
||||
return &MockFetcher{fs: fsys}
|
||||
}
|
||||
|
||||
// Fetch retrieves content from the mock filesystem.
|
||||
func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||
// Fetch retrieves the source's content from the mock filesystem.
|
||||
func (m *MockFetcher) Fetch(
|
||||
ctx context.Context, source imagerequest.Source,
|
||||
) (*FetchResult, error) {
|
||||
// Check context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -33,11 +35,7 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
default:
|
||||
}
|
||||
|
||||
// Parse URL to get filesystem path
|
||||
path, err := urlToFSPath(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := source.Host + source.Path
|
||||
|
||||
// Open the file
|
||||
f, err := m.fs.Open(path)
|
||||
@@ -68,32 +66,6 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// urlToFSPath converts a URL to a filesystem path.
|
||||
// https://example.com/images/photo.jpg -> example.com/images/photo.jpg
|
||||
func urlToFSPath(rawURL string) (string, error) {
|
||||
// Strip scheme
|
||||
url := rawURL
|
||||
if idx := strings.Index(url, "://"); idx != -1 {
|
||||
url = url[idx+3:]
|
||||
}
|
||||
|
||||
// Remove query string
|
||||
if idx := strings.Index(url, "?"); idx != -1 {
|
||||
url = url[:idx]
|
||||
}
|
||||
|
||||
// Remove fragment
|
||||
if idx := strings.Index(url, "#"); idx != -1 {
|
||||
url = url[:idx]
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
return "", errEmptyURLPath
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// detectContentTypeFromPath returns the MIME type based on file extension.
|
||||
func detectContentTypeFromPath(path string) string {
|
||||
path = strings.ToLower(path)
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestFetchSendsRequestID(t *testing.T) {
|
||||
|
||||
ctx := context.WithValue(testContext(t), middleware.RequestIDKey, requestID)
|
||||
|
||||
res, err := f.Fetch(ctx, upstreamURL("/image"))
|
||||
res, err := f.Fetch(ctx, upstreamSource("/image"))
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch() error = %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user