check / check (push) Canceled after 0s
The new package internal/imagerequest holds the request type, its two parts Source and Transform, the Format and Fit types, the dimension and fit checks, the /v1/image/ path parsing and the variant key; it imports no other package of pixa. imgcache.ParsedURL, imgcache.ImageRequest, signature.Request, imageprocessor.Request, the second Size, format and fit types and the conversions between them are gone. The image processor takes a Transform only. encurl keeps a private struct with the token's short CBOR field names. VariantKey moves with the function that makes it. Tests follow the moved and renamed code; the tests of the removed conversions go with them. Model: opus-5-5
487 lines
12 KiB
Go
487 lines
12 KiB
Go
package imagerequest
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// Shared test fixture strings.
|
|
const (
|
|
testHostCDN = "cdn.example.com"
|
|
testHostExample = "example.com"
|
|
testPathCat = "/photos/cat.jpg"
|
|
)
|
|
|
|
// assertRequest compares the source host, path and query and the size and
|
|
// format of got against want.
|
|
func assertRequest(t *testing.T, got, want *Request) {
|
|
t.Helper()
|
|
|
|
if got.Source.Host != want.Source.Host {
|
|
t.Errorf("Host = %q, want %q", got.Source.Host, want.Source.Host)
|
|
}
|
|
|
|
if got.Source.Path != want.Source.Path {
|
|
t.Errorf("Path = %q, want %q", got.Source.Path, want.Source.Path)
|
|
}
|
|
|
|
if got.Source.Query != want.Source.Query {
|
|
t.Errorf("Query = %q, want %q", got.Source.Query, want.Source.Query)
|
|
}
|
|
|
|
if got.Transform.Size != want.Transform.Size {
|
|
t.Errorf("Size = %v, want %v", got.Transform.Size, want.Transform.Size)
|
|
}
|
|
|
|
if got.Transform.Format != want.Transform.Format {
|
|
t.Errorf("Format = %q, want %q",
|
|
got.Transform.Format, want.Transform.Format)
|
|
}
|
|
}
|
|
|
|
func TestParseImageURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want *Request
|
|
}{
|
|
{
|
|
name: "basic path with size",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
|
},
|
|
},
|
|
{
|
|
name: "original size with 0x0",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatJPEG},
|
|
},
|
|
},
|
|
{
|
|
name: "original size with orig keyword",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatPNG},
|
|
},
|
|
},
|
|
{
|
|
name: "path with query string",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{
|
|
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
|
},
|
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
|
},
|
|
},
|
|
{
|
|
name: "deep nested path",
|
|
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: "/a/b/c/d/image.jpg"},
|
|
Transform: Transform{Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF},
|
|
},
|
|
},
|
|
{
|
|
name: "jpg alias for jpeg",
|
|
input: "/v1/image/example.com/img.png/100x100.jpg",
|
|
want: &Request{
|
|
Source: Source{Host: testHostExample, Path: "/img.png"},
|
|
Transform: Transform{Size: Size{Width: 100, Height: 100}, Format: FormatJPEG},
|
|
},
|
|
},
|
|
{
|
|
name: "gif format",
|
|
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
|
want: &Request{
|
|
Source: Source{Host: testHostExample, Path: "/animated.gif"},
|
|
Transform: Transform{Size: Size{Width: 200, Height: 200}, Format: FormatGIF},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseImageURL(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
|
}
|
|
|
|
assertRequest(t, got, tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseImageURL_AutoFormat verifies that the format auto in an image URL
|
|
// parses as FormatAuto.
|
|
func TestParseImageURL_AutoFormat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseImageURL("/v1/image/example.com/photo.jpg/200x200.auto")
|
|
if err != nil {
|
|
t.Fatalf("ParseImageURL() error = %v", err)
|
|
}
|
|
|
|
if got.Transform.Format != FormatAuto {
|
|
t.Errorf("Format = %q, want %q", got.Transform.Format, FormatAuto)
|
|
}
|
|
}
|
|
|
|
func TestParseImageURL_Errors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "missing prefix",
|
|
input: "/image/cdn.example.com/photo.jpg/800x600.webp",
|
|
wantErr: ErrInvalidPath,
|
|
},
|
|
{
|
|
name: "empty path after prefix",
|
|
input: "/v1/image/",
|
|
wantErr: ErrMissingHost,
|
|
},
|
|
{
|
|
name: "host only, no path or size",
|
|
input: "/v1/image/cdn.example.com",
|
|
wantErr: ErrMissingSize,
|
|
},
|
|
{
|
|
name: "invalid size format",
|
|
input: "/v1/image/cdn.example.com/photo.jpg/invalid.webp",
|
|
wantErr: ErrInvalidSize,
|
|
},
|
|
{
|
|
name: "unsupported format",
|
|
input: "/v1/image/cdn.example.com/photo.jpg/800x600.bmp",
|
|
wantErr: ErrInvalidFormat,
|
|
},
|
|
{
|
|
name: "dimension too large",
|
|
input: "/v1/image/cdn.example.com/photo.jpg/10000x600.webp",
|
|
wantErr: ErrDimensionTooLarge,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := ParseImageURL(tt.input)
|
|
if err == nil {
|
|
t.Fatalf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
|
}
|
|
|
|
if !errorIs(err, tt.wantErr) {
|
|
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseImagePath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want *Request
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "chi wildcard capture",
|
|
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{
|
|
Host: testHostCDN,
|
|
Path: testPathCat,
|
|
},
|
|
Transform: Transform{
|
|
Size: Size{Width: 800, Height: 600},
|
|
Format: FormatWebP,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "with leading slash from chi",
|
|
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{
|
|
Host: testHostCDN,
|
|
Path: testPathCat,
|
|
},
|
|
Transform: Transform{
|
|
Size: Size{Width: 800, Height: 600},
|
|
Format: FormatWebP,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseImagePath(tt.input)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
assertRequest(t, got, tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
|
|
// that every spelling of its letter case is the same host to the allowlist,
|
|
// the upstream connection limits and the cache, and that the path keeps its
|
|
// case.
|
|
func TestParseImagePath_LowerCasesHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
|
if err != nil {
|
|
t.Fatalf("ParseImagePath() error = %v", err)
|
|
}
|
|
|
|
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
|
|
|
|
if got.Source.Host != testHostCDN {
|
|
t.Errorf("Host = %q, want %q", got.Source.Host, testHostCDN)
|
|
}
|
|
|
|
if got.Source.Path != "/Photos/Cat.jpg" {
|
|
t.Errorf("Path = %q, want %q", got.Source.Path, "/Photos/Cat.jpg")
|
|
}
|
|
}
|
|
|
|
// TestParseImagePath_RefusesUserinfo verifies that a host holding "@" is
|
|
// refused: a URL reads what comes before the "@" as a user name and password
|
|
// and the rest as the host.
|
|
func TestParseImagePath_RefusesUserinfo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, input := range []string{
|
|
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
} {
|
|
got, err := ParseImagePath(input)
|
|
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
|
|
|
|
if !errors.Is(err, ErrInvalidHost) {
|
|
t.Errorf("ParseImagePath(%q) error = %v, want %v",
|
|
input, err, ErrInvalidHost)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseImageURL_PathTraversal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// All path traversal attempts should be rejected
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{
|
|
name: "simple parent directory",
|
|
input: "/v1/image/cdn.example.com/../etc/passwd/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "double parent directory",
|
|
input: "/v1/image/cdn.example.com/../../etc/passwd/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "parent in middle of path",
|
|
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "encoded parent directory",
|
|
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "double encoded parent",
|
|
input: "/v1/image/cdn.example.com/photos/%252e%252e/secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "backslash traversal",
|
|
input: "/v1/image/cdn.example.com/photos/..\\secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "mixed slashes",
|
|
input: "/v1/image/cdn.example.com/photos/../\\../secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "null byte injection",
|
|
input: "/v1/image/cdn.example.com/photos/image.jpg%00.png/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "parent at start of path",
|
|
input: "/v1/image/cdn.example.com/../800x600.jpeg",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := ParseImageURL(tt.input)
|
|
if err == nil {
|
|
t.Error("ParseImageURL() should reject path traversal attempts")
|
|
}
|
|
|
|
if !errors.Is(err, ErrPathTraversal) {
|
|
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseImagePath_PathTraversal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Test path traversal via ParseImagePath (chi wildcard)
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{
|
|
name: "parent directory in path",
|
|
input: "cdn.example.com/photos/../secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "encoded traversal",
|
|
input: "cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := ParseImagePath(tt.input)
|
|
if err == nil {
|
|
t.Error("ParseImagePath() should reject path traversal attempts")
|
|
}
|
|
|
|
if !errors.Is(err, ErrPathTraversal) {
|
|
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
|
|
// a source query holding %25, the percent-escape of a literal "%", are
|
|
// accepted as they are, although decoding them once more leaves a "%" that
|
|
// does not start a percent-escape.
|
|
func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want *Request
|
|
}{
|
|
{
|
|
name: "path",
|
|
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: "/sale/50%25off.jpg"},
|
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
|
},
|
|
},
|
|
{
|
|
name: "query",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{
|
|
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
|
|
},
|
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseImageURL(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
|
}
|
|
|
|
assertRequest(t, got, tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseImageURL_DoubleEncodedTraversalNextToPercent verifies that a
|
|
// double-encoded ".." is still refused when the path also holds a "%" that
|
|
// does not start a percent-escape, either as written or after one more
|
|
// decoding.
|
|
func TestParseImageURL_DoubleEncodedTraversalNextToPercent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, input := range []string{
|
|
"/v1/image/cdn.example.com/photos/%252e%252e/secret/50%25off.jpg/800x600.jpeg",
|
|
"/v1/image/cdn.example.com/photos/%252e%252e/secret/100%/800x600.jpeg",
|
|
} {
|
|
_, err := ParseImageURL(input)
|
|
t.Logf("ParseImageURL(%q) error = %v", input, err)
|
|
|
|
if !errors.Is(err, ErrPathTraversal) {
|
|
t.Errorf("ParseImageURL(%q) error = %v, want ErrPathTraversal",
|
|
input, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// errorIs checks if err matches target (handles wrapped errors).
|
|
func errorIs(err, target error) bool {
|
|
if errors.Is(err, target) {
|
|
return true
|
|
}
|
|
// Check if error message contains target message for wrapped errors
|
|
if err != nil && target != nil {
|
|
return contains(err.Error(), target.Error())
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr))
|
|
}
|
|
|
|
func containsAt(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|