Files
pixa/internal/magic/magic.go
clawbot d47aea67b8
check / check (push) Waiting to run
Support SVG sources (closes #68)
The fetcher accepts image/svg+xml; internal/magic detects SVG. orig of
an SVG at the orig size is the SVG unchanged, cached, with a
Content-Security-Policy allowing only its styles and data: images; at
another size, ErrSVGNotOriginalSize (400). Any other format renders it
at the least size covering the output, at most 8192 on a side, so it
stays sharp. Rendering refuses an SVG with a side over 8192 times the
other (ErrSVGTooThin, 415), and the orig size of one above 8192 on a
side (ErrSVGTooLargeForOrigSize, 400). The cache reads up to 1024 first
bytes to find a file's format. Tests pinning the SVG refusal now expect
SVG. Render time and memory are unbounded: P1 in TODO.md until the
owner decides on #68.

Model: opus-5-5
2026-11-11 16:42:08 +00:00

159 lines
4.8 KiB
Go

// Package magic detects image formats from magic bytes and validates
// content against declared MIME types.
package magic
import (
"bytes"
"errors"
"strings"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// Magic byte errors.
var (
ErrUnknownFormat = errors.New("unknown image format")
ErrMagicByteMismatch = errors.New("content does not match declared Content-Type")
ErrNotEnoughData = errors.New("not enough data to detect format")
)
// MinMagicBytes is the minimum number of bytes needed to detect format.
const MinMagicBytes = 12
// MaxMagicBytes is the most bytes DetectFormat looks at. An SVG's root
// element must start within them, as libvips requires.
const MaxMagicBytes = 1024
// Magic byte signatures for supported formats.
// These are effectively constants but Go doesn't support const slices.
//
//nolint:gochecknoglobals // immutable lookup data
var (
magicJPEG = []byte{0xFF, 0xD8, 0xFF}
magicPNG = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
magicGIF = []byte{0x47, 0x49, 0x46, 0x38} // GIF8 (GIF87a or GIF89a)
magicWebP = []byte{0x52, 0x49, 0x46, 0x46} // RIFF (WebP starts with RIFF....WEBP)
// AVIF uses the ftyp box with brand "avif" or "avis"
// Format: size(4 bytes) + "ftyp" + brand(4 bytes)
magicFtyp = []byte{0x66, 0x74, 0x79, 0x70} // "ftyp"
// JPEG XL is a bare codestream or a codestream in a container
magicJXLCodestream = []byte{0xFF, 0x0A}
magicJXLContainer = []byte{
0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A,
}
)
// utf8BOM is the UTF-8 byte order mark, which a text file such as an SVG may
// start with.
//
//nolint:gochecknoglobals // immutable lookup data
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
// WebP identifier appears at offset 8 after RIFF header.
//
//nolint:gochecknoglobals // immutable lookup data
var webpIdent = []byte{0x57, 0x45, 0x42, 0x50} // "WEBP"
// AVIF brand identifiers.
//
//nolint:gochecknoglobals // immutable lookup data
var (
avifBrand = []byte{0x61, 0x76, 0x69, 0x66} // "avif"
avisBrand = []byte{0x61, 0x76, 0x69, 0x73} // "avis" (AVIF sequence)
)
// DetectFormat detects the image format from magic bytes. Data in none of
// the formats pixa reads is ErrUnknownFormat.
func DetectFormat(data []byte) (imagerequest.Format, error) {
if len(data) < MinMagicBytes {
return "", ErrNotEnoughData
}
// Check JPEG (FFD8FF)
if bytes.HasPrefix(data, magicJPEG) {
return imagerequest.FormatJPEG, nil
}
// Check PNG (89504E47 0D0A1A0A)
if bytes.HasPrefix(data, magicPNG) {
return imagerequest.FormatPNG, nil
}
// Check GIF (GIF87a or GIF89a)
if bytes.HasPrefix(data, magicGIF) {
return imagerequest.FormatGIF, nil
}
// Check WebP (RIFF....WEBP)
if bytes.HasPrefix(data, magicWebP) && len(data) >= 12 {
if bytes.Equal(data[8:12], webpIdent) {
return imagerequest.FormatWebP, nil
}
}
// Check AVIF (....ftypavif or ....ftypavis)
// The ftyp box can start at offset 4 (after size bytes)
if len(data) >= 12 && bytes.Equal(data[4:8], magicFtyp) {
brand := data[8:12]
if bytes.Equal(brand, avifBrand) || bytes.Equal(brand, avisBrand) {
return imagerequest.FormatAVIF, nil
}
}
// Check JPEG XL (FF0A, or the 12-byte container signature)
if bytes.HasPrefix(data, magicJXLCodestream) ||
bytes.HasPrefix(data, magicJXLContainer) {
return imagerequest.FormatJXL, nil
}
if isSVG(data) {
return imagerequest.FormatSVG, nil
}
return "", ErrUnknownFormat
}
// isSVG reports whether data starts as an SVG does: with a "<", after an
// optional UTF-8 byte order mark and white space, and with "<svg" within the
// first MaxMagicBytes bytes. Before the root element an SVG may have an XML
// declaration, comments and a DOCTYPE.
func isSVG(data []byte) bool {
data = data[:min(len(data), MaxMagicBytes)]
text := bytes.TrimLeft(bytes.TrimPrefix(data, utf8BOM), " \t\r\n")
return bytes.HasPrefix(text, []byte("<")) &&
bytes.Contains(text, []byte("<svg"))
}
// ValidateMagicBytes detects the format of data from its magic bytes, and
// returns it when it is the format the declared MIME type names. Data in
// another format is ErrMagicByteMismatch.
func ValidateMagicBytes(
data []byte, declaredType string,
) (imagerequest.Format, error) {
detected, err := DetectFormat(data)
if err != nil {
return "", err
}
// Normalize the declared type (remove parameters like charset)
normalizedDeclared := normalizeMIMEType(declaredType)
// Check if they match
if detected.MIMEType() != normalizedDeclared {
return "", ErrMagicByteMismatch
}
return detected, nil
}
// normalizeMIMEType extracts just the media type, removing parameters.
func normalizeMIMEType(mimeType string) string {
// Handle "image/jpeg; charset=utf-8" -> "image/jpeg"
if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = mimeType[:idx]
}
return strings.TrimSpace(strings.ToLower(mimeType))
}