From fbba42ebe9a1be1d298ef2847d58545a298a31dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 21:10:25 +0000 Subject: [PATCH 1/2] Replace bare assertions with proper error handling; add crypto tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five bare `assert` statements were used as production error guards across main.py, syncthing.py, and ui.py. These silently become no-ops under `python -O` and produce unhelpful AssertionError tracebacks otherwise. Replaced each with an explicit `if ... raise RuntimeError(...)` (fatal invariant violations) or an early `return` with a logged error (recoverable caller paths). Adds tests/test_crypto.py with 18 tests covering encrypt/decrypt round-trips, wrong-passphrase rejection, v0 legacy format compatibility, truncated/garbage input, and is_encrypted — the crypto module previously had no dedicated test coverage. https://claude.ai/code/session_01BULAPFvuh92QBWZboAuSL2 --- clipsync/main.py | 7 ++- clipsync/syncthing.py | 3 +- clipsync/ui.py | 6 +- tests/test_crypto.py | 131 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 tests/test_crypto.py diff --git a/clipsync/main.py b/clipsync/main.py index eaee4d4..e03013a 100644 --- a/clipsync/main.py +++ b/clipsync/main.py @@ -152,7 +152,8 @@ def start(self) -> None: self._start_syncthing_with_retry() - assert self.syncthing.client is not None + if self.syncthing.client is None: + raise RuntimeError("Syncthing started but REST client was not initialized") self.clipboard = ClipboardSync(self.settings) self.clipboard.start() @@ -309,7 +310,9 @@ def _on_pending_device(self, device_id: str, info: dict[str, object]) -> None: ) def _accept_device(self, device_id: str) -> None: - assert self.syncthing.client is not None + if self.syncthing.client is None: + log.error("Cannot accept device %s: Syncthing client not available", device_id) + return info: dict[str, object] with self._pending_lock: info = self._pending.pop(device_id, {}) diff --git a/clipsync/syncthing.py b/clipsync/syncthing.py index 81da5e1..4580ce7 100644 --- a/clipsync/syncthing.py +++ b/clipsync/syncthing.py @@ -712,7 +712,8 @@ def start(self) -> None: self._monitor.start() def _spawn(self) -> None: - assert self._binary is not None + if self._binary is None: + raise RuntimeError("Cannot spawn Syncthing: binary path not set") with self._lock: old = self._proc if old is not None and old.poll() is None: diff --git a/clipsync/ui.py b/clipsync/ui.py index 835b5db..bcaab81 100644 --- a/clipsync/ui.py +++ b/clipsync/ui.py @@ -87,7 +87,8 @@ def open(self, window: str) -> None: def _read_events(self, proc: subprocess.Popen[str]) -> None: try: - assert proc.stdout is not None + if proc.stdout is None: + raise RuntimeError("UI subprocess has no stdout pipe") for line in proc.stdout: line = line.strip() if not line: @@ -1020,7 +1021,8 @@ def _finish_update_check(self, info: update.UpdateInfo | None, error: str | None if error is not None: self._status.configure(text=error) return - assert info is not None + if info is None: + return if not info.update_available: self._status.configure(text=f"You're up to date (v{info.current_version}).") return diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 0000000..cf681d5 --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,131 @@ +"""Tests for clipboard encryption helpers (crypto.py).""" + +from __future__ import annotations + +import pytest + +from clipsync import crypto +from clipsync.crypto import _ENC_MAGIC_V0, _ENC_MAGIC_V1, _LEGACY_SALT, _derive_key + + +# --------------------------------------------------------------------------- +# Encrypt / decrypt round-trip +# --------------------------------------------------------------------------- + + +def test_roundtrip_text() -> None: + plaintext = b"hello world" + assert crypto.decrypt(crypto.encrypt(plaintext, "secret"), "secret") == plaintext + + +def test_roundtrip_binary() -> None: + data = bytes(range(256)) + assert crypto.decrypt(crypto.encrypt(data, "pass"), "pass") == data + + +def test_roundtrip_empty_bytes() -> None: + assert crypto.decrypt(crypto.encrypt(b"", "pw"), "pw") == b"" + + +def test_roundtrip_unicode_passphrase() -> None: + plaintext = b"data" + passphrase = "pässwörd\U0001f511" + assert crypto.decrypt(crypto.encrypt(plaintext, passphrase), passphrase) == plaintext + + +# --------------------------------------------------------------------------- +# Wrong passphrase +# --------------------------------------------------------------------------- + + +def test_wrong_passphrase_returns_none() -> None: + ciphertext = crypto.encrypt(b"secret data", "correct") + assert crypto.decrypt(ciphertext, "wrong") is None + + +def test_empty_passphrase_wrong_returns_none() -> None: + ciphertext = crypto.encrypt(b"data", "notempty") + assert crypto.decrypt(ciphertext, "") is None + + +# --------------------------------------------------------------------------- +# V1 format properties +# --------------------------------------------------------------------------- + + +def test_encrypt_produces_v1_magic() -> None: + ct = crypto.encrypt(b"x", "pw") + assert ct.startswith(_ENC_MAGIC_V1) + + +def test_v1_uses_random_salt_per_call() -> None: + ct1 = crypto.encrypt(b"same", "pw") + ct2 = crypto.encrypt(b"same", "pw") + assert ct1 != ct2 + + +# --------------------------------------------------------------------------- +# V0 legacy format (backward compatibility) +# --------------------------------------------------------------------------- + + +def _make_v0_payload(plaintext: bytes, passphrase: str) -> bytes: + from cryptography.fernet import Fernet + + key = _derive_key(passphrase, _LEGACY_SALT) + token = Fernet(key).encrypt(plaintext) + return _ENC_MAGIC_V0 + token + + +def test_v0_legacy_decrypt() -> None: + payload = _make_v0_payload(b"legacy data", "oldpass") + assert crypto.decrypt(payload, "oldpass") == b"legacy data" + + +def test_v0_wrong_passphrase_returns_none() -> None: + payload = _make_v0_payload(b"data", "correct") + assert crypto.decrypt(payload, "wrong") is None + + +# --------------------------------------------------------------------------- +# Truncated / malformed input +# --------------------------------------------------------------------------- + + +def test_truncated_v1_header_returns_none() -> None: + # V1 magic + salt that is too short (no token) + short = _ENC_MAGIC_V1 + b"\x00" * 5 + assert crypto.decrypt(short, "pw") is None + + +def test_garbage_returns_none() -> None: + assert crypto.decrypt(b"not encrypted at all", "pw") is None + + +def test_empty_bytes_returns_none() -> None: + assert crypto.decrypt(b"", "pw") is None + + +def test_partial_magic_returns_none() -> None: + assert crypto.decrypt(b"CSEN", "pw") is None + + +# --------------------------------------------------------------------------- +# is_encrypted +# --------------------------------------------------------------------------- + + +def test_is_encrypted_v1() -> None: + assert crypto.is_encrypted(crypto.encrypt(b"x", "pw")) is True + + +def test_is_encrypted_v0() -> None: + assert crypto.is_encrypted(_make_v0_payload(b"x", "pw")) is True + + +def test_is_encrypted_plaintext() -> None: + assert crypto.is_encrypted(b"plain text clipboard") is False + + +def test_is_encrypted_empty() -> None: + assert crypto.is_encrypted(b"") is False From 442ae2e22035aa14d5cf25e1cedad4639eeffe23 Mon Sep 17 00:00:00 2001 From: Slowe <83889256+offbyonebit@users.noreply.github.com> Date: Wed, 27 May 2026 15:48:36 -0500 Subject: [PATCH 2/2] fix: resolve ruff and mypy lint errors --- clipsync/clipboard.py | 2 +- clipsync/main.py | 2 +- clipsync/ui.py | 2 +- tests/test_crypto.py | 3 --- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/clipsync/clipboard.py b/clipsync/clipboard.py index 9696387..05dc457 100644 --- a/clipsync/clipboard.py +++ b/clipsync/clipboard.py @@ -146,7 +146,7 @@ def __init__(self, settings: config.Settings) -> None: self._poll_thread: threading.Thread | None = None self._in_thread: threading.Thread | None = None self._in_queue: queue.SimpleQueue[str] = queue.SimpleQueue() - self._observer: Observer | None = None # type: ignore[valid-type] + self._observer: Observer | None = None self._last_synced: str | bytes | None = None self._lock = threading.Lock() self._last_read_error: str | None = None diff --git a/clipsync/main.py b/clipsync/main.py index e03013a..16148ef 100644 --- a/clipsync/main.py +++ b/clipsync/main.py @@ -110,7 +110,7 @@ def _thread_safe_update_menu() -> None: else: hwnd = icon._hwnd if hwnd: - ctypes.windll.user32.PostMessageW(hwnd, _WM_UPDATE_MENU, 0, 0) + ctypes.windll.user32.PostMessageW(hwnd, _WM_UPDATE_MENU, 0, 0) # type: ignore[attr-defined] icon.update_menu = _thread_safe_update_menu diff --git a/clipsync/ui.py b/clipsync/ui.py index bcaab81..eb257fe 100644 --- a/clipsync/ui.py +++ b/clipsync/ui.py @@ -499,7 +499,7 @@ def _on_frame(self, frame: object) -> None: except ImportError: return try: - rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # type: ignore[call-overload] + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w = rgb.shape[:2] target_w, target_h = self._preview_size scale = min(target_w / w, target_h / h) diff --git a/tests/test_crypto.py b/tests/test_crypto.py index cf681d5..d4ed0a0 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -2,12 +2,9 @@ from __future__ import annotations -import pytest - from clipsync import crypto from clipsync.crypto import _ENC_MAGIC_V0, _ENC_MAGIC_V1, _LEGACY_SALT, _derive_key - # --------------------------------------------------------------------------- # Encrypt / decrypt round-trip # ---------------------------------------------------------------------------