chore: update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 2m3s
All checks were successful
check / check (push) Successful in 2m3s
Replace .golangci.yml with the canonical v2-schema config (default: all minus six disabled linters, lll 88, tests included) and bump every golangci-lint pin to v2.12.2: - Dockerfile: golangci/golangci-lint:v2.12.2-alpine (hash-pinned) - script/bootstrap: GOLANGCI_LINT_VERSION 2.12.2 with new linux-amd64/arm64 release-archive sha256 pins Fix all 747 findings the stricter config surfaces, with no behavior changes: t.Parallel() throughout the test suite, static sentinel errors and errors.Is comparisons, checked error returns, context propagation (contextcheck/noctx), 88-column wrapping, extracted constants and helpers for goconst/dupl/funlen/cyclop, exhaustive switch cases replicating existing defaults, and white-box test files renamed to *_internal_test.go for testpackage. Three nolint:tagliatelle directives preserve the existing snake_case JSON wire and on-disk metadata formats.
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
neturl "net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -28,6 +29,23 @@ const (
|
||||
DefaultMaxConnectionsPerHost = 20
|
||||
)
|
||||
|
||||
// MIME content types.
|
||||
const (
|
||||
contentTypeJPEG = "image/jpeg"
|
||||
contentTypePNG = "image/png"
|
||||
contentTypeGIF = "image/gif"
|
||||
contentTypeWebP = "image/webp"
|
||||
contentTypeAVIF = "image/avif"
|
||||
contentTypeSVG = "image/svg+xml"
|
||||
contentTypeOctetStream = "application/octet-stream"
|
||||
)
|
||||
|
||||
// Loopback addresses blocked by SSRF protection.
|
||||
const (
|
||||
localhostIPv4 = "127.0.0.1"
|
||||
localhostIPv6 = "::1"
|
||||
)
|
||||
|
||||
// Fetcher errors.
|
||||
var (
|
||||
ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
|
||||
@@ -39,6 +57,12 @@ var (
|
||||
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
||||
)
|
||||
|
||||
// Internal fetcher errors.
|
||||
var (
|
||||
errTooManyRedirects = errors.New("too many redirects")
|
||||
errConnectFailed = errors.New("failed to connect")
|
||||
)
|
||||
|
||||
// Fetcher retrieves content from upstream origins.
|
||||
type Fetcher interface {
|
||||
// Fetch retrieves content from the given URL.
|
||||
@@ -92,12 +116,12 @@ func DefaultConfig() *Config {
|
||||
MaxResponseSize: DefaultMaxResponseSize,
|
||||
UserAgent: "pixa/1.0",
|
||||
AllowedContentTypes: []string{
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/svg+xml",
|
||||
contentTypeJPEG,
|
||||
contentTypePNG,
|
||||
contentTypeGIF,
|
||||
contentTypeWebP,
|
||||
contentTypeAVIF,
|
||||
contentTypeSVG,
|
||||
},
|
||||
AllowHTTP: false,
|
||||
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
|
||||
@@ -132,10 +156,12 @@ func New(config *Config) *HTTPFetcher {
|
||||
// Don't follow redirects automatically - we need to validate each hop
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= DefaultMaxRedirects {
|
||||
return errors.New("too many redirects")
|
||||
return errTooManyRedirects
|
||||
}
|
||||
|
||||
// Validate the redirect target
|
||||
if err := validateURL(req.URL.String(), config.AllowHTTP); err != nil {
|
||||
err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("redirect blocked: %w", err)
|
||||
}
|
||||
|
||||
@@ -150,24 +176,11 @@ func New(config *Config) *HTTPFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
||||
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
||||
f.hostSemMu.Lock()
|
||||
defer f.hostSemMu.Unlock()
|
||||
|
||||
sem, ok := f.hostSems[host]
|
||||
if !ok {
|
||||
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
|
||||
f.hostSems[host] = sem
|
||||
}
|
||||
|
||||
return sem
|
||||
}
|
||||
|
||||
// Fetch retrieves content from the given URL with SSRF protection.
|
||||
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
|
||||
// Validate URL before making request
|
||||
if err := validateURL(url, f.config.AllowHTTP); err != nil {
|
||||
err := validateURL(ctx, url, f.config.AllowHTTP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -201,7 +214,6 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
URL: parsedURL,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
req.Header.Set("User-Agent", f.config.UserAgent)
|
||||
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
|
||||
@@ -216,11 +228,10 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
}
|
||||
},
|
||||
}
|
||||
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
|
||||
req = req.WithContext(httptrace.WithClientTrace(ctx, trace))
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
//nolint:gosec // G704: URL validated by validateURL() above
|
||||
resp, err := f.client.Do(req)
|
||||
|
||||
fetchDuration := time.Since(startTime)
|
||||
@@ -233,6 +244,39 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
|
||||
result, err := f.buildResult(resp, remoteAddr, fetchDuration, sem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mark success so defer doesn't release the semaphore
|
||||
success = true
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
|
||||
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
|
||||
f.hostSemMu.Lock()
|
||||
defer f.hostSemMu.Unlock()
|
||||
|
||||
sem, ok := f.hostSems[host]
|
||||
if !ok {
|
||||
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
|
||||
f.hostSems[host] = sem
|
||||
}
|
||||
|
||||
return sem
|
||||
}
|
||||
|
||||
// buildResult validates the upstream response and assembles a FetchResult
|
||||
// whose Content releases the host semaphore slot when closed.
|
||||
func (f *HTTPFetcher) buildResult(
|
||||
resp *http.Response,
|
||||
remoteAddr string,
|
||||
fetchDuration time.Duration,
|
||||
sem chan struct{},
|
||||
) (*FetchResult, error) {
|
||||
// Extract HTTP version (strip "HTTP/" prefix)
|
||||
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
|
||||
|
||||
@@ -265,9 +309,6 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
remaining: f.config.MaxResponseSize,
|
||||
}
|
||||
|
||||
// Mark success so defer doesn't release the semaphore
|
||||
success = true
|
||||
|
||||
return &FetchResult{
|
||||
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, sem},
|
||||
ContentLength: resp.ContentLength,
|
||||
@@ -297,7 +338,7 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
||||
}
|
||||
|
||||
// validateURL checks if a URL is safe to fetch (not internal/private).
|
||||
func validateURL(rawURL string, allowHTTP bool) error {
|
||||
func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
||||
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
|
||||
return ErrUnsupportedScheme
|
||||
}
|
||||
@@ -309,7 +350,8 @@ func validateURL(rawURL string, allowHTTP bool) error {
|
||||
}
|
||||
|
||||
// Remove port if present
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
@@ -319,15 +361,16 @@ func validateURL(rawURL string, allowHTTP bool) error {
|
||||
}
|
||||
|
||||
// Resolve the host to check IP addresses
|
||||
ips, err := net.LookupIP(host)
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidHost, host)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip) {
|
||||
return ErrSSRFBlocked
|
||||
}
|
||||
private := slices.ContainsFunc(addrs, func(addr net.IPAddr) bool {
|
||||
return isPrivateIP(addr.IP)
|
||||
})
|
||||
if private {
|
||||
return ErrSSRFBlocked
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -340,9 +383,11 @@ func extractHost(rawURL string) string {
|
||||
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]
|
||||
}
|
||||
@@ -355,8 +400,8 @@ func isLocalhost(host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
|
||||
return host == "localhost" ||
|
||||
host == "127.0.0.1" ||
|
||||
host == "::1" ||
|
||||
host == localhostIPv4 ||
|
||||
host == localhostIPv6 ||
|
||||
host == "[::1]" ||
|
||||
strings.HasSuffix(host, ".localhost") ||
|
||||
strings.HasSuffix(host, ".local")
|
||||
@@ -422,23 +467,23 @@ func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// Check all resolved IPs
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip) {
|
||||
return nil, ErrSSRFBlocked
|
||||
}
|
||||
if slices.ContainsFunc(ips, isPrivateIP) {
|
||||
return nil, ErrSSRFBlocked
|
||||
}
|
||||
|
||||
// Connect using the first valid IP
|
||||
var dialer net.Dialer
|
||||
|
||||
for _, ip := range ips {
|
||||
addr := net.JoinHostPort(ip.String(), port)
|
||||
|
||||
conn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to connect to %s", host)
|
||||
return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
|
||||
}
|
||||
|
||||
// limitedReader wraps a reader and limits the number of bytes read.
|
||||
@@ -465,6 +510,7 @@ func (r *limitedReader) Read(p []byte) (int, error) {
|
||||
// semaphoreReleasingReadCloser releases a semaphore slot when closed.
|
||||
type semaphoreReleasingReadCloser struct {
|
||||
*limitedReader
|
||||
|
||||
closer io.Closer
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ import (
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// testHost is the hostname used by mock fetch tests.
|
||||
const testHost = "example.com"
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Timeout != DefaultFetchTimeout {
|
||||
@@ -35,6 +40,8 @@ func TestDefaultConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewWithNilConfigUsesDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := New(nil)
|
||||
|
||||
if f == nil {
|
||||
@@ -51,24 +58,28 @@ func TestNewWithNilConfigUsesDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsAllowedContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := New(DefaultConfig())
|
||||
|
||||
tests := []struct {
|
||||
contentType string
|
||||
want bool
|
||||
}{
|
||||
{"image/jpeg", true},
|
||||
{"image/png", true},
|
||||
{"image/webp", true},
|
||||
{contentTypeJPEG, true},
|
||||
{contentTypePNG, true},
|
||||
{contentTypeWebP, true},
|
||||
{"image/jpeg; charset=utf-8", true},
|
||||
{"IMAGE/JPEG", true},
|
||||
{"text/html", false},
|
||||
{"application/octet-stream", false},
|
||||
{contentTypeOctetStream, false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.contentType, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := f.isAllowedContentType(tc.contentType)
|
||||
if got != tc.want {
|
||||
t.Errorf("isAllowedContentType(%q) = %v, want %v", tc.contentType, got, tc.want)
|
||||
@@ -78,20 +89,24 @@ func TestIsAllowedContentType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/path", "example.com"},
|
||||
{"https://example.com/path", testHost},
|
||||
{"http://example.com:8080/path", "example.com:8080"},
|
||||
{"https://example.com", "example.com"},
|
||||
{"https://example.com?q=1", "example.com"},
|
||||
{"example.com/path", "example.com"},
|
||||
{"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)
|
||||
@@ -101,23 +116,27 @@ func TestExtractHost(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsLocalhost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
host string
|
||||
want bool
|
||||
}{
|
||||
{"localhost", true},
|
||||
{"LOCALHOST", true},
|
||||
{"127.0.0.1", true},
|
||||
{"::1", true},
|
||||
{localhostIPv4, true},
|
||||
{localhostIPv6, true},
|
||||
{"[::1]", true},
|
||||
{"foo.localhost", true},
|
||||
{"foo.local", true},
|
||||
{"example.com", false},
|
||||
{testHost, false},
|
||||
{"127.0.0.2", false}, // Handled by isPrivateIP, not isLocalhost string match
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.host, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := isLocalhost(tc.host)
|
||||
if got != tc.want {
|
||||
t.Errorf("isLocalhost(%q) = %v, want %v", tc.host, got, tc.want)
|
||||
@@ -127,18 +146,20 @@ func TestIsLocalhost(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{"127.0.0.1", true}, // loopback
|
||||
{localhostIPv4, true}, // loopback
|
||||
{"10.0.0.1", true}, // private
|
||||
{"192.168.1.1", true}, // private
|
||||
{"172.16.0.1", true}, // private
|
||||
{"169.254.1.1", true}, // link-local
|
||||
{"0.0.0.0", true}, // unspecified
|
||||
{"224.0.0.1", true}, // multicast
|
||||
{"::1", true}, // IPv6 loopback
|
||||
{localhostIPv6, true}, // IPv6 loopback
|
||||
{"fe80::1", true}, // IPv6 link-local
|
||||
{"8.8.8.8", false}, // public
|
||||
{"2001:4860:4860::8888", false}, // public IPv6
|
||||
@@ -146,6 +167,8 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.ip, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ip := net.ParseIP(tc.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||
@@ -164,15 +187,19 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||
err := validateURL("http://example.com/path", false)
|
||||
t.Parallel()
|
||||
|
||||
err := validateURL(t.Context(), "http://example.com/path", false)
|
||||
if !errors.Is(err, ErrUnsupportedScheme) {
|
||||
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
||||
}
|
||||
}
|
||||
|
||||
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("http://nonexistent.invalid/path", true)
|
||||
err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
|
||||
// We expect a host resolution error, not ErrUnsupportedScheme.
|
||||
if errors.Is(err, ErrUnsupportedScheme) {
|
||||
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
||||
@@ -180,20 +207,26 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
||||
err := validateURL("https://localhost/path", false)
|
||||
t.Parallel()
|
||||
|
||||
err := validateURL(t.Context(), "https://localhost/path", false)
|
||||
if !errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateURL_EmptyHost(t *testing.T) {
|
||||
err := validateURL("https:///path", false)
|
||||
t.Parallel()
|
||||
|
||||
err := validateURL(t.Context(), "https:///path", false)
|
||||
if !errors.Is(err, ErrInvalidHost) {
|
||||
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockFetcher_FetchesFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockFS := fstest.MapFS{
|
||||
"example.com/images/photo.jpg": &fstest.MapFile{Data: []byte("fake-jpeg-data")},
|
||||
}
|
||||
@@ -206,7 +239,7 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
|
||||
}
|
||||
defer func() { _ = result.Content.Close() }()
|
||||
|
||||
if result.ContentType != "image/jpeg" {
|
||||
if result.ContentType != contentTypeJPEG {
|
||||
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
||||
}
|
||||
|
||||
@@ -225,6 +258,8 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockFS := fstest.MapFS{}
|
||||
m := NewMock(mockFS)
|
||||
|
||||
@@ -235,6 +270,8 @@ func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockFS := fstest.MapFS{
|
||||
"example.com/photo.jpg": &fstest.MapFile{Data: []byte("data")},
|
||||
}
|
||||
@@ -250,24 +287,28 @@ func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDetectContentTypeFromPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"foo/bar.jpg", "image/jpeg"},
|
||||
{"foo/bar.JPG", "image/jpeg"},
|
||||
{"foo/bar.jpeg", "image/jpeg"},
|
||||
{"foo/bar.png", "image/png"},
|
||||
{"foo/bar.gif", "image/gif"},
|
||||
{"foo/bar.webp", "image/webp"},
|
||||
{"foo/bar.avif", "image/avif"},
|
||||
{"foo/bar.svg", "image/svg+xml"},
|
||||
{"foo/bar.bin", "application/octet-stream"},
|
||||
{"foo/bar", "application/octet-stream"},
|
||||
{"foo/bar.jpg", contentTypeJPEG},
|
||||
{"foo/bar.JPG", contentTypeJPEG},
|
||||
{"foo/bar.jpeg", contentTypeJPEG},
|
||||
{"foo/bar.png", contentTypePNG},
|
||||
{"foo/bar.gif", contentTypeGIF},
|
||||
{"foo/bar.webp", contentTypeWebP},
|
||||
{"foo/bar.avif", contentTypeAVIF},
|
||||
{"foo/bar.svg", contentTypeSVG},
|
||||
{"foo/bar.bin", contentTypeOctetStream},
|
||||
{"foo/bar", contentTypeOctetStream},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := detectContentTypeFromPath(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("detectContentTypeFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
@@ -277,6 +318,8 @@ func TestDetectContentTypeFromPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLimitedReader_EnforcesLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
src := make([]byte, 100)
|
||||
r := &limitedReader{
|
||||
reader: &byteReader{data: src},
|
||||
@@ -298,10 +341,11 @@ func TestLimitedReader_EnforcesLimit(t *testing.T) {
|
||||
total := n
|
||||
for total < 50 {
|
||||
nn, err := r.Read(buf)
|
||||
total += nn
|
||||
if err != nil {
|
||||
t.Fatalf("during drain: %v", err)
|
||||
}
|
||||
|
||||
total += nn
|
||||
}
|
||||
|
||||
// Now the limit is exhausted — next read should error.
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errEmptyURLPath is returned when a mock URL has no usable path.
|
||||
var errEmptyURLPath = errors.New("empty URL path")
|
||||
|
||||
// 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.
|
||||
@@ -59,7 +61,7 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
||||
contentType := detectContentTypeFromPath(path)
|
||||
|
||||
return &FetchResult{
|
||||
Content: f.(io.ReadCloser),
|
||||
Content: f,
|
||||
ContentLength: stat.Size(),
|
||||
ContentType: contentType,
|
||||
Headers: make(http.Header),
|
||||
@@ -86,7 +88,7 @@ func urlToFSPath(rawURL string) (string, error) {
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
return "", errors.New("empty URL path")
|
||||
return "", errEmptyURLPath
|
||||
}
|
||||
|
||||
return url, nil
|
||||
@@ -98,18 +100,18 @@ func detectContentTypeFromPath(path string) string {
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
|
||||
return "image/jpeg"
|
||||
return contentTypeJPEG
|
||||
case strings.HasSuffix(path, ".png"):
|
||||
return "image/png"
|
||||
return contentTypePNG
|
||||
case strings.HasSuffix(path, ".gif"):
|
||||
return "image/gif"
|
||||
return contentTypeGIF
|
||||
case strings.HasSuffix(path, ".webp"):
|
||||
return "image/webp"
|
||||
return contentTypeWebP
|
||||
case strings.HasSuffix(path, ".avif"):
|
||||
return "image/avif"
|
||||
return contentTypeAVIF
|
||||
case strings.HasSuffix(path, ".svg"):
|
||||
return "image/svg+xml"
|
||||
return contentTypeSVG
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
return contentTypeOctetStream
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user