From 8db544b4d09cbbc3244def8dd78001507e4ddb04 Mon Sep 17 00:00:00 2001 From: Dusk1e Date: Wed, 8 Apr 2026 16:44:25 +0300 Subject: [PATCH] fix(clipboard): reject non-png clipboard images when png normalization fails --- hermes_cli/clipboard.py | 20 ++++++++++++--- tests/tools/test_clipboard.py | 47 ++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index facc8f3c50a..a782c876b26 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -22,6 +22,7 @@ from pathlib import Path from hermes_constants import is_wsl as _is_wsl logger = logging.getLogger(__name__) +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" def save_clipboard_image(dest: Path) -> bool: @@ -378,10 +379,13 @@ def _wayland_save(dest: Path) -> bool: dest.unlink(missing_ok=True) return False - # BMP needs conversion to PNG (common in WSLg where only BMP - # is bridged from Windows clipboard via RDP). - if mime == "image/bmp": - return _convert_to_png(dest) + # save_clipboard_image() promises a PNG output path. Wayland can offer + # JPEG/GIF/WebP/BMP payloads, so normalize every non-PNG result before + # returning success. + if mime != "image/png": + if not _convert_to_png(dest) or not _is_png_file(dest): + dest.unlink(missing_ok=True) + return False return True @@ -433,6 +437,14 @@ def _convert_to_png(path: Path) -> bool: return path.exists() and path.stat().st_size > 0 +def _is_png_file(path: Path) -> bool: + """Return True when *path* starts with the PNG file signature.""" + try: + return path.read_bytes().startswith(_PNG_SIGNATURE) + except OSError: + return False + + # ── X11 (xclip) ───────────────────────────────────────────────────────── def _xclip_has_image() -> bool: diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index 90e2ea847f8..750874400c4 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -39,6 +39,7 @@ from cli import _should_auto_attach_clipboard_image_on_paste FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 FAKE_BMP = b"BM" + b"\x00" * 100 +FAKE_JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 100 # ═════════════════════════════════════════════════════════════════════════ @@ -393,9 +394,53 @@ class TestWaylandSave: if "stdout" in kw and hasattr(kw["stdout"], "write"): kw["stdout"].write(FAKE_BMP) return MagicMock(returncode=0) + + def fake_convert(path): + assert path == dest + path.write_bytes(FAKE_PNG) + return True + + with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): + with patch("hermes_cli.clipboard._convert_to_png", side_effect=fake_convert): + assert _wayland_save(dest) is True + + def test_jpeg_extraction_converts_to_real_png(self, tmp_path): + dest = tmp_path / "out.png" + + def fake_run(cmd, **kw): + if "--list-types" in cmd: + return MagicMock(stdout="image/jpeg\ntext/plain\n", returncode=0) + if "stdout" in kw and hasattr(kw["stdout"], "write"): + kw["stdout"].write(FAKE_JPEG) + return MagicMock(returncode=0) + + def fake_convert(path): + assert path == dest + path.write_bytes(FAKE_PNG) + return True + + with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): + with patch("hermes_cli.clipboard._convert_to_png", side_effect=fake_convert) as mock_convert: + assert _wayland_save(dest) is True + + mock_convert.assert_called_once_with(dest) + assert dest.read_bytes() == FAKE_PNG + + def test_non_png_conversion_failure_cleans_up(self, tmp_path): + dest = tmp_path / "out.png" + + def fake_run(cmd, **kw): + if "--list-types" in cmd: + return MagicMock(stdout="image/jpeg\n", returncode=0) + if "stdout" in kw and hasattr(kw["stdout"], "write"): + kw["stdout"].write(FAKE_JPEG) + return MagicMock(returncode=0) + with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run): with patch("hermes_cli.clipboard._convert_to_png", return_value=True): - assert _wayland_save(dest) is True + assert _wayland_save(dest) is False + + assert not dest.exists() def test_no_image_types(self, tmp_path): dest = tmp_path / "out.png"