Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

255 lines
9.9 KiB
Python

import hashlib
import platform
import zlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image
from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage, _should_use_png_rle
from invokeai.app.util.thumbnails import get_thumbnail_name
@pytest.fixture
def image_names() -> list[str]:
# Determine the platform and return a path that matches its format
if platform.system() == "Windows":
return [
# Relative paths
"folder\\evil.txt",
"folder\\..\\evil.txt",
# Absolute paths
"\\folder\\evil.txt",
"C:\\folder\\..\\evil.txt",
]
else:
return [
# Relative paths
"folder/evil.txt",
"folder/../evil.txt",
# Absolute paths
"/folder/evil.txt",
"/folder/../evil.txt",
]
@pytest.fixture
def disk_storage(tmp_path: Path) -> DiskImageFileStorage:
storage = DiskImageFileStorage(tmp_path)
# Mock the invoker for save() which needs compress_level
mock_invoker = MagicMock()
mock_invoker.services.configuration.pil_compress_level = 6
storage._DiskImageFileStorage__invoker = mock_invoker # type: ignore
return storage
def test_directory_traversal_protection(tmp_path: Path, image_names: list[str]):
"""Test that the image file storage prevents directory traversal attacks.
There are two safeguards in the `DiskImageFileStorage.get_path` method:
1. Check if the image name contains any directory traversal characters
2. Check if the resulting path is relative to the base folder
This test checks the first safeguard. I'd like to check the second but I cannot figure out a test case that would
pass the first check but fail the second check.
"""
image_files_disk = DiskImageFileStorage(tmp_path)
for name in image_names:
with pytest.raises(ValueError, match="Invalid image name, potential directory traversal detected"):
image_files_disk.get_path(name)
def test_image_paths_relative_to_storage_dir(tmp_path: Path):
image_files_disk = DiskImageFileStorage(tmp_path)
path = image_files_disk.get_path("foo.png")
assert path.is_relative_to(tmp_path)
@pytest.mark.parametrize(
("compress_level", "expected_compress_type"),
[(0, None), (1, zlib.Z_RLE), (7, None)],
)
def test_save_uses_rle_only_for_compression_level_one(
tmp_path: Path, compress_level: int, expected_compress_type: int | None
):
storage = DiskImageFileStorage(tmp_path)
mock_invoker = MagicMock()
mock_invoker.services.configuration.pil_compress_level = compress_level
storage._DiskImageFileStorage__invoker = mock_invoker # type: ignore
with (
patch("invokeai.app.services.image_files.image_files_disk._should_use_png_rle", return_value=True),
patch.object(Image.Image, "save", autospec=True) as save_mock,
):
storage.save(image=Image.new("RGBA", (32, 32)), image_name="test.png")
png_calls = [call for call in save_mock.call_args_list if len(call.args) > 2 and call.args[2] == "PNG"]
assert len(png_calls) == 1
assert png_calls[0].kwargs["compress_level"] == compress_level
if expected_compress_type is None:
assert "compress_type" not in png_calls[0].kwargs
else:
assert png_calls[0].kwargs["compress_type"] == expected_compress_type
def test_png_rle_probe_rejects_structured_images():
entropy = Image.frombytes("RGB", (512, 512), hashlib.shake_256(b"png-rle-test").digest(512 * 512 * 3))
gradient = Image.linear_gradient("L").resize((512, 512)).convert("RGB")
assert _should_use_png_rle(entropy)
assert not _should_use_png_rle(gradient)
entropy.close()
gradient.close()
def _make_round_trip_image(mode: str) -> Image.Image:
image = Image.new(mode, (4, 4))
if mode == "P":
palette = [component for index in range(256) for component in (index, 255 - index, index // 2, index)]
image.putpalette(palette, rawmode="RGBA")
image.putdata(range(16))
else:
values = {
"1": [0, 1],
"L": [0, 255],
"LA": [(17, 0), (201, 255)],
"RGB": [(1, 2, 3), (251, 252, 253)],
"RGBA": [(1, 2, 3, 0), (251, 252, 253, 255)],
"I;16": [0, 65535],
}
image.putdata(values[mode] * 8)
return image
@pytest.mark.parametrize("mode", ["1", "L", "LA", "P", "RGB", "RGBA", "I;16"])
def test_level_one_png_round_trip_from_disk(tmp_path: Path, mode: str):
storage = DiskImageFileStorage(tmp_path)
mock_invoker = MagicMock()
mock_invoker.services.configuration.pil_compress_level = 1
storage._DiskImageFileStorage__invoker = mock_invoker # type: ignore
image = _make_round_trip_image(mode)
expected_bytes = image.tobytes()
expected_rgba = image.convert("RGBA").tobytes() if mode == "P" else None
metadata = f'{{"mode":"{mode}"}}'
image_name = f"round-trip-{mode.replace(';', '-')}.png"
with patch("invokeai.app.services.image_files.image_files_disk._should_use_png_rle", return_value=True):
storage.save(image=image, image_name=image_name, metadata=metadata)
image_path = storage.get_path(image_name)
storage.evict_cache_paths([image_path])
with Image.open(image_path) as loaded:
loaded.load()
assert loaded.format == "PNG"
assert loaded.mode == mode
assert loaded.tobytes() == expected_bytes
assert loaded.info["invokeai_metadata"] == metadata
if mode in {"LA", "RGBA"}:
assert loaded.getchannel("A").tobytes() == image.getchannel("A").tobytes()
if mode == "P":
assert loaded.info["transparency"] == bytes(range(256))
assert loaded.convert("RGBA").tobytes() == expected_rgba
image.close()
# ── Subfolder validation tests (Point 1) ──
class TestValidateSubfolder:
"""Tests for _validate_subfolder() and get_path() with image_subfolder."""
def test_valid_single_segment(self, tmp_path: Path):
storage = DiskImageFileStorage(tmp_path)
path = storage.get_path("img.png", image_subfolder="general")
assert path.is_relative_to(tmp_path)
assert "general" in path.parts
def test_valid_nested_subfolder(self, tmp_path: Path):
storage = DiskImageFileStorage(tmp_path)
path = storage.get_path("img.png", image_subfolder="2026/03/17")
assert path.is_relative_to(tmp_path)
assert path.name == "img.png"
@pytest.mark.parametrize(
"subfolder,error_match",
[
("../x", "Parent directory references not allowed"),
("x/../y", "Parent directory references not allowed"),
("/abs", "Absolute paths not allowed"),
("a//b", "Empty path segments not allowed"),
("a\\b", "Backslashes not allowed"),
],
ids=["parent_traversal", "mid_traversal", "absolute", "double_slash", "backslash"],
)
def test_invalid_subfolders(self, tmp_path: Path, subfolder: str, error_match: str):
storage = DiskImageFileStorage(tmp_path)
with pytest.raises(ValueError, match=error_match):
storage.get_path("img.png", image_subfolder=subfolder)
def test_empty_subfolder_gives_root(self, tmp_path: Path):
storage = DiskImageFileStorage(tmp_path)
path = storage.get_path("img.png", image_subfolder="")
assert path == (tmp_path / "img.png").resolve()
def test_thumbnail_mirrors_subfolder(self, tmp_path: Path):
storage = DiskImageFileStorage(tmp_path)
subfolder = "2026/03/17"
img_path = storage.get_path("img.png", thumbnail=False, image_subfolder=subfolder)
thumb_path = storage.get_path("img.png", thumbnail=True, image_subfolder=subfolder)
# Both should contain the subfolder segments
assert subfolder.replace("/", "\\") in str(img_path) or subfolder in str(img_path)
assert subfolder.replace("/", "\\") in str(thumb_path) or subfolder in str(thumb_path)
# Thumbnail should be under thumbnails folder
thumbnails_folder = (tmp_path / "thumbnails").resolve()
assert thumb_path.is_relative_to(thumbnails_folder)
class TestSaveDeleteRoundTrip:
"""Save/delete round-trip with subfolders, including thumbnail mirroring."""
def test_save_and_delete_with_subfolder(self, disk_storage: DiskImageFileStorage, tmp_path: Path):
subfolder = "2026/04/05"
image_name = "test_image.png"
image = Image.new("RGB", (64, 64), color="red")
disk_storage.save(image=image, image_name=image_name, image_subfolder=subfolder)
# Image file exists
image_path = disk_storage.get_path(image_name, image_subfolder=subfolder)
assert image_path.exists()
# Thumbnail file exists in mirrored subfolder
thumbnail_name = get_thumbnail_name(image_name)
thumb_path = disk_storage.get_path(image_name, thumbnail=True, image_subfolder=subfolder)
assert thumb_path.name == thumbnail_name
assert not thumb_path.name.startswith("thumbnail_thumbnail_")
assert thumb_path.exists()
# Round-trip read
loaded = disk_storage.get(image_name, image_subfolder=subfolder)
assert loaded.size == (64, 64)
# Delete removes both
disk_storage.delete(image_name, image_subfolder=subfolder)
assert not image_path.exists()
assert not thumb_path.exists()
def test_save_flat_and_subfolder_coexist(self, disk_storage: DiskImageFileStorage, tmp_path: Path):
image = Image.new("RGB", (32, 32), color="blue")
disk_storage.save(image=image, image_name="flat.png", image_subfolder="")
disk_storage.save(image=image, image_name="nested.png", image_subfolder="general")
flat_path = disk_storage.get_path("flat.png", image_subfolder="")
nested_path = disk_storage.get_path("nested.png", image_subfolder="general")
assert flat_path.exists()
assert nested_path.exists()
assert flat_path.parent != nested_path.parent