423 lines
12 KiB
Go
423 lines
12 KiB
Go
package analysis
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"testing"
|
|
)
|
|
|
|
// The tests build real TIFF/EXIF structures byte by byte and check the reader
|
|
// recovers the values, including for inputs a hostile client could supply.
|
|
|
|
// buildTIFF assembles a TIFF block from the given directories. The layout is
|
|
// header, IFD0, Exif SubIFD, GPS SubIFD, then a value area holding any entry
|
|
// payload larger than the four inline bytes.
|
|
//
|
|
// Pointer entries for the two sub-directories are filled in automatically, so a
|
|
// caller only has to list the tags it cares about.
|
|
func buildTIFF(t *testing.T, order binary.ByteOrder, ifd0, exifSub, gpsSub []tiffEntry) []byte {
|
|
t.Helper()
|
|
|
|
const headerLen = 8
|
|
// IFD0 is always emitted, even when empty, because readIFD reads the
|
|
// next-directory pointer that follows the entries.
|
|
ifdLen := func(n int) int { return 2 + n*12 + 4 }
|
|
areaLen := func(entries []tiffEntry) int {
|
|
total := 0
|
|
for _, e := range entries {
|
|
if len(e.payload) > 4 {
|
|
total += len(e.payload)
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
ifd0Offset := headerLen
|
|
// Sub-directories are only emitted when the caller supplies entries, but
|
|
// IFD0 always is, so it always occupies space.
|
|
exifOffset := ifd0Offset + ifdLen(len(ifd0))
|
|
gpsOffset := exifOffset
|
|
if len(exifSub) > 0 {
|
|
gpsOffset += ifdLen(len(exifSub))
|
|
}
|
|
dataOffset := gpsOffset
|
|
if len(gpsSub) > 0 {
|
|
dataOffset += ifdLen(len(gpsSub))
|
|
}
|
|
|
|
out := make([]byte, dataOffset+areaLen(ifd0)+areaLen(exifSub)+areaLen(gpsSub))
|
|
|
|
if order == binary.LittleEndian {
|
|
copy(out[0:2], "II")
|
|
} else {
|
|
copy(out[0:2], "MM")
|
|
}
|
|
order.PutUint16(out[2:4], 42)
|
|
order.PutUint32(out[4:8], uint32(ifd0Offset))
|
|
|
|
// TIFF records the element count, not the byte count, so a 24-byte
|
|
// rational triple is count 3.
|
|
elementCount := func(typ uint16, payload []byte) uint32 {
|
|
var size uint32 = 1
|
|
switch typ {
|
|
case tiffShort:
|
|
size = 2
|
|
case tiffLong:
|
|
size = 4
|
|
case tiffRational:
|
|
size = 8 // two LONGs
|
|
}
|
|
return uint32(len(payload)) / size
|
|
}
|
|
|
|
areaCursor := dataOffset
|
|
writeIFD := func(entries []tiffEntry, offset uint32) {
|
|
order.PutUint16(out[offset:offset+2], uint16(len(entries)))
|
|
at := offset + 2
|
|
for _, e := range entries {
|
|
order.PutUint16(out[at:at+2], uint16(e.tag))
|
|
order.PutUint16(out[at+2:at+4], e.typ)
|
|
order.PutUint32(out[at+4:at+8], elementCount(e.typ, e.payload))
|
|
|
|
// Sub-IFD pointers are always four inline bytes and are resolved
|
|
// from the layout rather than supplied by the caller.
|
|
switch e.tag {
|
|
case tagExifIFDPointer:
|
|
order.PutUint32(out[at+8:at+12], uint32(exifOffset))
|
|
case tagGPSIFDPointer:
|
|
order.PutUint32(out[at+8:at+12], uint32(gpsOffset))
|
|
default:
|
|
if len(e.payload) <= 4 {
|
|
copy(out[at+8:at+12], e.payload)
|
|
} else {
|
|
order.PutUint32(out[at+8:at+12], uint32(areaCursor))
|
|
copy(out[areaCursor:areaCursor+len(e.payload)], e.payload)
|
|
areaCursor += len(e.payload)
|
|
}
|
|
}
|
|
at += 12
|
|
}
|
|
// No second directory follows.
|
|
}
|
|
|
|
writeIFD(ifd0, uint32(ifd0Offset))
|
|
if len(exifSub) > 0 {
|
|
writeIFD(exifSub, uint32(exifOffset))
|
|
}
|
|
if len(gpsSub) > 0 {
|
|
writeIFD(gpsSub, uint32(gpsOffset))
|
|
}
|
|
return out
|
|
}
|
|
|
|
type tiffEntry struct {
|
|
tag tiffTag
|
|
typ uint16
|
|
payload []byte
|
|
}
|
|
|
|
func asciiEntry(tag tiffTag, value string) tiffEntry {
|
|
// EXIF ASCII values are NUL-terminated.
|
|
return tiffEntry{tag: tag, typ: tiffASCII, payload: append([]byte(value), 0)}
|
|
}
|
|
|
|
func rationalEntry(order binary.ByteOrder, tag tiffTag, values ...[2]uint32) tiffEntry {
|
|
payload := make([]byte, 0, len(values)*8)
|
|
for _, v := range values {
|
|
var b [8]byte
|
|
order.PutUint32(b[0:4], v[0])
|
|
order.PutUint32(b[4:8], v[1])
|
|
payload = append(payload, b[:]...)
|
|
}
|
|
return tiffEntry{tag: tag, typ: tiffRational, payload: payload}
|
|
}
|
|
|
|
// jpegWithExif splices an APP1 segment carrying the TIFF block into a valid
|
|
// JPEG, which is how EXIF reaches a real JPEG file. The segment has to go
|
|
// immediately after the start-of-image marker, so the image is encoded first and
|
|
// the segment inserted into it.
|
|
func jpegWithExif(t *testing.T, tiffBlock []byte) []byte {
|
|
t.Helper()
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
for y := 0; y < 4; y++ {
|
|
for x := 0; x < 4; x++ {
|
|
img.SetRGBA(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255})
|
|
}
|
|
}
|
|
var enc bytes.Buffer
|
|
if err := jpeg.Encode(&enc, img, nil); err != nil {
|
|
t.Fatalf("encode jpeg: %v", err)
|
|
}
|
|
encoded := enc.Bytes()
|
|
|
|
// APP1: marker, length, "Exif\0\0", then the TIFF block.
|
|
app1Payload := append([]byte("Exif\x00\x00"), tiffBlock...)
|
|
segment := make([]byte, 0, 4+len(app1Payload))
|
|
segment = append(segment, 0xFF, 0xE1)
|
|
lenBuf := make([]byte, 2)
|
|
binary.BigEndian.PutUint16(lenBuf, uint16(len(app1Payload)+2))
|
|
segment = append(segment, lenBuf...)
|
|
segment = append(segment, app1Payload...)
|
|
|
|
out := make([]byte, 0, len(encoded)+len(segment))
|
|
out = append(out, encoded[:2]...) // SOI
|
|
out = append(out, segment...)
|
|
out = append(out, encoded[2:]...)
|
|
return out
|
|
}
|
|
|
|
// pngWithExifChunk writes a PNG carrying an eXIf chunk, the other common
|
|
// container for EXIF.
|
|
func pngWithExifChunk(t *testing.T, tiffBlock []byte) []byte {
|
|
t.Helper()
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
for y := 0; y < 4; y++ {
|
|
for x := 0; x < 4; x++ {
|
|
img.SetRGBA(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255})
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
t.Fatalf("encode png: %v", err)
|
|
}
|
|
out := buf.Bytes()
|
|
|
|
// Splice an eXIf chunk in right after the 8-byte PNG signature.
|
|
const sigLen = 8
|
|
var chunk bytes.Buffer
|
|
chunk.WriteString("eXIf")
|
|
b4 := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(b4, uint32(len(tiffBlock)))
|
|
chunk.Write(b4)
|
|
chunk.Write(tiffBlock)
|
|
binary.BigEndian.PutUint32(b4, crc32IEEE(chunk.Bytes()))
|
|
chunk.Write(b4)
|
|
|
|
return append(append(append([]byte{}, out[:sigLen]...), chunk.Bytes()...), out[sigLen:]...)
|
|
}
|
|
|
|
// crc32IEEE computes the PNG chunk CRC.
|
|
func crc32IEEE(data []byte) uint32 {
|
|
var table [256]uint32
|
|
for i := range table {
|
|
c := uint32(i)
|
|
for k := 0; k < 8; k++ {
|
|
if c&1 != 0 {
|
|
c = 0xedb88320 ^ (c >> 1)
|
|
} else {
|
|
c >>= 1
|
|
}
|
|
}
|
|
table[i] = c
|
|
}
|
|
crc := uint32(0xffffffff)
|
|
for _, b := range data {
|
|
crc = table[(crc^uint32(b))&0xff] ^ (crc >> 8)
|
|
}
|
|
return crc ^ 0xffffffff
|
|
}
|
|
|
|
func TestReadEXIFExtractsMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, order := range []binary.ByteOrder{binary.LittleEndian, binary.BigEndian} {
|
|
name := "big-endian"
|
|
if order == binary.LittleEndian {
|
|
name = "little-endian"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
block := buildTIFF(t, order,
|
|
[]tiffEntry{
|
|
asciiEntry(tagMake, "Canon"),
|
|
asciiEntry(tagModel, "EOS R6"),
|
|
// Empty payloads: buildTIFF fills the offsets in.
|
|
{tag: tagExifIFDPointer, typ: tiffLong},
|
|
{tag: tagGPSIFDPointer, typ: tiffLong},
|
|
},
|
|
[]tiffEntry{
|
|
asciiEntry(tagDateTimeOriginal, "2026:06:01 14:23:45"),
|
|
},
|
|
[]tiffEntry{
|
|
asciiEntry(tagGPSLatitudeRef, "N"),
|
|
// 48 deg 51' 23.76" N
|
|
rationalEntry(order, tagGPSLatitude, [2]uint32{48, 1}, [2]uint32{51, 1}, [2]uint32{2376, 100}),
|
|
asciiEntry(tagGPSLongitudeRef, "E"),
|
|
// 2 deg 17' 39.6" E
|
|
rationalEntry(order, tagGPSLongitude, [2]uint32{2, 1}, [2]uint32{17, 1}, [2]uint32{396, 10}),
|
|
},
|
|
)
|
|
got, err := readEXIF(withExifHeader(block))
|
|
if err != nil {
|
|
t.Fatalf("readEXIF: %v", err)
|
|
}
|
|
|
|
if got.Make != "Canon" {
|
|
t.Errorf("Make = %q, want %q", got.Make, "Canon")
|
|
}
|
|
if got.Model != "EOS R6" {
|
|
t.Errorf("Model = %q, want %q", got.Model, "EOS R6")
|
|
}
|
|
if got.TakenAt == nil {
|
|
t.Fatal("TakenAt was not decoded")
|
|
}
|
|
if got.TakenAt.Format("2006:01:02 15:04:05") != "2026:06:01 14:23:45" {
|
|
t.Errorf("TakenAt = %v, want 2026:06:01 14:23:45", got.TakenAt)
|
|
}
|
|
if !got.HasGPS {
|
|
t.Fatal("HasGPS = false, want true")
|
|
}
|
|
// 48 + 51/60 + 23.76/3600 = 48.8566
|
|
if diff := got.Latitude - 48.8566; diff > 1e-3 || diff < -1e-3 {
|
|
t.Errorf("Latitude = %v, want ~48.8566", got.Latitude)
|
|
}
|
|
// 2 + 17/60 + 39.6/3600 = 2.29433
|
|
if diff := got.Longitude - 2.29433; diff > 1e-3 || diff < -1e-3 {
|
|
t.Errorf("Longitude = %v, want ~2.29433", got.Longitude)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReadEXIFHandlesSouthAndWest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
block := buildTIFF(t, binary.LittleEndian,
|
|
[]tiffEntry{
|
|
{tag: tagExifIFDPointer, typ: tiffLong},
|
|
{tag: tagGPSIFDPointer, typ: tiffLong},
|
|
},
|
|
nil,
|
|
[]tiffEntry{
|
|
asciiEntry(tagGPSLatitudeRef, "S"),
|
|
rationalEntry(binary.LittleEndian, tagGPSLatitude, [2]uint32{33, 1}, [2]uint32{52, 1}, [2]uint32{0, 1}),
|
|
asciiEntry(tagGPSLongitudeRef, "W"),
|
|
rationalEntry(binary.LittleEndian, tagGPSLongitude, [2]uint32{151, 1}, [2]uint32{12, 1}, [2]uint32{0, 1}),
|
|
},
|
|
)
|
|
got, err := readEXIF(withExifHeader(block))
|
|
if err != nil {
|
|
t.Fatalf("readEXIF: %v", err)
|
|
}
|
|
if got.Latitude >= 0 {
|
|
t.Errorf("Latitude = %v, want a negative value for a south ref", got.Latitude)
|
|
}
|
|
if got.Longitude >= 0 {
|
|
t.Errorf("Longitude = %v, want a negative value for a west ref", got.Longitude)
|
|
}
|
|
}
|
|
|
|
func TestReadEXIFWithoutMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
block := buildTIFF(t, binary.LittleEndian, nil, nil, nil)
|
|
got, err := readEXIF(withExifHeader(block))
|
|
if err != nil {
|
|
t.Fatalf("readEXIF: %v", err)
|
|
}
|
|
if got.Make != "" || got.Model != "" || got.TakenAt != nil || got.HasGPS {
|
|
t.Errorf("expected an empty result, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestReadEXIFRejectsMalformed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
data []byte
|
|
}{
|
|
{"not exif at all", []byte("this is a text file, not a picture")},
|
|
{"truncated header", []byte("Exif\x00\x00II")},
|
|
{"unknown byte order", append([]byte("Exif\x00\x00"), []byte("XY\x00\x2a\x08\x00\x00\x00")...)},
|
|
{"bad magic", append([]byte("Exif\x00\x00"), []byte("II\x00\x00\x08\x00\x00\x00")...)},
|
|
// A directory offset pointing past the end of the block.
|
|
{"offset out of range", append([]byte("Exif\x00\x00"), []byte("II\x2a\x00\x08\x00\x00\xff\xff\xff\xff")...)},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if _, err := readEXIF(tc.data); err == nil {
|
|
t.Error("expected an error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFindExifBlockIgnoresAbsentMetadata confirms a payload with no EXIF is
|
|
// reported as such rather than as a parse failure.
|
|
func TestFindExifBlockIgnoresAbsentMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
t.Fatalf("encode png: %v", err)
|
|
}
|
|
if _, err := readEXIF(buf.Bytes()); err == nil {
|
|
t.Error("expected an error for a PNG with no EXIF")
|
|
}
|
|
}
|
|
|
|
// TestReadEXIFRejectsAbsurdEntryCount guards the bound on directory size: a
|
|
// hostile file claiming a huge entry count must not cause a huge allocation.
|
|
func TestReadEXIFRejectsAbsurdEntryCount(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
block := append([]byte("Exif\x00\x00"),
|
|
0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00,
|
|
0xff, 0xff, // 65535 entries
|
|
)
|
|
if _, err := readEXIF(block); err == nil {
|
|
t.Error("expected an error for an implausible entry count")
|
|
}
|
|
}
|
|
|
|
// TestAnalyzeUsesEXIFGPS verifies the metadata reaches the analysis result,
|
|
// which is what lets an upload be located without the client sending a
|
|
// coordinate.
|
|
func TestAnalyzeUsesEXIFGPS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
block := buildTIFF(t, binary.LittleEndian,
|
|
[]tiffEntry{
|
|
{tag: tagExifIFDPointer, typ: tiffLong},
|
|
{tag: tagGPSIFDPointer, typ: tiffLong},
|
|
},
|
|
[]tiffEntry{asciiEntry(tagDateTimeOriginal, "2026:06:01 14:23:45")},
|
|
[]tiffEntry{
|
|
asciiEntry(tagGPSLatitudeRef, "N"),
|
|
rationalEntry(binary.LittleEndian, tagGPSLatitude, [2]uint32{48, 1}, [2]uint32{51, 1}, [2]uint32{2376, 100}),
|
|
asciiEntry(tagGPSLongitudeRef, "E"),
|
|
rationalEntry(binary.LittleEndian, tagGPSLongitude, [2]uint32{2, 1}, [2]uint32{17, 1}, [2]uint32{396, 10}),
|
|
},
|
|
)
|
|
data := jpegWithExif(t, block)
|
|
a := NewLocal(DefaultMaxDimension)
|
|
res, err := a.Analyze(newTestContext(), Input{ContentType: "image/jpeg", Bytes: data})
|
|
if err != nil {
|
|
t.Fatalf("Analyze: %v", err)
|
|
}
|
|
if res.Metadata.Coordinate == nil {
|
|
t.Fatal("expected the GPS coordinate to be recovered from EXIF")
|
|
}
|
|
if diff := res.Metadata.Coordinate.Latitude - 48.8566; diff > 1e-3 || diff < -1e-3 {
|
|
t.Errorf("latitude = %v, want ~48.8566", res.Metadata.Coordinate.Latitude)
|
|
}
|
|
if res.Metadata.TakenAt == nil {
|
|
t.Error("expected the capture timestamp to be recovered from EXIF")
|
|
}
|
|
}
|
|
|
|
// withExifHeader prefixes a raw TIFF block with the identifier that introduces
|
|
// an APP1 payload, which is what readEXIF scans for.
|
|
func withExifHeader(tiffBlock []byte) []byte {
|
|
return append([]byte("Exif\x00\x00"), tiffBlock...)
|
|
}
|