Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package imagefile identifies the image formats volumen accepts, from
|
||||
// the bytes rather than from a name or a declared type.
|
||||
//
|
||||
// Only the three formats below are stored, and the media route serves
|
||||
// nothing else: a browser is never handed a document from a directory
|
||||
// that an archive or an upload can write to. An SVG is a document of
|
||||
// sorts, so the media route serves it sandboxed and the signature test
|
||||
// here demands the root element really be <svg>.
|
||||
package imagefile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The canonical extensions and the Content-Type each is served with.
|
||||
const (
|
||||
ExtWebP = ".webp"
|
||||
ExtAVIF = ".avif"
|
||||
ExtSVG = ".svg"
|
||||
|
||||
MIMEWebP = "image/webp"
|
||||
MIMEAVIF = "image/avif"
|
||||
MIMESVG = "image/svg+xml"
|
||||
)
|
||||
|
||||
var mimeTypes = map[string]string{
|
||||
ExtWebP: MIMEWebP,
|
||||
ExtAVIF: MIMEAVIF,
|
||||
ExtSVG: MIMESVG,
|
||||
}
|
||||
|
||||
// ContentType returns the Content-Type for an allowed image name, or ""
|
||||
// when the name does not carry an allowed extension.
|
||||
func ContentType(name string) string {
|
||||
return mimeTypes[strings.ToLower(filepath.Ext(filepath.Base(name)))]
|
||||
}
|
||||
|
||||
// Allowed reports whether name carries an allowed extension.
|
||||
func Allowed(name string) bool { return ContentType(name) != "" }
|
||||
|
||||
// SignatureMatches reports whether data carries the signature of the
|
||||
// format the extension names.
|
||||
func SignatureMatches(data []byte, ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ExtWebP:
|
||||
// RIFF....WEBP, twelve bytes of magic.
|
||||
return len(data) >= 12 &&
|
||||
bytes.Equal(data[:4], []byte("RIFF")) &&
|
||||
bytes.Equal(data[8:12], []byte("WEBP"))
|
||||
case ExtAVIF:
|
||||
// ISO BMFF: bytes 4..7 are the box size, 8..11 are "ftyp",
|
||||
// followed by the major brand.
|
||||
if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) {
|
||||
return false
|
||||
}
|
||||
brand := data[8:12]
|
||||
return bytes.Equal(brand, []byte("avif")) || bytes.Equal(brand, []byte("avis"))
|
||||
case ExtSVG:
|
||||
// The root element must be <svg>: XML text that opens with
|
||||
// another document (an XHTML page, an SVGZ masquerading as a
|
||||
// plain .svg) is not an accepted image.
|
||||
return svgRootTag(data) != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Detect returns the canonical extension for data, or "" when the bytes
|
||||
// carry no accepted signature.
|
||||
func Detect(data []byte) string {
|
||||
if SignatureMatches(data, ExtWebP) {
|
||||
return ExtWebP
|
||||
}
|
||||
if SignatureMatches(data, ExtAVIF) {
|
||||
return ExtAVIF
|
||||
}
|
||||
if SignatureMatches(data, ExtSVG) {
|
||||
return ExtSVG
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Dimensions reports the pixel size of a WebP, AVIF or SVG image, reading
|
||||
// only the container headers or the root element, and ok=false when the
|
||||
// bytes carry no size it can trust. A caller that holds a whole file can
|
||||
// pass it whole; a 64 KiB prefix of a media file carries every header
|
||||
// this reads.
|
||||
func Dimensions(data []byte) (width, height int, ok bool) {
|
||||
if w, h, ok := webpDimensions(data); ok {
|
||||
return w, h, true
|
||||
}
|
||||
if w, h, ok := avifDimensions(data); ok {
|
||||
return w, h, true
|
||||
}
|
||||
return svgDimensions(data)
|
||||
}
|
||||
|
||||
// webpDimensions reads the size out of the three chunk shapes WebP
|
||||
// uses: VP8 (lossy), VP8L (lossless) and VP8X (extended canvas).
|
||||
func webpDimensions(data []byte) (int, int, bool) {
|
||||
if len(data) < 20 || !bytes.Equal(data[:4], []byte("RIFF")) ||
|
||||
!bytes.Equal(data[8:12], []byte("WEBP")) {
|
||||
return 0, 0, false
|
||||
}
|
||||
switch string(data[12:16]) {
|
||||
case "VP8 ":
|
||||
// After the frame tag sit the three sync bytes 0x9d 0x01 0x2a,
|
||||
// then the width and the height as 16-bit little-endian values
|
||||
// whose top two bits carry a scale code.
|
||||
if len(data) < 30 || data[23] != 0x9d || data[24] != 0x01 || data[25] != 0x2a {
|
||||
return 0, 0, false
|
||||
}
|
||||
w := int(binary.LittleEndian.Uint16(data[26:28]) & 0x3fff)
|
||||
h := int(binary.LittleEndian.Uint16(data[28:30]) & 0x3fff)
|
||||
return w, h, w > 0 && h > 0
|
||||
case "VP8L":
|
||||
// The payload opens with 0x2f and packs width-1 into 14 bits
|
||||
// followed by height-1 into 14 more, least significant first.
|
||||
if len(data) < 25 || data[20] != 0x2f {
|
||||
return 0, 0, false
|
||||
}
|
||||
bits := uint32(data[21]) | uint32(data[22])<<8 | uint32(data[23])<<16 | uint32(data[24])<<24
|
||||
w := int(bits&0x3fff) + 1
|
||||
h := int((bits>>14)&0x3fff) + 1
|
||||
return w, h, true
|
||||
case "VP8X":
|
||||
// The canvas size sits as two 24-bit little-endian minus-one
|
||||
// values after the flags and three reserved bytes.
|
||||
if len(data) < 30 {
|
||||
return 0, 0, false
|
||||
}
|
||||
w := int(uint32(data[24])|uint32(data[25])<<8|uint32(data[26])<<16) + 1
|
||||
h := int(uint32(data[27])|uint32(data[28])<<8|uint32(data[29])<<16) + 1
|
||||
return w, h, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// avifDimensions walks the ISO-BMFF boxes of an AVIF: the meta box
|
||||
// names the primary item (pitm) and associates it with properties
|
||||
// (ipma inside iprp); the ispe property it points at carries the image
|
||||
// extent. Anything the walk cannot certify is reported as unknown
|
||||
// rather than guessed.
|
||||
func avifDimensions(data []byte) (int, int, bool) {
|
||||
if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) {
|
||||
return 0, 0, false
|
||||
}
|
||||
var meta []byte
|
||||
for _, b := range readBoxes(data) {
|
||||
if b.typ == "meta" {
|
||||
meta = b.body
|
||||
break
|
||||
}
|
||||
}
|
||||
if meta == nil || len(meta) < 4 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// meta is a full box: four bytes of version and flags precede the
|
||||
// children.
|
||||
children := readBoxes(meta[4:])
|
||||
|
||||
var primary uint64
|
||||
var properties []box
|
||||
var associations []box
|
||||
for _, b := range children {
|
||||
switch b.typ {
|
||||
case "pitm":
|
||||
if len(b.body) < 1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// The bounds name the whole item id: a box whose body stops
|
||||
// short of it is refused rather than read past, because a
|
||||
// truncated box at the end of the buffer has no bytes left
|
||||
// to read and the slice would run out of range.
|
||||
if b.body[0] == 0 {
|
||||
if len(b.body) < 6 {
|
||||
return 0, 0, false
|
||||
}
|
||||
primary = uint64(binary.BigEndian.Uint16(b.body[4:6]))
|
||||
} else {
|
||||
if len(b.body) < 8 {
|
||||
return 0, 0, false
|
||||
}
|
||||
primary = uint64(binary.BigEndian.Uint32(b.body[4:8]))
|
||||
}
|
||||
case "iprp":
|
||||
for _, inner := range readBoxes(b.body) {
|
||||
switch inner.typ {
|
||||
case "ipco":
|
||||
properties = readBoxes(inner.body)
|
||||
case "ipma":
|
||||
associations = append(associations, inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if primary == 0 || properties == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, assoc := range associations {
|
||||
for _, propertyIndex := range readAssociations(assoc) {
|
||||
if propertyIndex.item != primary {
|
||||
continue
|
||||
}
|
||||
// Property indices are one-based over ipco's children.
|
||||
if propertyIndex.index == 0 || propertyIndex.index > len(properties) {
|
||||
continue
|
||||
}
|
||||
boxed := properties[propertyIndex.index-1]
|
||||
// ispe is a full box: version and flags, then the width and
|
||||
// the height as big-endian 32-bit values.
|
||||
if boxed.typ != "ispe" || len(boxed.body) < 12 {
|
||||
continue
|
||||
}
|
||||
w := int(binary.BigEndian.Uint32(boxed.body[4:8]))
|
||||
h := int(binary.BigEndian.Uint32(boxed.body[8:12]))
|
||||
return w, h, w > 0 && h > 0
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// boxAssociation pairs an item id with the one-based property index one
|
||||
// of its associations names.
|
||||
type boxAssociation struct {
|
||||
item uint64
|
||||
index int
|
||||
}
|
||||
|
||||
// readAssociations decodes an ipma box's entries into item/property
|
||||
// pairs, honouring both the 16- and 32-bit item id sizes and the
|
||||
// 7- and 15-bit index sizes the flags select.
|
||||
func readAssociations(b box) []boxAssociation {
|
||||
if len(b.body) < 12 {
|
||||
return nil
|
||||
}
|
||||
flags := uint32(b.body[1])<<16 | uint32(b.body[2])<<8 | uint32(b.body[3])
|
||||
wideItems := flags&0b10 != 0
|
||||
wideIndexes := flags&0b01 != 0
|
||||
idSize, indexSize := 2, 1
|
||||
if wideItems {
|
||||
idSize = 4
|
||||
}
|
||||
if wideIndexes {
|
||||
indexSize = 2
|
||||
}
|
||||
count := int(binary.BigEndian.Uint32(b.body[4:8]))
|
||||
out := make([]boxAssociation, 0, count)
|
||||
pos := 8
|
||||
for range count {
|
||||
if pos+idSize+1 > len(b.body) {
|
||||
return out
|
||||
}
|
||||
var item uint64
|
||||
if wideItems {
|
||||
item = uint64(binary.BigEndian.Uint32(b.body[pos : pos+4]))
|
||||
} else {
|
||||
item = uint64(binary.BigEndian.Uint16(b.body[pos : pos+2]))
|
||||
}
|
||||
pos += idSize
|
||||
assocCount := int(b.body[pos])
|
||||
pos++
|
||||
for range assocCount {
|
||||
if pos+indexSize > len(b.body) {
|
||||
return out
|
||||
}
|
||||
var index int
|
||||
if wideIndexes {
|
||||
// The essential bit rides the top bit of a 15-bit index.
|
||||
index = int(binary.BigEndian.Uint16(b.body[pos:pos+2]) & 0x7fff)
|
||||
} else {
|
||||
index = int(b.body[pos] & 0x7f)
|
||||
}
|
||||
pos += indexSize
|
||||
out = append(out, boxAssociation{item: item, index: index})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// box is one ISO-BMFF box: its type and the body after the header.
|
||||
type box struct {
|
||||
typ string
|
||||
body []byte
|
||||
}
|
||||
|
||||
// readBoxes splits a run of sibling boxes. A size of zero means "to the
|
||||
// end of the input" and a size of one promotes to a 64-bit size; both
|
||||
// are honoured, and a truncated or empty box stops the walk.
|
||||
func readBoxes(data []byte) []box {
|
||||
var out []box
|
||||
pos := 0
|
||||
for pos+8 <= len(data) {
|
||||
size := uint64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||
typ := string(data[pos+4 : pos+8])
|
||||
header := 8
|
||||
if size == 1 {
|
||||
if pos+16 > len(data) {
|
||||
break
|
||||
}
|
||||
size = binary.BigEndian.Uint64(data[pos+8 : pos+16])
|
||||
header = 16
|
||||
}
|
||||
if size < uint64(header) {
|
||||
break
|
||||
}
|
||||
end := min(uint64(pos)+size, uint64(len(data)))
|
||||
out = append(out, box{typ: typ, body: data[pos+header : end]})
|
||||
if end <= uint64(pos)+8 {
|
||||
break
|
||||
}
|
||||
pos = int(end)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// svgRootTag returns the start tag of the root <svg> element, or nil when
|
||||
// the bytes are not an SVG document. The XML prolog, comments and any
|
||||
// leading declaration are stepped over on the way to it; anything that
|
||||
// opens the document with another root element is refused, so an XHTML
|
||||
// page never takes the .svg extension it is served under.
|
||||
func svgRootTag(data []byte) []byte {
|
||||
s := bytes.TrimPrefix(data, []byte("\ufeff"))
|
||||
for {
|
||||
s = bytes.TrimLeft(s, " \t\r\n")
|
||||
switch {
|
||||
case bytes.HasPrefix(s, []byte("<?xml")):
|
||||
end := bytes.Index(s, []byte("?>"))
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
s = s[end+2:]
|
||||
case bytes.HasPrefix(s, []byte("<!--")):
|
||||
end := bytes.Index(s, []byte("-->"))
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
s = s[end+3:]
|
||||
case bytes.HasPrefix(s, []byte("<!")):
|
||||
end := bytes.IndexByte(s, '>')
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
s = s[end+1:]
|
||||
case bytes.HasPrefix(s, []byte("<svg")):
|
||||
if len(s) > 4 {
|
||||
switch s[4] {
|
||||
case ' ', '\t', '\n', '\r', '>', '/':
|
||||
default:
|
||||
return nil // <svgrect and friends are not a root
|
||||
}
|
||||
}
|
||||
end := bytes.IndexByte(s, '>')
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
return s[:end+1]
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// svgDimensions reads the size off the root element: the width and height
|
||||
// attributes when both are bare lengths, or the viewBox box otherwise. A
|
||||
// percentage length carries no size the media library could show.
|
||||
func svgDimensions(data []byte) (int, int, bool) {
|
||||
tag := svgRootTag(data)
|
||||
if tag == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if width, ok := svgLength(tag, "width"); ok {
|
||||
if height, ok := svgLength(tag, "height"); ok {
|
||||
return width, height, width > 0 && height > 0
|
||||
}
|
||||
}
|
||||
if box, ok := svgAttr(tag, "viewBox"); ok {
|
||||
parts := strings.FieldsFunc(box, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
if len(parts) == 4 {
|
||||
w, errW := strconv.ParseFloat(parts[2], 64)
|
||||
h, errH := strconv.ParseFloat(parts[3], 64)
|
||||
if errW == nil && errH == nil && w >= 1 && h >= 1 {
|
||||
return int(w), int(h), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// svgLength reads one of the root element's size attributes as a pixel
|
||||
// length: a plain number or one written in px.
|
||||
func svgLength(tag []byte, name string) (int, bool) {
|
||||
value, ok := svgAttr(tag, name)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
value = strings.TrimSuffix(strings.TrimSuffix(value, "px"), "PX")
|
||||
if strings.TrimLeftFunc(value, func(r rune) bool {
|
||||
return (r >= '0' && r <= '9') || r == '.'
|
||||
}) != "" {
|
||||
return 0, false // a unit the media library does not convert
|
||||
}
|
||||
n, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return int(n), n >= 0
|
||||
}
|
||||
|
||||
// svgAttr returns the quoted value of one attribute of a start tag. The
|
||||
// scan is deliberately shallow: it reads the plain attributes the size
|
||||
// of a figure is written with and gives up on anything else.
|
||||
func svgAttr(tag []byte, name string) (string, bool) {
|
||||
s := string(tag)
|
||||
i := strings.IndexByte(s, ' ') // past "<svg"
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
for i < len(s) {
|
||||
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
start := i
|
||||
for i < len(s) && s[i] != '=' && !isSVGTagSpace(s[i]) && s[i] != '>' && s[i] != '/' {
|
||||
i++
|
||||
}
|
||||
key := s[start:i]
|
||||
if i >= len(s) || s[i] != '=' {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
if i >= len(s) || (s[i] != '"' && s[i] != '\'') {
|
||||
return "", false
|
||||
}
|
||||
quote := s[i]
|
||||
i++
|
||||
valueStart := i
|
||||
for i < len(s) && s[i] != quote {
|
||||
i++
|
||||
}
|
||||
if i >= len(s) {
|
||||
return "", false
|
||||
}
|
||||
value := s[valueStart:i]
|
||||
i++
|
||||
if key == name {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isSVGTagSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package imagefile
|
||||
|
||||
import "testing"
|
||||
|
||||
// webpBytes is a minimal RIFF/WEBP header, enough for the signature
|
||||
// check.
|
||||
func webpBytes() []byte {
|
||||
data := append([]byte("RIFF"), 0, 0, 0, 0)
|
||||
return append(data, []byte("WEBPVP8 ")...)
|
||||
}
|
||||
|
||||
// avifBytes is a minimal ISO BMFF header carrying the avif brand.
|
||||
func avifBytes() []byte {
|
||||
return []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f', 0, 0, 0, 0}
|
||||
}
|
||||
|
||||
func TestSignatureMatches(t *testing.T) {
|
||||
if !SignatureMatches(webpBytes(), ExtWebP) {
|
||||
t.Fatal("webp signature rejected")
|
||||
}
|
||||
if !SignatureMatches(avifBytes(), ExtAVIF) {
|
||||
t.Fatal("avif signature rejected")
|
||||
}
|
||||
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect/></svg>`)
|
||||
if !SignatureMatches(svg, ExtSVG) {
|
||||
t.Fatal("svg signature rejected")
|
||||
}
|
||||
prologed := []byte("\ufeff<?xml version=\"1.0\"?>\n<!-- figure --><svg></svg>")
|
||||
if !SignatureMatches(prologed, ExtSVG) {
|
||||
t.Fatal("svg with prolog and comment rejected")
|
||||
}
|
||||
if SignatureMatches([]byte("<html><body>hi</body></html>"), ExtSVG) {
|
||||
t.Fatal("an html document accepted as svg")
|
||||
}
|
||||
if SignatureMatches([]byte("<svgrect/>"), ExtSVG) {
|
||||
t.Fatal("a lookalike element name accepted as svg")
|
||||
}
|
||||
// The avis brand is a valid AVIF image sequence.
|
||||
avis := append([]byte{}, avifBytes()...)
|
||||
copy(avis[8:12], "avis")
|
||||
if !SignatureMatches(avis, ExtAVIF) {
|
||||
t.Fatal("avis brand rejected")
|
||||
}
|
||||
if SignatureMatches([]byte("short"), ExtWebP) {
|
||||
t.Fatal("short data accepted as webp")
|
||||
}
|
||||
if SignatureMatches([]byte("plain text here!!!"), ExtAVIF) {
|
||||
t.Fatal("garbage accepted as avif")
|
||||
}
|
||||
if SignatureMatches(webpBytes(), ".png") {
|
||||
t.Fatal("unknown extension accepted")
|
||||
}
|
||||
if SignatureMatches(avifBytes(), ExtWebP) {
|
||||
t.Fatal("avif bytes accepted as webp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetect(t *testing.T) {
|
||||
if got := Detect(webpBytes()); got != ExtWebP {
|
||||
t.Fatalf("Detect(webp) = %q", got)
|
||||
}
|
||||
if got := Detect(avifBytes()); got != ExtAVIF {
|
||||
t.Fatalf("Detect(avif) = %q", got)
|
||||
}
|
||||
if got := Detect([]byte("<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")); got != ExtSVG {
|
||||
t.Fatalf("Detect(svg) = %q", got)
|
||||
}
|
||||
for _, data := range [][]byte{
|
||||
nil, []byte("RIFF"), []byte("GIF89a and then padding"),
|
||||
[]byte("not an image at all, but long enough"),
|
||||
[]byte("<!DOCTYPE html>\n<html></html>"),
|
||||
} {
|
||||
if got := Detect(data); got != "" {
|
||||
t.Fatalf("Detect(%q) = %q, want empty", data, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"photo.webp": MIMEWebP,
|
||||
"photo.AVIF": MIMEAVIF,
|
||||
"figure.svg": MIMESVG,
|
||||
"nested/dir/photo.webp": MIMEWebP,
|
||||
"photo.png": "",
|
||||
"photo": "",
|
||||
"": "",
|
||||
"photo.webp.html": "",
|
||||
}
|
||||
for name, want := range cases {
|
||||
if got := ContentType(name); got != want {
|
||||
t.Errorf("ContentType(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
if Allowed(name) != (want != "") {
|
||||
t.Errorf("Allowed(%q) = %v, want %v", name, Allowed(name), want != "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mkbox assembles one ISO-BMFF box: a big-endian size, the four-byte type
|
||||
// and the body.
|
||||
func mkbox(typ string, body []byte) []byte {
|
||||
size := len(body) + 8
|
||||
out := make([]byte, 8, size)
|
||||
out[0] = byte(size >> 24)
|
||||
out[1] = byte(size >> 16)
|
||||
out[2] = byte(size >> 8)
|
||||
out[3] = byte(size)
|
||||
copy(out[4:], typ)
|
||||
return append(out, body...)
|
||||
}
|
||||
|
||||
// avifWithDimensions assembles a minimal but structurally honest AVIF:
|
||||
// ftyp, then meta naming item 1 as primary and associating its ispe.
|
||||
func avifWithDimensions(width, height int) []byte {
|
||||
ispe := []byte{0, 0, 0, 0} // full box: version and flags
|
||||
ispe = append(ispe,
|
||||
byte(width>>24), byte(width>>16), byte(width>>8), byte(width),
|
||||
byte(height>>24), byte(height>>16), byte(height>>8), byte(height))
|
||||
ipco := mkbox("ipco", mkbox("ispe", ispe))
|
||||
// ipma v0, flags 0: one entry, item 1, one association naming
|
||||
// property 1 (the ispe).
|
||||
ipma := []byte{0, 0, 0, 0,
|
||||
0, 0, 0, 1, // entry count
|
||||
0, 1, // item id 1
|
||||
1, // one association
|
||||
1, // property index 1
|
||||
}
|
||||
pitm := []byte{0, 0, 0, 0, 0, 1} // v0, item id 1
|
||||
iprp := append(ipco, mkbox("ipma", ipma)...)
|
||||
metaBody := append([]byte{0, 0, 0, 0}, mkbox("pitm", pitm)...)
|
||||
metaBody = append(metaBody, mkbox("iprp", iprp)...)
|
||||
meta := mkbox("meta", metaBody)
|
||||
ftyp := []byte{0, 0, 0, 16, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f', 0, 0, 0, 0}
|
||||
return append(ftyp, meta...)
|
||||
}
|
||||
|
||||
func TestDimensions(t *testing.T) {
|
||||
// webpChunk builds a RIFF/WEBP file whose first chunk carries the
|
||||
// payload: fourcc, little-endian size, then the bytes.
|
||||
webpChunk := func(chunk string, payload ...byte) []byte {
|
||||
out := append([]byte("RIFF"), 0, 0, 0, 0)
|
||||
out = append(out, []byte("WEBP")...)
|
||||
out = append(out, chunk...)
|
||||
out = append(out, byte(len(payload)), 0, 0, 0)
|
||||
return append(out, payload...)
|
||||
}
|
||||
|
||||
// VP8L opens with 0x2f and packs width-1 then height-1 into 14-bit
|
||||
// little-endian fields.
|
||||
bits := uint32(1919) | uint32(1079)<<14
|
||||
vp8l := webpChunk("VP8L", 0x2f, byte(bits), byte(bits>>8), byte(bits>>16), byte(bits>>24))
|
||||
|
||||
// VP8X carries two 24-bit minus-one canvas values after the flags
|
||||
// and three reserved bytes: 999 and 599, little-endian.
|
||||
vp8x := webpChunk("VP8X", 0, 0, 0, 0, 0xE7, 0x03, 0x00, 0x57, 0x02, 0x00)
|
||||
|
||||
// VP8 lossy: frame tag, the sync bytes, then two scaled 14-bit
|
||||
// little-endian values.
|
||||
vp8 := webpChunk("VP8 ", 0, 0, 0, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01)
|
||||
|
||||
// The AVIF structure assembled above.
|
||||
avif := avifWithDimensions(800, 600)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
w, h int
|
||||
ok bool
|
||||
}{
|
||||
{"webp lossless", vp8l, 1920, 1080, true},
|
||||
{"webp extended", vp8x, 1000, 600, true},
|
||||
{"webp lossy", vp8, 640, 480, true},
|
||||
{"avif primary item", avif, 800, 600, true},
|
||||
{"empty", nil, 0, 0, false},
|
||||
{"truncated webp", vp8x[:18], 0, 0, false},
|
||||
{"text", []byte("plainly not an image at all"), 0, 0, false},
|
||||
{"svg attributes",
|
||||
[]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="800px" height="600px"><g/></svg>`), 800, 600, true},
|
||||
{"svg viewbox",
|
||||
[]byte("<?xml version=\"1.0\"?>\n<svg viewBox=\"0 -10 1200 900\"></svg>"), 1200, 900, true},
|
||||
{"svg percent length",
|
||||
[]byte(`<svg width="100%" height="100%"></svg>`), 0, 0, false},
|
||||
{"svg no size",
|
||||
[]byte(`<svg xmlns="http://www.w3.org/2000/svg"><circle/></svg>`), 0, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w, h, ok := Dimensions(tc.data)
|
||||
if ok != tc.ok || (ok && (w != tc.w || h != tc.h)) {
|
||||
t.Errorf("%s: Dimensions = %d, %d, %v; want %d, %d, %v",
|
||||
tc.name, w, h, ok, tc.w, tc.h, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A box whose declared content is cut short must be refused, not panic:
|
||||
// the walk may only read bytes the box really holds, and a truncated
|
||||
// pitm or meta sits at the end of the buffer, where a read past the box
|
||||
// runs past the whole input.
|
||||
func TestDimensionsTruncatedBoxes(t *testing.T) {
|
||||
ftyp := []byte{0, 0, 0, 16, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f', 0, 0, 0, 0}
|
||||
fullBox := func(typ string, body []byte) []byte {
|
||||
return append(ftyp, mkbox(typ, body)...)
|
||||
}
|
||||
|
||||
// A v0 pitm carries a two-byte item id; only one byte of it survives.
|
||||
shortPitm := []byte{0, 0, 0, 0, 0}
|
||||
// A v1 pitm carries a four-byte item id; three bytes survive.
|
||||
widePitm := []byte{1, 0, 0, 0, 1, 2, 3}
|
||||
// A meta box whose full-box header itself is cut in half.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"truncated pitm", fullBox("meta", append([]byte{0, 0, 0, 0}, mkbox("pitm", shortPitm)...))},
|
||||
{"truncated wide pitm", fullBox("meta", append([]byte{0, 0, 0, 0}, mkbox("pitm", widePitm)...))},
|
||||
{"truncated meta header", fullBox("meta", []byte{0, 0})},
|
||||
{"header-only pitm", fullBox("meta", append([]byte{0, 0, 0, 0}, mkbox("pitm", nil)...))},
|
||||
} {
|
||||
if _, _, ok := Dimensions(tc.data); ok {
|
||||
t.Errorf("%s: Dimensions reported a size", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix is enough: the media listing reads only the head of a file,
|
||||
// so the sizes must come from there too.
|
||||
func TestDimensionsFromPrefix(t *testing.T) {
|
||||
full := avifWithDimensions(123, 45)
|
||||
head := make([]byte, 64<<10)
|
||||
copy(head, full)
|
||||
if w, h, ok := Dimensions(head[:len(full)+1024]); !ok || w != 123 || h != 45 {
|
||||
t.Fatalf("prefix Dimensions = %d, %d, %v", w, h, ok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user