Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions clipsync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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, {})
Expand Down
3 changes: 2 additions & 1 deletion clipsync/syncthing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions clipsync/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions tests/test_crypto.py
Original file line number Diff line number Diff line change
@@ -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
Loading