diff --git a/src/nostr/client.py b/src/nostr/client.py index c04f89a..0155617 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -368,6 +368,7 @@ class NostrClient: start = time.perf_counter() if self.offline_mode or not self.relays: return Manifest(ver=1, algo="gzip", chunks=[]), "" + await self.ensure_manifest_is_current() await self._connect_async() manifest, chunks = prepare_snapshot(encrypted_bytes, limit) for meta, chunk in zip(manifest.chunks, chunks): @@ -537,10 +538,39 @@ class NostrClient: return None + async def ensure_manifest_is_current(self) -> None: + """Verify the local manifest is up to date before publishing.""" + if self.offline_mode or not self.relays: + return + await self._connect_async() + pubkey = self.keys.public_key() + ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" + f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1) + timeout = timedelta(seconds=10) + try: + events = (await self.client.fetch_events(f, timeout)).to_vec() + except Exception: + return + if not events: + return + try: + data = json.loads(events[0].content()) + remote = data.get("delta_since") + if remote is not None: + remote = int(remote) + except Exception: + return + with self._state_lock: + local = self.current_manifest.delta_since if self.current_manifest else None + if remote is not None and (local is None or remote > local): + self.last_error = "Manifest out of date" + raise RuntimeError("Manifest out of date") + async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: """Publish a delta event referencing a manifest.""" if self.offline_mode or not self.relays: return "" + await self.ensure_manifest_is_current() await self._connect_async() content = base64.b64encode(delta_bytes).decode("utf-8") diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 6b24237..763e5b0 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -1,8 +1,9 @@ import asyncio import gzip import math +import pytest -from helpers import create_vault, dummy_nostr_client +from helpers import create_vault, dummy_nostr_client, TEST_SEED from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager @@ -141,3 +142,44 @@ def test_fetch_snapshot_uses_event_ids(dummy_nostr_client): if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK ] assert id_filters and all(id_filters) + + +def test_publish_delta_aborts_if_outdated(tmp_path, monkeypatch, dummy_nostr_client): + client1, relay = dummy_nostr_client + + from cryptography.fernet import Fernet + from nostr.client import NostrClient + from seedpass.core.encryption import EncryptionManager + + enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path) + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + with pytest.MonkeyPatch().context() as mp: + mp.setattr("nostr.client.KeyManager", DummyKeyManager) + mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) + client2 = NostrClient(enc_mgr, "fp") + + base = b"base" + manifest, _ = asyncio.run(client1.publish_snapshot(base)) + with client1._state_lock: + client1.current_manifest.delta_since = 0 + import copy + + with client2._state_lock: + client2.current_manifest = copy.deepcopy(manifest) + client2.current_manifest_id = manifest_id = relay.manifests[-1].tags[0] + + asyncio.run(client2.publish_delta(b"d1", manifest_id)) + + with pytest.raises(RuntimeError): + asyncio.run(client1.publish_delta(b"d2", manifest_id))