Repository: python-pillow/Pillow

Issue:
Incorrect behaviour of the ImageFile._safe_read function
### What did you do?

Randomly came across the [ImageFile._safe_read](https://github.com/python-pillow/Pillow/blob/ab6efcb925f615d153538e84780f093f7f9f8df2/src/PIL/ImageFile.py#L527) fuction.

### What did you expect to happen?

An "OSError: Truncated File Read" if the length of the file-like object `fp` is less than the value of the `size` parameter.

### What actually happened?

Not always getting an OSError if the value for `size` is larger than `ImageFile.SAFEBLOCK` (and the length of fp is less than size).

### What are your OS, Python and Pillow versions?

* OS: Windows 10 (21H1)
* Python: 3.8.10
* Pillow: 8.4.0

```python
import io
from PIL import ImageFile

fp = io.BytesIO(b"0" * ImageFile.SAFEBLOCK)
size = ImageFile.SAFEBLOCK + 1

# no error, but expected OSError:
content = ImageFile._safe_read(fp=fp, size=size)

print(len(content))
# 1048576
print(size)
# 1048577
```



Discussion:
Why are you calling `_safe_read`? It's a "private" function and not part of the public API, as denoted by the leading underscore.

Is there a concrete problem when calling this via the public API?
I didn't find a problem via the public API. I just found this function because it raised an exception for an incompatible / truncated image and noticed, that it does not always do what its docstring describes. I just thought it is worth reporting so there are no problems in the future (regarding that function).
I've figured out a test case for this using our public APIs, and created #5872 accordingly.

Resolving patch:
diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py
--- a/src/PIL/ImageFile.py
+++ b/src/PIL/ImageFile.py
@@ -545,12 +545,13 @@ def _safe_read(fp, size):
             raise OSError("Truncated File Read")
         return data
     data = []
-    while size > 0:
-        block = fp.read(min(size, SAFEBLOCK))
+    remaining_size = size
+    while remaining_size > 0:
+        block = fp.read(min(remaining_size, SAFEBLOCK))
         if not block:
             break
         data.append(block)
-        size -= len(block)
+        remaining_size -= len(block)
     if sum(len(d) for d in data) < size:
         raise OSError("Truncated File Read")
     return b"".join(data)


Test patch:
diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py
--- a/Tests/test_imagefile.py
+++ b/Tests/test_imagefile.py
@@ -2,7 +2,7 @@
 
 import pytest
 
-from PIL import EpsImagePlugin, Image, ImageFile, features
+from PIL import BmpImagePlugin, EpsImagePlugin, Image, ImageFile, _binary, features
 
 from .helper import (
     assert_image,
@@ -111,6 +111,20 @@ def test_negative_stride(self):
         with pytest.raises(OSError):
             p.close()
 
+    def test_truncated(self):
+        b = BytesIO(
+            b"BM000000000000"  # head_data
+            + _binary.o32le(
+                ImageFile.SAFEBLOCK + 1 + 4
+            )  # header_size, so BmpImagePlugin will try to read SAFEBLOCK + 1 bytes
+            + (
+                b"0" * ImageFile.SAFEBLOCK
+            )  # only SAFEBLOCK bytes, so that the header is truncated
+        )
+        with pytest.raises(OSError) as e:
+            BmpImagePlugin.BmpImageFile(b)
+        assert str(e.value) == "Truncated File Read"
+
     @skip_unless_feature("zlib")
     def test_truncated_with_errors(self):
         with Image.open("Tests/images/truncated_image.png") as im: