Files
ComfyAssets-ComfyUI_PromptM…/tests/test_image_reveal.py
T
Vito 80c6ec5340 fix(lora): find LoraManager's sidecars, previews and example images in every storage configuration (#355) (#358)
* fix(lora): read LoraManager's settings and storage layout the way it does (#355)

Add py/lora_manager_layout.py: the settings file in LoraManager's lookup
order (environment folder, portable plugin file, user config folder), only
the seven keys the import needs, the LoRA roots, the centralized sidecar
root, the example-images root and the active library, each folder through
one guard. Sidecar discovery for both storage modes and the local preview
and example-image lookup live there too.

The test runner and the browser test server point LoraManager's settings
lookup at a disposable folder, so no test reads the real settings.

* fix(lora): import the sidecars and local images LoraManager really uses (#355)

The import, the legacy scan, the trigger-word cache and the image server
now share LoraManager's layout: sidecars beside the models (alongside) or
under the sidecar root (centralized), kept only when their model exists
inside a LoRA root. Each LoRA links its LoraManager preview and example
images where they are; CivitAI downloads run only for a LoRA without any
local image. The review adds skipped, with_local_media and the layout, and
offers no token when LoraManager's settings cannot be used; the legacy scan
reports the same reason without replacing anything.

find_lora_directories becomes default_lora_roots (no scan of the
LoraManager install); the preview and example-folder helpers it replaced
are removed. The trigger cache never raises into a running node.

* fix(lora): never serve a LoraManager folder that holds the home folder (#355)

The folder guard also rejects ancestors of the home folder (/home,
C:\Users), compared in the realpath + normcase form, so a LoraManager
setting cannot widen the image server's allow-list to every user's files.

* fix(lora): show linked LoraManager videos as videos (#355)

_enrich_images marks every image without is_video by its extension, so an
.mp4 preview outside the output folders shows the Video preview label and
plays in the inspector. Tests cover serving the sidecar and example-image
folders only while the integration is on, and a filesystem root as the
sidecar folder adding nothing.

* feat(lora): the import review names the LoraManager settings it read (#355)

The review gains a first row, LoraManager ("<source> settings · <mode>
storage · library <name>", the settings file as its hover title), and its
Found row counts metadata files, LoRAs and LoRAs with local images. A plan
whose LoraManager settings cannot be used takes the close-only path:
"LoraManager settings: not usable: <reason>. Nothing was changed." The
browser fixture writes each LoRA's model file and can leave out the local
preview, so the download, gate and cancel specs still exercise CivitAI.

* docs(lora): describe how the import finds LoraManager's files (#355)

* test(lora): set the settings folder relative to the process folder (#355)

os.path.relpath fails across Windows drives (the temp folder on C:, the
checkout on D:); the test now changes into its temp folder instead.

* fix(lora): review findings for the LoraManager layout (#355)

- The example-images library subfolder goes through the folder guard like
  its root, so its canonical (normcased) form matches the media check on
  Windows for a library name with capitals.
- A failed trigger-word load is retried by a running node only after
  TRIGGER_RETRY_SECONDS, not on every prompt run.
- A review whose discovery cannot list a folder says so (folder-unreadable,
  the folder's name only) instead of a generic error.

* test(images): hold the listing clock in the deferred-walk test

test_a_deferred_walk_waits_at_least_half_a_second set the previous walk
29.99 s back against a 30 s interval and then read the real clock again:
a runner slower than 10 ms between the two lines passed the interval and
the walk started at once (failed on macOS 3.13 and Windows 3.11). The
module's clock is now held for the mark, so the deferred walk is always
the one measured.

* test(lora): write the unkept-settings fixture as raw text

CodeQL read the dict with a credential-named key flowing into write_text
as clear-text storage of a secret. The value is a marker, so the fixture
now writes the JSON text directly and names the value MARKER.

* fix(lora): link each LoRA's cover as its newest image (#355)

Workspace lists show a prompt's media newest link first, so the import
links a record's images last to first: the card shows the cover (a still
in front of a leading video) the import put first.

* fix(lora): show LoRA-linked media on the Library page and in the inspector (#355)

Library previews, the positive detail and the per-prompt images page list
a prompt's linked media under the output folders plus, while the LoRA
integration is on, LoraManager's guarded folders and the download cache
(_media_roots; output root indices keep their meaning). That media keeps
its rooted identity, so Info and the media viewer open it; the path-based
serve route answers for those roots only with a file the database records
(either path spelling), never serves or makes a preview there, and output
requests never read LoraManager's layout. Such media is marked
linked_only: no preview file, no favorite star, no Tag action. Show in
folder accepts those roots; the download cache is named, not created.

* fix(lora): expose only linked files under LoraManager's folders (#355)

Serve, inspect and Show in folder share one gate for a LoRA-linked root
(_media_roots past the output folders): a file the database does not
record as a linked image is refused with 403, checked by its given and
its real path. Before, inspect and reveal read any file under those
folders by path.

* fix(lora): check the file Show in folder would open under a LoRA root (#355)

reveal_image resolved the requested path through the client-path
resolver, which can fall back to a path anchor; under a LoRA-linked root
the file that would open now passes the same recorded-media gate, so only
a linked file is ever revealed there.
2026-10-26 21:21:33 -07:00

955 lines
40 KiB
Python

"""Show in folder (TESTING.md D38): the local-only reveal route and its openers.
No test launches a file manager: ``subprocess.Popen``, ``sys.platform`` and
``shutil.which`` are mocked, and route tests swap the API's opener.
"""
import ctypes
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import types
import unittest
from types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
from aiohttp import web
from multidict import CIMultiDict
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError:
from tests.test_api_images import ImageAPITestCase, make_png
import utils.file_reveal as file_reveal
from utils.file_reveal import (
OpenerUnavailable,
is_local_request,
reveal_in_file_manager,
)
REVEAL = "/prompt_manager/images/reveal"
PROXY_HEADERS = (
"Forwarded",
"Via",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Forwarded-Port",
"X-Forwarded-Server",
"X-Real-IP",
"X-Original-Forwarded-For",
"X-Original-Host",
"CF-Connecting-IP",
"True-Client-IP",
"X-Client-IP",
"X-Cluster-Client-IP",
"Fastly-Client-IP",
)
ORIGIN = "http://127.0.0.1:8188"
def headers(**values):
base = {"Host": "127.0.0.1:8188", "Origin": ORIGIN}
base.update({key.replace("_", "-"): value for key, value in values.items()})
return CIMultiDict({key: value for key, value in base.items() if value is not None})
class TestIsLocalRequest(unittest.TestCase):
def test_same_origin_loopback_clients_by_loopback_name_are_local(self):
for remote, host, origin in (
("127.0.0.1", "127.0.0.1:8188", ORIGIN),
("127.8.9.10", "localhost:8188", "http://localhost:8188"),
("::1", "[::1]:8188", "http://[::1]:8188"),
("::ffff:127.0.0.1", "comfy.localhost", "http://comfy.localhost"),
("127.0.0.1", "LOCALHOST:8188", "http://localhost:8188"),
("127.0.0.1", "localhost", "http://localhost:80"),
):
with self.subTest(remote=remote, host=host):
self.assertTrue(
is_local_request(remote, headers(Host=host, Origin=origin))
)
self.assertTrue(
is_local_request(
"127.0.0.1",
headers(Host="localhost", Origin="https://localhost:443"),
"https",
)
)
def test_non_loopback_or_unknown_remote_is_not_local(self):
for remote in ("192.168.1.5", "10.0.0.1", "::2", "fe80::1%eth0", "", None):
with self.subTest(remote=remote):
self.assertFalse(is_local_request(remote, headers()))
def test_any_forwarding_header_makes_loopback_remote(self):
for name in (*PROXY_HEADERS, "X-Forwarded-Anything", "X-Original-URI"):
for spelling in (name, name.lower()):
with self.subTest(header=spelling):
request_headers = headers()
request_headers[spelling] = "127.0.0.1"
self.assertFalse(is_local_request("127.0.0.1", request_headers))
def test_origin_is_required_and_must_be_the_request_origin(self):
# A header-less proxy or curl leaves Origin out; a page on another
# loopback port or scheme sends its own (security review M1, L2).
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=None)))
for origin in (
"",
"null",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://127.0.0.1",
"https://127.0.0.1:8188",
"http://localhost:8188",
"http://foo.localhost:8188",
"http://evil.example:8188",
"http://[bad",
"http://127.0.0.1:8188/path",
"http://user@127.0.0.1:8188",
"file://127.0.0.1:8188",
):
with self.subTest(origin=origin):
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=origin)))
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=ORIGIN), "https"))
def test_sec_fetch_site_must_be_same_origin_when_sent(self):
self.assertTrue(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site="same-origin"))
)
for site in ("same-site", "cross-site", "none", ""):
with self.subTest(site=site):
self.assertFalse(
is_local_request("127.0.0.1", headers(Sec_Fetch_Site=site))
)
def test_foreign_host_is_not_local(self):
# A DNS-rebinding page reaches 127.0.0.1 under its own name.
for host in ("evil.example:8188", "", "[bad", "127.0.0.1:bad"):
with self.subTest(host=host):
self.assertFalse(
is_local_request(
"127.0.0.1", headers(Host=host, Origin=f"http://{host}")
)
)
self.assertFalse(is_local_request("127.0.0.1", CIMultiDict({"Origin": ORIGIN})))
class TestRevealGate(unittest.TestCase):
def test_one_reveal_at_a_time_then_a_cooldown(self):
now = [100.0]
gate = file_reveal.RevealGate(cooldown=1.0, clock=lambda: now[0])
self.assertTrue(gate.try_enter())
self.assertFalse(gate.try_enter())
now[0] += 5
self.assertFalse(gate.try_enter())
gate.leave()
now[0] += 0.5
self.assertFalse(gate.try_enter())
now[0] += 0.5
self.assertTrue(gate.try_enter())
def test_zero_cooldown_only_blocks_while_busy(self):
gate = file_reveal.RevealGate(cooldown=0)
self.assertTrue(gate.try_enter())
self.assertFalse(gate.try_enter())
gate.leave()
self.assertTrue(gate.try_enter())
class TestRevealInFileManager(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(temp.cleanup)
self.root = Path(os.path.realpath(temp.name)) / "out"
self.path = self.root / "sub" / "a.png"
self.path.parent.mkdir(parents=True)
self.path.write_bytes(b"png")
self.popen = patch.object(subprocess, "Popen").start()
self.reaped = patch.object(file_reveal, "_reap").start()
self.addCleanup(patch.stopall)
def reveal(self, path=None):
reveal_in_file_manager(self.path if path is None else path, self.root)
def which(self, available):
def lookup(name, path=None):
self.assertEqual(path, file_reveal.SYSTEM_PATH)
return available.get(name)
return patch.object(file_reveal.shutil, "which", side_effect=lookup)
def argv(self, call=0):
return self.popen.call_args_list[call].args[0]
def assert_detached(self, call=0):
options = self.popen.call_args_list[call].kwargs
for stream in ("stdin", "stdout", "stderr"):
self.assertIs(options[stream], subprocess.DEVNULL)
self.assertIs(options["close_fds"], True)
self.assertIs(options["start_new_session"], True)
self.assertNotIn("shell", options)
self.assertEqual(options["env"]["PATH"], file_reveal.SYSTEM_PATH)
def windows(self, selected=True, startfile=None):
"""Windows without Windows: the shell libraries and os.startfile
injected; the patched shell records its calls."""
shell = FakeShell(select=0 if selected else -2147467259)
select = patch.object(
file_reveal,
"_windows_libraries",
return_value=(shell.ole32, shell.shell32),
)
select.shell = shell
start = patch.object(
file_reveal.os, "startfile", startfile or MagicMock(), create=True
)
platform = patch.object(file_reveal.sys, "platform", "win32")
return select, start, platform
def test_windows_selects_through_the_shell_api_without_a_process(self):
# A comma path splits Explorer's own command line (security review H1);
# the shell API takes the path as one value, unchanged.
path = self.root / "a," / "b,c.png"
path.parent.mkdir()
path.write_bytes(b"png")
select, start, platform = self.windows(selected=True)
with select, start as startfile, platform:
self.reveal(path)
self.assertEqual(dict(select.shell.calls)["ILCreateFromPathW"], (str(path),))
startfile.assert_not_called()
self.popen.assert_not_called()
def test_windows_falls_back_to_opening_the_folder(self):
select, start, platform = self.windows(selected=False)
with select, start as startfile, platform:
self.reveal()
self.assertEqual(
dict(select.shell.calls)["ILCreateFromPathW"], (str(self.path),)
)
startfile.assert_called_once_with(str(self.path.parent), "explore")
self.popen.assert_not_called()
def test_windows_folder_start_failure_raises(self):
select, start, platform = self.windows(
selected=False, startfile=MagicMock(side_effect=OSError("denied"))
)
with select, start, platform, self.assertRaises(OSError):
self.reveal()
self.popen.assert_not_called()
def test_windows_without_startfile_is_unavailable(self):
with (
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
patch.object(file_reveal.sys, "platform", "win32"),
patch.object(file_reveal.os, "startfile", None, create=True),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_macos_reveals_with_the_system_open(self):
with (
patch.object(file_reveal.sys, "platform", "darwin"),
patch.object(file_reveal, "_macos_open", return_value="/usr/bin/open"),
self.which({"open": "/planted/open"}),
):
self.reveal()
self.assertEqual(self.argv(), ["/usr/bin/open", "-R", str(self.path)])
self.assert_detached()
self.popen.return_value.wait.assert_not_called()
self.reaped.assert_called_once_with(self.popen.return_value)
def test_macos_open_is_the_fixed_system_path(self):
self.assertEqual(file_reveal.MACOS_OPEN, "/usr/bin/open")
with patch.object(file_reveal.os.path, "isfile", return_value=False):
self.assertIsNone(file_reveal._macos_open())
with (
patch.object(file_reveal.os.path, "isfile", return_value=True),
patch.object(file_reveal.os, "access", return_value=True),
):
self.assertEqual(file_reveal._macos_open(), "/usr/bin/open")
with (
patch.object(file_reveal.sys, "platform", "darwin"),
patch.object(file_reveal, "_macos_open", return_value=None),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_relative_path_lookup_never_counts(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({"dbus-send": "./dbus-send", "xdg-open": "bin/xdg-open"}),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_posix_opener_lookup_does_not_depend_on_the_host_path_rules(self):
# Python 3.13 on Windows no longer treats "/usr/bin/x" as absolute. The
# Linux and macOS openers are POSIX paths whatever host runs the code,
# so a host os.path with those rules must not reject them.
windows_313 = SimpleNamespace(isabs=lambda value: False)
with (
patch.object(file_reveal.os, "path", windows_313),
self.which({"xdg-open": "/usr/bin/xdg-open", "rel": "bin/rel"}),
):
self.assertEqual(file_reveal._executable("xdg-open"), "/usr/bin/xdg-open")
self.assertIsNone(file_reveal._executable("rel"))
def test_children_get_a_minimal_environment(self):
inherited = {
"PATH": "/venv/bin:/usr/bin",
"PYTHONPATH": "/comfy",
"LD_LIBRARY_PATH": "/venv/lib",
"VIRTUAL_ENV": "/venv",
"CIVITAI_API_KEY": "secret",
"HOME": "/home/u",
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000",
"XDG_CURRENT_DESKTOP": "KDE",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
"LANG": "en_US.UTF-8",
# What a desktop opener can need (final review m1): the X11
# cookie under GDM/SDDM, the session and the user's mimeapps.
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_DIRS": "/usr/share",
}
with patch.dict(os.environ, inherited, clear=True):
env = file_reveal._child_env()
self.assertEqual(
env,
{
"PATH": file_reveal.SYSTEM_PATH,
"HOME": "/home/u",
"DISPLAY": ":0",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1000",
"XDG_CURRENT_DESKTOP": "KDE",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
"LANG": "en_US.UTF-8",
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
"DESKTOP_SESSION": "plasma",
"XDG_SESSION_DESKTOP": "KDE",
"XDG_CONFIG_HOME": "/home/u/.config",
"XDG_DATA_DIRS": "/usr/share",
},
)
self.assertNotIn("/venv", file_reveal.SYSTEM_PATH)
def test_linux_asks_the_file_manager_service_and_waits_for_its_reply(self):
self.popen.return_value.wait.return_value = 0
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/x"}),
):
self.reveal()
self.assertEqual(
self.argv(),
[
"/usr/bin/dbus-send",
"--session",
"--print-reply",
"--reply-timeout=2000",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
f"array:string:{self.path.as_uri()}",
"string:",
],
)
self.assertEqual(self.popen.call_count, 1)
self.popen.return_value.wait.assert_called_once_with(
timeout=file_reveal.DBUS_WAIT
)
self.assert_detached()
self.reaped.assert_not_called()
def test_linux_falls_back_to_xdg_open_on_the_folder(self):
both = {"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/usr/bin/xdg-open"}
cases = {
"missing": ({"xdg-open": "/usr/bin/xdg-open"}, None),
"no reply": (both, 1),
"start error": (both, OSError("no bus")),
"hung": (both, subprocess.TimeoutExpired("dbus-send", 3)),
}
for name, (available, outcome) in cases.items():
with self.subTest(name):
self.popen.reset_mock(side_effect=True, return_value=True)
self.reaped.reset_mock()
dbus = MagicMock()
if isinstance(outcome, OSError):
self.popen.side_effect = [outcome, MagicMock()]
elif isinstance(outcome, subprocess.TimeoutExpired):
dbus.wait.side_effect = [outcome, None]
self.popen.side_effect = [dbus, MagicMock()]
else:
self.popen.return_value.wait.return_value = outcome
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which(available),
):
self.reveal()
self.assertEqual(
self.popen.call_args.args[0],
["/usr/bin/xdg-open", str(self.path.parent)],
)
self.assert_detached(self.popen.call_count - 1)
self.reaped.assert_called_once()
if name == "hung":
dbus.kill.assert_called_once_with()
def test_linux_without_any_opener_is_unavailable(self):
with (
patch.object(file_reveal.sys, "platform", "linux"),
self.which({}),
self.assertRaises(OpenerUnavailable),
):
self.reveal()
self.popen.assert_not_called()
def test_relative_paths_are_refused(self):
with self.assertRaises(ValueError):
reveal_in_file_manager(Path("sub/a.png"), self.root)
self.popen.assert_not_called()
def test_containment_is_rechecked_just_before_launching(self):
# A parent swapped for a symlink after the route resolved the path
# (security review L4) must not open a folder outside the root.
outside = self.root.parent / "elsewhere"
outside.mkdir()
(outside / "a.png").write_bytes(b"png")
(self.root / "sub" / "a.png").unlink()
(self.root / "sub").rmdir()
try:
os.symlink(outside, self.root / "sub", target_is_directory=True)
except (OSError, NotImplementedError):
self.skipTest("symlinks unavailable")
missing = self.root / "gone.png"
folder = self.root / "folder.png"
folder.mkdir()
for path in (self.path, missing, folder):
for platform in ("linux", "darwin", "win32"):
with (
self.subTest(path=path.name, platform=platform),
patch.object(file_reveal.sys, "platform", platform),
patch.object(file_reveal, "_windows_libraries") as shell,
self.assertRaises(file_reveal.RevealTargetChanged),
):
self.reveal(path)
shell.assert_not_called()
self.popen.assert_not_called()
def test_a_path_on_another_drive_is_not_contained(self):
# Windows: commonpath raises ValueError across drives.
with (
patch.object(file_reveal.os.path, "commonpath", side_effect=ValueError),
self.assertRaises(file_reveal.RevealTargetChanged),
):
self.reveal()
self.popen.assert_not_called()
class TestReaping(unittest.TestCase):
def test_reap_waits_on_a_daemon_thread(self):
process = MagicMock()
with patch.object(file_reveal.threading, "Thread") as thread:
file_reveal._reap(process)
thread.assert_called_once()
kwargs = thread.call_args.kwargs
self.assertIs(kwargs["target"], process.wait)
self.assertIs(kwargs["daemon"], True)
thread.return_value.start.assert_called_once_with()
def test_reveal_runs_on_its_own_single_worker(self):
executor = file_reveal.reveal_executor()
self.assertIs(file_reveal.reveal_executor(), executor)
self.assertEqual(executor._max_workers, 1)
name = executor.submit(lambda: file_reveal.threading.current_thread().name)
self.assertTrue(name.result(timeout=5).startswith("pm-reveal"))
class FakeShell:
"""ole32 and shell32 stand-ins recording the shell calls (H1)."""
def __init__(self, init=0, pidl=1234, select=0):
self.calls = []
self.ole32 = types.SimpleNamespace(
CoInitializeEx=self.fn("CoInitializeEx", init),
CoUninitialize=self.fn("CoUninitialize", None),
)
self.shell32 = types.SimpleNamespace(
ILCreateFromPathW=self.fn("ILCreateFromPathW", pidl),
SHOpenFolderAndSelectItems=self.fn("SHOpenFolderAndSelectItems", select),
ILFree=self.fn("ILFree", None),
)
def fn(self, name, result):
def call(*args):
self.calls.append((name, args))
if isinstance(result, Exception):
raise result
return result
return MagicMock(side_effect=call)
def names(self):
return [name for name, _ in self.calls]
class TestWindowsReveal(unittest.TestCase):
"""The Windows opener with the shell libraries and os.startfile faked.
The folder fallback is recorded in the same call list, so its place
inside the COM bracket is visible (final review m2)."""
def reveal(self, shell, path=r"C:\out\a,\b.png"):
def startfile(*args):
shell.calls.append(("startfile", args))
with (
patch.object(
file_reveal,
"_windows_libraries",
return_value=(shell.ole32, shell.shell32),
),
patch.object(
file_reveal.os,
"startfile",
MagicMock(side_effect=startfile),
create=True,
),
):
file_reveal._reveal_windows(Path(path))
return shell
def test_selects_the_item_and_frees_everything(self):
path = r"C:\out\a,\Windows\System32\calc.exe,b\x.png"
shell = self.reveal(FakeShell(), path)
self.assertEqual(
shell.names(),
[
"CoInitializeEx",
"ILCreateFromPathW",
"SHOpenFolderAndSelectItems",
"ILFree",
"CoUninitialize",
],
)
self.assertEqual(dict(shell.calls)["ILCreateFromPathW"], (str(Path(path)),))
self.assertEqual(
dict(shell.calls)["SHOpenFolderAndSelectItems"], (1234, 0, None, 0)
)
self.assertEqual(dict(shell.calls)["ILFree"], (1234,))
def test_declares_the_shell_signatures(self):
shell = self.reveal(FakeShell())
create = shell.shell32.ILCreateFromPathW
self.assertEqual(create.argtypes, [ctypes.c_wchar_p])
self.assertIs(create.restype, ctypes.c_void_p)
open_select = shell.shell32.SHOpenFolderAndSelectItems
self.assertEqual(
open_select.argtypes,
[ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_ulong],
)
self.assertIs(open_select.restype, ctypes.c_long)
self.assertEqual(shell.shell32.ILFree.argtypes, [ctypes.c_void_p])
self.assertEqual(
shell.ole32.CoInitializeEx.argtypes, [ctypes.c_void_p, ctypes.c_ulong]
)
self.assertIs(shell.ole32.CoInitializeEx.restype, ctypes.c_long)
def test_already_initialized_com_is_used_and_left_alone(self):
shell = self.reveal(FakeShell(init=file_reveal.RPC_E_CHANGED_MODE))
self.assertIn("SHOpenFolderAndSelectItems", shell.names())
self.assertNotIn("CoUninitialize", shell.names())
self.assertNotIn("startfile", shell.names())
shell = self.reveal(FakeShell(init=file_reveal.S_FALSE))
self.assertNotIn("startfile", shell.names())
self.assertEqual(shell.names()[-1], "CoUninitialize")
def test_the_folder_fallback_runs_inside_com(self):
# ShellExecute callers should initialize COM first (final review m2).
for name, shell in {
"no pidl": FakeShell(pidl=None),
"select failed": FakeShell(select=-2147467259),
"raises": FakeShell(select=OSError("access violation")),
}.items():
with self.subTest(name):
names = self.reveal(shell).names()
self.assertEqual(names[0], "CoInitializeEx")
self.assertEqual(names[-2:], ["startfile", "CoUninitialize"])
if name != "no pidl":
self.assertIn("ILFree", names)
def test_failed_com_still_opens_the_folder_without_balancing(self):
for init in (-2147024882, OSError("no ole32 export")):
with self.subTest(init=init):
names = self.reveal(FakeShell(init=init)).names()
self.assertEqual(names, ["CoInitializeEx", "startfile"])
def test_missing_libraries_open_the_folder(self):
startfile = MagicMock()
with (
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
patch.object(file_reveal.os, "startfile", startfile, create=True),
):
file_reveal._reveal_windows(Path(r"C:\out\a.png"))
startfile.assert_called_once_with(str(Path(r"C:\out\a.png").parent), "explore")
def test_libraries_are_unavailable_off_windows(self):
if sys.platform == "win32":
self.skipTest("loads the real shell libraries on Windows")
with self.assertRaises((AttributeError, OSError)):
file_reveal._windows_libraries()
@unittest.skipUnless(sys.platform == "win32", "the real Windows shell libraries")
class TestRealWindowsShell(unittest.TestCase):
"""Final review I1: the declared signatures against the real ole32 and
shell32, without SHOpenFolderAndSelectItems (no window opens on CI)."""
def setUp(self):
self.ole32, self.shell32 = file_reveal._windows_libraries()
file_reveal._declare_shell_signatures(self.ole32, self.shell32)
def test_a_pidl_is_created_for_a_real_file_and_freed(self):
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as folder:
path = Path(folder) / "a,b.png"
path.write_bytes(b"png") # write_bytes closes its handle
pidl = self.shell32.ILCreateFromPathW(str(path))
self.assertIsInstance(pidl, int)
self.assertNotEqual(pidl, 0)
self.shell32.ILFree(pidl)
self.assertIsNone(self.shell32.ILCreateFromPathW(str(path) + ".gone"))
path.unlink()
def probe(self):
"""CoInitializeEx's answer on this thread, balanced at once."""
result = self.ole32.CoInitializeEx(
None,
file_reveal.COINIT_APARTMENTTHREADED | file_reveal.COINIT_DISABLE_OLE1DDE,
)
if result in (file_reveal.S_OK, file_reveal.S_FALSE):
self.ole32.CoUninitialize()
return result
def test_com_is_entered_and_balanced_through_the_module_helper(self):
before = self.probe()
with file_reveal._com_apartment(self.ole32) as ready:
self.assertIs(ready, True)
# An unbalanced bracket would leave COM initialized: S_FALSE after S_OK.
self.assertEqual(self.probe(), before)
class TestRevealRoute(ImageAPITestCase):
async def get_application(self):
app = await super().get_application()
self.opened, self.roots, self.threads = [], [], []
self.outcome = None
def opener(path, root):
self.threads.append(file_reveal.threading.current_thread().name)
if self.outcome is not None:
raise self.outcome
self.opened.append(path)
self.roots.append(root)
self.api._reveal_opener = opener
self.api._reveal_gate = file_reveal.RevealGate(cooldown=0)
return app
def origin(self):
return str(self.client.make_url("/").origin())
def scope(self, index=0):
roots = self.api._get_all_output_dirs()
return self.api._gallery_root_scope(roots[index], index)
async def post(self, body, **request_headers):
# Like the workspace's fetch: a same-origin JSON POST.
sent = {"Origin": self.origin(), **request_headers}
sent = {key: value for key, value in sent.items() if value is not None}
response = await self.client.post(REVEAL, json=body, headers=sent)
return response.status, await response.json()
def body(self, path="sub/a.png", **changes):
return {"root_index": 0, "root_scope": self.scope(), "path": path, **changes}
async def test_loopback_request_opens_the_resolved_file(self):
target = make_png(self.output_dir / "sub" / "a.png")
status, data = await self.post(self.body())
self.assertEqual((status, data), (200, {"success": True}))
self.assertEqual(self.opened, [Path(os.path.realpath(target))])
self.assertEqual(self.roots, [self.output_dir])
# Its own worker, never ComfyUI's shared default executor (L1).
self.assertTrue(self.threads[0].startswith("pm-reveal"), self.threads)
async def test_a_lora_root_reveals_only_linked_files(self):
# #355 security review: a LoRA root exposes only the files the import
# linked; any other file under it is refused before the opener runs.
lora = Path(tempfile.mkdtemp()).resolve()
self.addCleanup(shutil.rmtree, lora, True)
self.api._lora_image_dirs = lambda: [lora]
linked = make_png(lora / "abc" / "cover.png")
make_png(lora / "abc" / "unlinked.png")
self.api.db.link_image_to_prompt(self._save_prompt("lora"), str(linked))
scope = self.api._gallery_root_scope(lora, 1)
status, _data = await self.post(
{"root_index": 1, "root_scope": scope, "path": "abc/unlinked.png"}
)
self.assertEqual(status, 403)
self.assertEqual(self.opened, [])
status, data = await self.post(
{"root_index": 1, "root_scope": scope, "path": "abc/cover.png"}
)
self.assertEqual((status, data), (200, {"success": True}))
self.assertEqual(self.opened, [Path(os.path.realpath(linked))])
# The file that would open is checked too, whatever the request named
# (a path anchor could resolve it elsewhere in the root).
other = make_png(lora / "abc" / "other.png")
from py.api.images import ImageRoutesMixin
with patch.object(
ImageRoutesMixin, "_resolve_client_media_path", return_value=other
):
status, _data = await self.post(
{"root_index": 1, "root_scope": scope, "path": "abc/cover.png"}
)
self.assertEqual(status, 403)
self.assertEqual(len(self.opened), 1)
async def test_target_changed_before_launch_is_404(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = file_reveal.RevealTargetChanged()
status, data = await self.post(self.body())
self.assertEqual(status, 404)
self.assertEqual(data, {"success": False, "error": "Image file not found"})
async def test_non_loopback_remote_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
with patch.object(
web.BaseRequest, "remote", new_callable=PropertyMock
) as remote:
remote.return_value = "192.168.1.20"
status, data = await self.post(self.body())
self.assertEqual(status, 403)
self.assertEqual(data["code"], "not-local")
self.assertIs(data["success"], False)
self.assertEqual(self.opened, [])
async def test_loopback_with_a_proxy_header_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
for name in PROXY_HEADERS:
with self.subTest(header=name):
status, data = await self.post(self.body(), **{name: "127.0.0.1"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, data = await self.post(self.body(), Host="evil.example:8188")
self.assertEqual((status, data["code"]), (403, "not-local"))
self.assertEqual(self.opened, [])
async def test_missing_or_foreign_origin_is_not_local(self):
make_png(self.output_dir / "sub" / "a.png")
port = self.client.make_url("/").port
for origin in (None, f"http://127.0.0.1:{port + 1}", "null"):
with self.subTest(origin=origin):
status, data = await self.post(self.body(), Origin=origin)
self.assertEqual((status, data["code"]), (403, "not-local"))
status, data = await self.post(self.body(), **{"Sec-Fetch-Site": "same-site"})
self.assertEqual((status, data["code"]), (403, "not-local"))
status, _ = await self.post(self.body(), **{"Sec-Fetch-Site": "same-origin"})
self.assertEqual(status, 200)
self.assertEqual(len(self.opened), 1)
async def test_only_json_bodies_are_accepted(self):
# text/plain would be a CORS "simple" request with no preflight (L2).
make_png(self.output_dir / "sub" / "a.png")
body = json.dumps(self.body())
for content_type in ("text/plain", "application/x-www-form-urlencoded", None):
with self.subTest(content_type=content_type):
sent = {"Origin": self.origin()}
if content_type:
sent["Content-Type"] = content_type
response = await self.client.post(
REVEAL, data=body.encode(), headers=sent
)
self.assertEqual(response.status, 415)
data = await response.json()
self.assertEqual(
(data["success"], data["code"]), (False, "unsupported-media-type")
)
self.assertEqual(self.opened, [])
async def test_a_reveal_in_progress_or_cooling_down_is_busy(self):
make_png(self.output_dir / "sub" / "a.png")
gate = self.api._reveal_gate
self.assertTrue(gate.try_enter())
status, data = await self.post(self.body())
self.assertEqual(status, 429)
self.assertEqual(data, {**data, "success": False, "code": "busy"})
gate.leave()
now = [50.0]
self.api._reveal_gate = file_reveal.RevealGate(
cooldown=1.0, clock=lambda: now[0]
)
statuses = [(await self.post(self.body()))[0] for _ in range(2)]
now[0] += 1.0
statuses.append((await self.post(self.body()))[0])
self.assertEqual(statuses, [200, 429, 200])
self.assertEqual(len(self.opened), 2)
async def test_a_failed_reveal_releases_the_gate(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OSError("cannot start")
status, _ = await self.post(self.body())
self.assertEqual(status, 500)
self.outcome = None
status, _ = await self.post(self.body())
self.assertEqual(status, 200)
async def test_default_gate_is_one_per_process_with_a_cooldown(self):
gate = type(self.api)._reveal_gate
self.assertIsInstance(gate, file_reveal.RevealGate)
self.assertEqual(gate.cooldown, file_reveal.REVEAL_COOLDOWN)
self.assertGreaterEqual(file_reveal.REVEAL_COOLDOWN, 1.0)
async def test_resolution_errors_are_404_and_never_log_the_path(self):
# ENAMETOOLONG and friends must not escape as a non-JSON 500 (L3).
make_png(self.output_dir / "sub" / "a.png")
secret = "sub/" + "n" * 300 + "/a.png"
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
self.api.logger.debug("start")
status, data = await self.post(self.body(path=secret))
self.assertEqual(status, 404)
self.assertEqual(data, {"success": False, "error": "Image file not found"})
with patch.object(
type(self.api),
"_reveal_path",
staticmethod(MagicMock(side_effect=OSError(36, "too long", secret))),
):
status, data = await self.post(self.body(path=secret))
self.assertEqual(status, 404)
self.assertIs(data["success"], False)
for record in logs.records:
if record.levelno >= logging.WARNING:
self.assertNotIn("n" * 300, record.getMessage())
self.assertEqual(self.opened, [])
async def test_start_failure_never_logs_the_path_at_error_level(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OSError(2, "No such file", str(self.output_dir / "sub"))
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
status, _ = await self.post(self.body())
self.assertEqual(status, 500)
errors = [r for r in logs.records if r.levelno >= logging.ERROR]
self.assertTrue(errors)
for record in errors:
self.assertNotIn(str(self.output_dir), record.getMessage())
self.assertIsNone(record.exc_info)
async def test_changed_or_missing_scope_is_rejected(self):
make_png(self.output_dir / "sub" / "a.png")
status, data = await self.post(self.body(root_scope="0" * 64))
self.assertEqual((status, data["code"]), (409, "source-changed"))
status, data = await self.post(self.body(root_index=3))
self.assertEqual((status, data["code"]), (409, "source-changed"))
for scope in (None, "ABC", 7, "g" * 64):
with self.subTest(scope=scope):
status, _ = await self.post(self.body(root_scope=scope))
self.assertEqual(status, 400)
body = self.body()
del body["root_scope"]
status, _ = await self.post(body)
self.assertEqual(status, 400)
self.assertEqual(self.opened, [])
async def test_body_types_are_strict(self):
make_png(self.output_dir / "sub" / "a.png")
for changes in (
{"root_index": True},
{"root_index": "0"},
{"root_index": -1},
{"root_index": 0.0},
{"path": 5},
{"path": ""},
{"path": "a" * 5000 + ".png"},
{"path": "sub/a\0.png"},
):
with self.subTest(changes=changes):
status, data = await self.post(self.body(**changes))
self.assertEqual(status, 400, data)
response = await self.client.post(
REVEAL,
data="[1]",
headers={"Origin": self.origin(), "Content-Type": "application/json"},
)
self.assertEqual(response.status, 400)
self.assertEqual(self.opened, [])
async def test_traversal_and_absolute_paths_are_rejected(self):
make_png(self.output_dir / "sub" / "a.png")
outside = make_png(self.comfy_dir / "outside.png")
for path in (
"../outside.png",
"sub/../../outside.png",
"sub\\..\\..\\outside.png",
str(outside),
"/" + outside.name,
"C:/outside.png",
"C:outside.png",
):
with self.subTest(path=path):
status, data = await self.post(self.body(path=path))
self.assertEqual(status, 400, data)
self.assertEqual(self.opened, [])
async def test_symlink_out_of_the_root_is_refused(self):
outside = make_png(self.comfy_dir / "outside.png")
self._make_symlink(outside, self.output_dir / "link.png")
status, _ = await self.post(self.body(path="link.png"))
self.assertIn(status, (403, 404))
self.assertEqual(self.opened, [])
async def test_missing_non_media_and_non_regular_files(self):
(self.output_dir / "folder.png").mkdir()
(self.output_dir / "notes.txt").write_text("x")
for path, expected in (
("sub/missing.png", 404),
("folder.png", 404),
("notes.txt", 403),
):
with self.subTest(path=path):
status, data = await self.post(self.body(path=path))
self.assertEqual(status, expected, data)
self.assertIs(data["success"], False)
self.assertEqual(self.opened, [])
async def test_unavailable_opener_is_501_and_start_failure_500(self):
make_png(self.output_dir / "sub" / "a.png")
self.outcome = OpenerUnavailable()
status, data = await self.post(self.body())
self.assertEqual(status, 501)
self.assertEqual(data, {**data, "success": False, "code": "unavailable"})
self.outcome = OSError("cannot start")
status, data = await self.post(self.body())
self.assertEqual(status, 500)
self.assertIs(data["success"], False)
self.assertNotIn("cannot start", data["error"])
async def test_default_opener_is_the_platform_file_manager(self):
self.assertIs(type(self.api)._reveal_opener, file_reveal.reveal_in_file_manager)
if __name__ == "__main__":
unittest.main()