go-places/api/internal/analysis/exif_test.go

424 lines
12 KiB
Go
Raw Normal View History

2026-10-07 15:14:50 +02:00
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...)
}