Skip to content

Commit 4b63dff

Browse files
aclark4lifeclaude
andcommitted
Share TIFF tag data budget across frames
The per-IFD limit could be bypassed with many frames, each with a tag pointing to the same payload. Walking 30,000 such frames in a 7.5 MB file read 227 GB. Each frame's limit is now taken from the bytes left after previous frames, and fixed when the frame is first loaded so that reloading it gives the same tags. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 4908eda commit 4b63dff

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

‎Tests/test_file_tiff_metadata.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,41 @@ def test_tag_data_larger_than_file() -> None:
499499
assert 1 not in ifd
500500

501501

502+
def test_tag_data_larger_than_file_across_frames() -> None:
503+
# Three 1x1 frames, each with a private BYTE tag that points to the same
504+
# payload, which is more than half of the file.
505+
payload_size = 400
506+
ifd_size = 2 + 10 * 12 + 4
507+
pixel_offset = 8 + payload_size
508+
first_ifd = pixel_offset + 1
509+
510+
data = b"II*\x00" + struct.pack("<L", first_ifd)
511+
data += b"Z" * payload_size + b"\x7f"
512+
for i in range(3):
513+
data += struct.pack("<H", 10)
514+
for tag in (256, 257, 258, 259, 262, 277):
515+
value = 8 if tag == 258 else 1
516+
data += struct.pack("<HHLH2x", tag, 3, 1, value)
517+
for tag, value in ((273, pixel_offset), (278, 1), (279, 1)):
518+
data += struct.pack("<HHLL", tag, 4, 1, value)
519+
data += struct.pack("<HHLL", 65000, 1, payload_size, 8)
520+
data += struct.pack("<L", first_ifd + (i + 1) * ifd_size if i < 2 else 0)
521+
522+
with Image.open(io.BytesIO(data)) as im:
523+
assert isinstance(im, TiffImagePlugin.TiffImageFile)
524+
assert len(im.tag_v2[65000]) == payload_size
525+
526+
for frame in (1, 2):
527+
with pytest.warns(UserWarning, match="Tag data larger than file"):
528+
im.seek(frame)
529+
assert 65000 not in im.tag_v2
530+
assert im.n_frames == 3
531+
532+
# Reloading a frame gives the same tags as before
533+
im.seek(0)
534+
assert len(im.tag_v2[65000]) == payload_size
535+
536+
502537
def test_tag_group_data() -> None:
503538
base_ifd = TiffImagePlugin.ImageFileDirectory_v2()
504539
interop_ifd = TiffImagePlugin.ImageFileDirectory_v2(group=40965)

‎src/PIL/TiffImagePlugin.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,10 @@ def __init__(
622622
self.tagtype: dict[int, int] = {}
623623
""" Dictionary of tag types """
624624
self.reset()
625+
# Maximum total out-of-line tag data that load() may read, as well
626+
# as the size of the stream. Set by TiffImageFile so that
627+
# all frames of a file share one budget.
628+
self._tag_data_limit: int | None = None
625629
self.next = (
626630
self._unpack("Q", ifh[8:])[0]
627631
if self._bigtiff
@@ -896,8 +900,11 @@ def load(self, fp: IO[bytes]) -> None:
896900
self.reset()
897901
self._offset = fp.tell()
898902
fp.seek(0, io.SEEK_END)
899-
file_size = fp.tell()
903+
limit = fp.tell()
900904
fp.seek(self._offset)
905+
if self._tag_data_limit is not None:
906+
limit = min(limit, self._tag_data_limit)
907+
self._tag_data_size = 0
901908

902909
try:
903910
tag_count = (
@@ -935,9 +942,10 @@ def load(self, fp: IO[bytes]) -> None:
935942
# Well-formed IFD values should fit within the stream they
936943
# are read from, even before accounting for overlaps.
937944
total_size += size
938-
if total_size > file_size:
945+
if total_size > limit:
939946
warnings.warn(f"Tag data larger than file. Skipping tag {tag}")
940947
continue
948+
self._tag_data_size += size
941949
fp.seek(offset)
942950
data = ImageFile._safe_read(fp, size)
943951
fp.seek(here)
@@ -1217,6 +1225,13 @@ def _open(self) -> None:
12171225
self._fp = self.fp
12181226
self._frame_pos: list[int] = []
12191227

1228+
# Out-of-line tag data is budgeted across all frames, so that many
1229+
# frames cannot each read the same data. Each frame's limit is fixed
1230+
# when it is first loaded, so that reloading it gives the same tags.
1231+
self.fp.seek(0, io.SEEK_END)
1232+
self._tag_data_remaining = self.fp.tell()
1233+
self._tag_data_limits: list[int] = []
1234+
12201235
logger.debug("*** TiffImageFile._open ***")
12211236
logger.debug("- __first: %s", self.__first)
12221237
logger.debug("- ifh: %s", repr(ifh)) # Use repr to avoid str(bytes)
@@ -1269,8 +1284,11 @@ def _seek(self, frame: int) -> None:
12691284
raise ValueError(msg)
12701285
self.fp.seek(self.__next)
12711286
self._frame_pos.append(self.__next)
1287+
self._tag_data_limits.append(self._tag_data_remaining)
12721288
logger.debug("Loading tags, location: %s", self.fp.tell())
1289+
self.tag_v2._tag_data_limit = self._tag_data_remaining
12731290
self.tag_v2.load(self.fp)
1291+
self._tag_data_remaining -= self.tag_v2._tag_data_size
12741292
if self.tag_v2.next in self._frame_pos:
12751293
# This IFD has already been processed
12761294
# Declare this to be the end of the image
@@ -1283,6 +1301,7 @@ def _seek(self, frame: int) -> None:
12831301
self.is_animated = self.__next != 0
12841302
self.__frame += 1
12851303
self.fp.seek(self._frame_pos[frame])
1304+
self.tag_v2._tag_data_limit = self._tag_data_limits[frame]
12861305
self.tag_v2.load(self.fp)
12871306
if XMP in self.tag_v2:
12881307
xmp = self.tag_v2[XMP]

0 commit comments

Comments
 (0)