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...) }