From 47a7136c467a89eab497a5439e00a30cdba989f8 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 19 Aug 2026 07:30:53 +0200 Subject: [PATCH] feat(thumbnails): support BigTIFF containers for raw previews (DNG 1.7) --- .../thumbnails/pkg/preprocessor/rawtiff.go | 123 +++++++++++++----- .../pkg/preprocessor/rawtiff_test.go | 62 +++++++++ 2 files changed, 152 insertions(+), 33 deletions(-) diff --git a/services/thumbnails/pkg/preprocessor/rawtiff.go b/services/thumbnails/pkg/preprocessor/rawtiff.go index e170f0655f..34f05e6a6a 100644 --- a/services/thumbnails/pkg/preprocessor/rawtiff.go +++ b/services/thumbnails/pkg/preprocessor/rawtiff.go @@ -43,13 +43,23 @@ const ( tiffTagJPEGLength = 0x0202 // JPEGInterchangeFormatLength tiffTypeShort = 3 tiffTypeLong = 4 + tiffTypeLong8 = 16 // BigTIFF 64-bit offset tiffMagic = 42 - // bound work on untrusted input: cap processed IFDs (and the queue) and - // the JPEG header segments walked before the SOF marker + bigTiffMagic = 43 + // bound work on untrusted input: cap processed IFDs (and the queue), the + // entries per IFD (BigTIFF counts are 64-bit) and the JPEG header segments + // walked before the SOF marker maxIFDs = 64 + maxIFDEntries = 4096 maxJPEGSegments = 32 ) +// isLongType reports whether a tag type carries a 32-bit (or, in BigTIFF, a +// 64-bit) integer offset we can follow. +func isLongType(typ uint16, bigTiff bool) bool { + return typ == tiffTypeLong || (bigTiff && typ == tiffTypeLong8) +} + // maxPreviewLength bounds the served preview: previews are camera-generated // JPEGs, so tens of MB is already generous and an oversized declared length is // rejected rather than served. A var so tests can lower it. @@ -70,23 +80,57 @@ func extractEmbeddedJPEG(data []byte) ([]byte, uint16, error) { default: return nil, 0, thumbnailerErrors.ErrNoImageFromRawFile } - if order.Uint16(data[2:4]) != tiffMagic { + + // classic TIFF (magic 42, 32-bit offsets) and BigTIFF (magic 43, 64-bit + // offsets, allowed for DNG since spec 1.7) share the IFD layout, only with + // wider entry-count, per-entry count and offset/value fields. + var ( + bigTiff bool + firstIFD uint64 + countSize int // width of the IFD entry-count field + entrySize int // width of one IFD entry + offW int // width of an offset / inline value field + ) + switch order.Uint16(data[2:4]) { + case tiffMagic: + countSize, entrySize, offW = 2, 12, 4 + firstIFD = uint64(order.Uint32(data[4:8])) + case bigTiffMagic: + // header carries the offset size (always 8) and a constant 0 before + // the 8-byte IFD0 offset + if len(data) < 16 || order.Uint16(data[4:6]) != 8 || order.Uint16(data[6:8]) != 0 { + return nil, 0, thumbnailerErrors.ErrNoImageFromRawFile + } + bigTiff = true + countSize, entrySize, offW = 8, 20, 8 + firstIFD = order.Uint64(data[8:16]) + default: return nil, 0, thumbnailerErrors.ErrNoImageFromRawFile } - type candidate struct{ offset, length uint32 } + // readOff reads a 4- or 8-byte offset/value depending on the container. + readOff := func(b []byte) uint64 { + if bigTiff { + return order.Uint64(b[:8]) + } + return uint64(order.Uint32(b[:4])) + } + valAt := 4 + offW // inline value field: after tag(2) + type(2) + count(offW) + + type candidate struct{ offset, length uint64 } var candidates []candidate var orientation uint16 - queue := []uint32{order.Uint32(data[4:8])} - seen := map[uint32]struct{}{} + queue := []uint64{firstIFD} + seen := map[uint64]struct{}{} // push queues an IFD offset unless the queue is already full; a crafted // file with millions of SubIFD pointers must not grow it without bound - push := func(offset uint32) { + push := func(offset uint64) { if len(queue) < maxIFDs { queue = append(queue, offset) } } + dlen := uint64(len(data)) first := true for len(queue) > 0 && len(seen) < maxIFDs { ifdOffset := queue[0] @@ -95,59 +139,72 @@ func extractEmbeddedJPEG(data []byte) ([]byte, uint16, error) { continue } seen[ifdOffset] = struct{}{} - if ifdOffset == 0 || int(ifdOffset)+2 > len(data) { + if ifdOffset == 0 || ifdOffset+uint64(countSize) > dlen { continue } - entryCount := int(order.Uint16(data[ifdOffset : ifdOffset+2])) - entriesEnd := int(ifdOffset) + 2 + entryCount*12 - if entriesEnd+4 > len(data) { + var entryCount int + if bigTiff { + entryCount = int(order.Uint64(data[ifdOffset : ifdOffset+8])) + } else { + entryCount = int(order.Uint16(data[ifdOffset : ifdOffset+2])) + } + if entryCount < 0 || entryCount > maxIFDEntries { + continue + } + entriesEnd := ifdOffset + uint64(countSize) + uint64(entryCount)*uint64(entrySize) + if entriesEnd+uint64(offW) > dlen { continue } - var jpegOffset, jpegLength, stripOffset, stripLength uint32 + var jpegOffset, jpegLength, stripOffset, stripLength uint64 for i := 0; i < entryCount; i++ { - entry := data[int(ifdOffset)+2+i*12:] + entry := data[ifdOffset+uint64(countSize)+uint64(i*entrySize):] tag := order.Uint16(entry[0:2]) typ := order.Uint16(entry[2:4]) - count := order.Uint32(entry[4:8]) + var count uint64 + if bigTiff { + count = order.Uint64(entry[4:12]) + } else { + count = uint64(order.Uint32(entry[4:8])) + } switch tag { case tiffTagOrientation: // only IFD0's orientation applies if first && typ == tiffTypeShort && count == 1 { - orientation = order.Uint16(entry[8:10]) + orientation = order.Uint16(entry[valAt : valAt+2]) } case tiffTagJPEGOffset: - if typ == tiffTypeLong && count == 1 { - jpegOffset = order.Uint32(entry[8:12]) + if isLongType(typ, bigTiff) && count == 1 { + jpegOffset = readOff(entry[valAt:]) } case tiffTagJPEGLength: - if typ == tiffTypeLong && count == 1 { - jpegLength = order.Uint32(entry[8:12]) + if isLongType(typ, bigTiff) && count == 1 { + jpegLength = readOff(entry[valAt:]) } case tiffTagStripOffsets: // only single-strip images can be a contiguous JPEG stream - if typ == tiffTypeLong && count == 1 { - stripOffset = order.Uint32(entry[8:12]) + if isLongType(typ, bigTiff) && count == 1 { + stripOffset = readOff(entry[valAt:]) } case tiffTagStripByteCounts: - if typ == tiffTypeLong && count == 1 { - stripLength = order.Uint32(entry[8:12]) + if isLongType(typ, bigTiff) && count == 1 { + stripLength = readOff(entry[valAt:]) } case tiffTagSubIFDs: - if typ != tiffTypeLong { + if !isLongType(typ, bigTiff) { continue } if count == 1 { - push(order.Uint32(entry[8:12])) + push(readOff(entry[valAt:])) continue } - arrayOffset := order.Uint32(entry[8:12]) - for j := uint32(0); j < count && j < maxIFDs; j++ { - pos := int(arrayOffset) + int(j)*4 - if pos+4 > len(data) || len(queue) >= maxIFDs { + arrayOffset := readOff(entry[valAt:]) + for j := uint64(0); j < count && j < uint64(maxIFDs); j++ { + pos := arrayOffset + j*uint64(offW) + if pos+uint64(offW) > dlen || len(queue) >= maxIFDs { break } - push(order.Uint32(data[pos : pos+4])) + push(readOff(data[pos:])) } } } @@ -157,14 +214,14 @@ func extractEmbeddedJPEG(data []byte) ([]byte, uint16, error) { if stripOffset > 0 && stripLength > 0 { candidates = append(candidates, candidate{stripOffset, stripLength}) } - push(order.Uint32(data[entriesEnd : entriesEnd+4])) + push(readOff(data[entriesEnd:])) first = false } var best []byte for _, c := range candidates { - end := int64(c.offset) + int64(c.length) - if end > int64(len(data)) || int64(c.length) > maxPreviewLength || int(c.length) <= len(best) { + end := c.offset + c.length + if c.length == 0 || end < c.offset || end > dlen || int64(c.length) > maxPreviewLength || int(c.length) <= len(best) { continue } jpg := data[c.offset:end] diff --git a/services/thumbnails/pkg/preprocessor/rawtiff_test.go b/services/thumbnails/pkg/preprocessor/rawtiff_test.go index d678be364e..62c5ccfbc0 100644 --- a/services/thumbnails/pkg/preprocessor/rawtiff_test.go +++ b/services/thumbnails/pkg/preprocessor/rawtiff_test.go @@ -391,3 +391,65 @@ var _ = Describe("RawTiffDecoder orientation direction", func() { }) } }) + +// buildBigTiffRawFile assembles a minimal little-endian BigTIFF (magic 43, +// 8-byte counts/offsets, 20-byte entries) as DNG allows since spec 1.7: IFD0 +// carries the orientation and a LONG8 JpegInterchange preview. +func buildBigTiffRawFile(orientation uint16, preview []byte) []byte { + le := binary.LittleEndian + // header: II, magic 43, offset size 8, constant 0, then the 8-byte IFD0 offset + buf := []byte{'I', 'I', 43, 0, 8, 0, 0, 0} + buf = le.AppendUint64(buf, 16) + + entry := func(tag, typ uint16, value uint64) []byte { + e := le.AppendUint16(nil, tag) + e = le.AppendUint16(e, typ) + e = le.AppendUint64(e, 1) // count + return le.AppendUint64(e, value) + } + + // IFD0: entryCount(8) + 3 entries(20 each) + next(8) + previewStart := uint64(16) + 8 + 3*20 + 8 + + ifd0 := le.AppendUint64(nil, 3) + ifd0 = append(ifd0, entry(tiffTagOrientation, tiffTypeShort, uint64(orientation))...) + ifd0 = append(ifd0, entry(tiffTagJPEGOffset, tiffTypeLong8, previewStart)...) + ifd0 = append(ifd0, entry(tiffTagJPEGLength, tiffTypeLong8, uint64(len(preview)))...) + ifd0 = le.AppendUint64(ifd0, 0) + + buf = append(buf, ifd0...) + return append(buf, preview...) +} + +var _ = Describe("RawTiffDecoder BigTIFF (DNG 1.7)", func() { + preview := encodeJPEG(64, 32) + + It("extracts the embedded JPEG and orientation from a BigTIFF container", func() { + jpg, orientation, err := extractEmbeddedJPEG(buildBigTiffRawFile(6, preview)) + Expect(err).ToNot(HaveOccurred()) + Expect(jpg).To(Equal(preview)) + Expect(orientation).To(Equal(uint16(6))) + }) + + It("rejects a BigTIFF header with an unexpected offset size", func() { + data := buildBigTiffRawFile(1, preview) + data[4] = 4 // offset size must be 8 + _, _, err := extractEmbeddedJPEG(data) + Expect(err).To(MatchError(thumbnailerErrors.ErrNoImageFromRawFile)) + }) + + It("decodes and orients a BigTIFF preview end to end", func() { + img, err := RawTiffDecoder{}.Convert(bytes.NewReader(buildBigTiffRawFile(6, preview))) + Expect(err).ToNot(HaveOccurred()) + bounds := img.(image.Image).Bounds() + Expect(bounds.Dx()).To(Equal(32)) + Expect(bounds.Dy()).To(Equal(64)) + }) + + It("survives truncated BigTIFF files", func() { + full := buildBigTiffRawFile(1, preview) + for cut := 0; cut < len(full); cut += 7 { + _, _, _ = extractEmbeddedJPEG(full[:cut]) + } + }) +})