// 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 // 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, } ) // 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, SVG included, 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 } return "", ErrUnknownFormat } // 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)) }