initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
423
api/internal/analysis/exif_test.go
Normal file
423
api/internal/analysis/exif_test.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
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...)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue