* test(inspector): concept comparison for the panel and viewer details Compare the Images side panel and the viewer's full details with the concept (1600x1100 and 390x844, both themes): structure, tokens, geometry and controls, plus copy, the acknowledged pair send, the tag chip filter and Prompt history. mapInspectorConcept applies only D3, D20, D21 and D22; a mutation test shows the comparison notices a changed role label and a removed footer. The comparison showed the role copy and send names lacked the concept's ": <title>" subject, so the inspector now passes its title to the prompt blocks. It also showed that a recipe-linked image shows its recipe's own category and tags (#224), which the concept fixture's recipes lack; that is recorded as D38, and the fixture's new positive_links option links the compared image to its positive alone. * docs(inspector): renumber the recipe-category decision to D39 * feat(images): local-only Show in folder route POST /prompt_manager/images/reveal takes the listing identity {root_index, root_scope, path} and opens this computer's file manager at that file. It answers 403 not-local unless the client is loopback, no proxy header is present and Host/Origin name this computer; the scope is checked with _gallery_scope_error (409) and the path resolves only inside that root to a regular media file. Openers run as argv lists with an absolute executable, no shell and DEVNULL streams (D38). * feat(inspector): Show in folder after the File & provenance path The viewer's File & provenance offers "Show in folder" right after the Path value for a listed file only, POSTs its listing identity and toasts the outcome (opened, not-local, unavailable or failed). The browser test server stubs the opener; TESTING.md records the decision as D38. * fix(images): keep the discovery alert clear of the Rescan toast A rescan that leaves the Images discovery alert visible could show its fixed bottom toast over the alert on a short window. The concept's toast keeps its placement; the production-only alert now scrolls into view only as far as needed, with a 96px block-end scroll margin (D42). * docs(inspector): concept panel and viewer details * fix(images): open Windows folders through the shell API, not an Explorer command line Explorer splits its own command line on commas, so a gallery path such as output\a,\Windows\System32\calc.exe,b\x.png escaped the roots through [explorer, "/select,", path] (security review H1). Windows now selects the file with ILCreateFromPathW and SHOpenFolderAndSelectItems, and on any shell failure falls back to os.startfile on the folder. No process is started. * fix(images): re-check, isolate and reap the Show in folder opener - re-check that the real path is still a regular file inside its root right before launching; a changed target answers 404 (review L4) - run openers on a dedicated single worker, not ComfyUI's default executor, and make dbus-send wait for the FileManager1 reply (--print-reply --reply-timeout=2000) so a missing service falls back to xdg-open (L1) - reap launched processes on a daemon thread, give them a minimal environment with a fixed system PATH, and use /usr/bin/open on macOS and dbus-send/xdg-open resolved only on that PATH (L5) * fix(images): accept Show in folder only as a same-origin JSON request, one at a time - require an Origin equal to the request's own scheme://host:port and, when sent, Sec-Fetch-Site: same-origin; refuse every forwarding-style header (Via, X-Forwarded-*, X-Original-*, *-Client-IP, CF-Connecting-IP and the rest) as remote (security review M1, L2) - answer a non-JSON body with 415, so a cross-origin request always needs a CORS preflight (L2) - single flight plus a 1 s cooldown per process: 429 code busy (L1) - path resolution errors such as ENAMETOOLONG are a JSON 404, and neither that nor a start failure logs the client's path at error level (L3) * fix(inspector): say the folder is already opening on a busy Show in folder A 429 busy from the reveal route now toasts "The folder is already opening." The browser workflow checks that the reveal POST carries the page's own Origin and an application/json body, and TESTING.md D38 records the tightened route, the toast table and the accepted residual risk of a header-less local proxy. * test(inspector): compare the viewer details under a coarse pointer and prove its mutations The touch project measured production's D34 40px disclosure targets against the concept's fine-pointer summaries. The concept's plain-chip copy now also carries the recorded D34 coarse rule, so both documents are measured under the same emulation. The open File & provenance is compared too (D38 Show in folder and D20 Download original mapped), and the mutation check now removes the viewer's pair actions and Show in folder. * refactor(inspector): attach Show in folder to the Path value it follows The button was placed on the second fact row by position; the Path dd is now built by the caller and kept, so reordering the facts cannot move it. * test(inspector): let the uncertain-file send check fail again Inspector send buttons are named "... to ComfyUI: <title>", so the anchored regex could never match and the no-send assertion always passed. * fix(images): run the Windows folder fallback inside COM and keep desktop session variables Show in folder's os.startfile fallback now runs inside the same CoInitializeEx/CoUninitialize bracket as the shell selection, as ShellExecute callers should. The Linux opener keeps XAUTHORITY, DESKTOP_SESSION, XDG_SESSION_DESKTOP and XDG_CONFIG_HOME, so a file manager can reach the X11 display under GDM or SDDM and honour the user's mimeapps.list. A Windows-only test calls the real ILCreateFromPathW and ILFree on a temp file and pairs CoInitializeEx with CoUninitialize through the module's helper, without SHOpenFolderAndSelectItems, so no window opens on CI. * fix(images): scroll to the rescan's discovery alert only when the toast band covers it D42 scrolled the alert into view after every rescan, so a user scrolled down the grid was sent back to the top. It now scrolls only when the alert intersects the toast's band (its scroll-margin-block-end). * refactor(inspector): name prompt actions as PR #263 does The title option keeps its name, default and output ("Copy positive prompt: <title>") and now uses #263's actionName helper; the region-name arrow no longer shadows the title option. * test(inspector): the comparison opens Filters by its D44 active-count name After a tag chip applies its filter, the shared toolbar's Filters button reads "Filters · 1" (D44, #263), so the comparison opens it by that name. * fix(images): POSIX opener paths stay absolute under Python 3.13 on Windows Python 3.13 on Windows no longer counts "/usr/bin/x" as absolute, so the faked-Linux reveal tests failed on windows-latest py3.13 (the Linux and macOS openers are POSIX paths whatever host evaluates them). Use posixpath.isabs for those lookups.
920 lines
38 KiB
Python
920 lines
38 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 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_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()
|