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