Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
16 changes: 16 additions & 0 deletions Tests/test_file_dds.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
assert_image_similar,
assert_image_similar_tofile,
hopper,
timeout_unless_slower_valgrind,
)

TEST_FILE_DXT1 = "Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds"
Expand Down Expand Up @@ -540,3 +541,18 @@ def test_save_large_file(tmp_path: Path, pixel_format: str, mode: str) -> None:
im = hopper(mode).resize((440, 440))
# should not error in valgrind
im.save(tmp_path / "img.dds", pixel_format=pixel_format)


@timeout_unless_slower_valgrind(1)
@pytest.mark.parametrize(
"test_file",
[
"Tests/images/timeout-041dd17dfde800360a47a172269df127af138c6b.dds",
"Tests/images/timeout-755a4d204f4208e3597ac3391edebee196462bd0.dds",
"Tests/images/timeout-52d106579505547091ef69b58341351a37c23e31.dds",
"Tests/images/timeout-c60a3d7314213624607bfb3e38d551a8b24a7435.dds",
],
)
def test_timeout(test_file) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def test_timeout(test_file) -> None:
def test_timeout(test_file: str) -> None:

with Image.open(test_file) as im:
im.load()
29 changes: 21 additions & 8 deletions src/PIL/DdsImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,21 +503,34 @@ def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int
while mask >> (offset + 1) << (offset + 1) == mask:
offset += 1
mask_offsets.append(offset)
mask_totals.append(mask >> offset)
mask_total = mask >> offset
if not mask_total:
mask_totals.append(0)
else:
mask_totals.append(255 / mask_total)

data = bytearray()
bytecount = bitcount // 8
dest_length = self.state.xsize * self.state.ysize * len(masks)
while len(data) < dest_length:
value = int.from_bytes(self.fd.read(bytecount), "little")
# consume the data
has_more = True
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
has_more = True
has_more = bytecount != 0

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bytecount is number of bytes per pixel, or the number of masks. It's not the data size.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but if bytecount is zero, then self.fd.read(bytecount) is always going to be empty.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But bytecount should never be zero. It’s an image level compression property, how many bytes do we consume per pixel.

To me, this looks like a misunderstanding, so can you explain why it’s better to initialize with this rather than the simpler expression for true?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But bytecount should never be zero

...awkwardly, it does seem to be possible. I mean, it doesn't seem helpful value, and I do think it should lead to an error.

# pixel format
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header[68:84])

bytecount = bitcount // 8

Here's the documentation - https://learn.microsoft.com/en-us/windows/win32/direct3ddds/dds-pixelformat

dwRGBBitCount

Type: DWORD

Number of bits in an RGB (possibly including alpha) format. Valid when dwFlags includes DDPF_RGB, DDPF_LUMINANCE, or DDPF_YUV.

To demonstrate, I've added radarhere@c90d124 onto a fork of this branch - https://github.com/radarhere/Pillow/actions/runs/21312659863/job/61351156707#step:11:58

bitcount is set to 0 in _open
bitcount is 0 which means that bytecount is 0 within DdsRgbDecoder

while len(data) < dest_length and has_more:
chunk = self.fd.read(bytecount)
# work around BufferedIO not being seekable
has_more = len(chunk) > 0
value = int.from_bytes(chunk, "little")
for i, mask in enumerate(masks):
masked_value = value & mask
# Remove the zero padding, and scale it to 8 bits
data += o8(
int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
if mask_totals[i]
else 0
)
data += o8(int((masked_value >> mask_offsets[i]) * mask_totals[i]))

# extra padding pixels -- always all 0
if len(data) < dest_length:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the file stopped before we were able to read all the bytes we needed, why not just let the data be shorter than expected, leading to a ValueError: not enough image data be raised? That error sounds like it is accurately describing the situation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question - I couldn't tell if it was intentional that it filled off the end. Given that it's plain black, it's at least a reasonable fill, but I didn't see a spec. I'd be happy enough erroring out as well. Main issue here is that I want to catch things other than dds timeouts in oss-fuzz.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've created #9405

pixel = bytearray()
pixel += o8(0)
ct_bytes = dest_length - len(data)
data += pixel * ct_bytes

self.set_as_raw(data)
return -1, 0

Expand Down
Loading