diff --git a/AGENTS.md b/AGENTS.md index 72812dc..0fa0dbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,8 +138,10 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers. - `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte. - `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`. - `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it. -- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous. -- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts. +- Contact routing now uses canonical route fields: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, plus optional `route_override_*`. +- Route precedence for direct-message sends is: explicit override, then learned direct route, then flood. +- The learned direct route is sourced from radio contact sync (`out_path`) and PATH/path-discovery updates, matching how firmware updates `ContactInfo.out_path`. +- Advertisement paths are informational only. They are retained in `contact_advert_paths` for the contact pane and visualizer, but they are not used as DM send routes. - `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`. ## Data Flow @@ -159,12 +161,21 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers. 4. Message stored in database with `outgoing=true` 5. For direct messages: ACK tracked; for channel: repeat detection +Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow: +- We push the contact's effective route to the radio via `add_contact(...)` before sending. +- Non-final attempts use the effective route (`override > direct > flood`). +- Retry timing follows the radio's `suggested_timeout`. +- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists. +- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`. + ### ACK and Repeat Detection **Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked. Outgoing DMs send once immediately, then may retry up to 2 more times in the background if still unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts. +ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends. + **Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only. This message-layer echo/path handling is independent of raw-packet storage deduplication. diff --git a/app/AGENTS.md b/app/AGENTS.md index eb741c5..9cef9b2 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -104,8 +104,10 @@ app/ - Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room. - TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access. - `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send. -- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes. -- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback. +- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes. +- Direct-route sources are limited to radio contact sync (`out_path`) and PATH/path-discovery updates. This mirrors firmware `onContactPathRecv(...)`, which replaces `ContactInfo.out_path` when a new returned path is heard. +- `route_override_path`, `route_override_len`, and `route_override_hash_mode` take precedence over the learned direct route for radio-bound sends. +- Advertisement paths are stored only in `contact_advert_paths` for analytics/visualization. They are not part of `Contact.to_radio_dict()` or DM route selection. - `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths. ### Read/unread state @@ -120,8 +122,10 @@ app/ - DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`. - Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background if still unacked. - DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries. -- The final DM retry is intentionally sent as flood via `reset_path(...)`, even when a routing override exists. +- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)`: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`. +- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists. - DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries. +- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning. ### Echo/repeat dedup @@ -258,7 +262,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`. ## Data Model Notes Main tables: -- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping) +- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing) - `channels` Includes optional `flood_scope_override` for channel-specific regional sends. - `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution) diff --git a/app/database.py b/app/database.py index dbc7719..e4cb912 100644 --- a/app/database.py +++ b/app/database.py @@ -13,9 +13,10 @@ CREATE TABLE IF NOT EXISTS contacts ( name TEXT, type INTEGER DEFAULT 0, flags INTEGER DEFAULT 0, - last_path TEXT, - last_path_len INTEGER DEFAULT -1, - out_path_hash_mode INTEGER DEFAULT 0, + direct_path TEXT, + direct_path_len INTEGER, + direct_path_hash_mode INTEGER, + direct_path_updated_at INTEGER, route_override_path TEXT, route_override_len INTEGER, route_override_hash_mode INTEGER, @@ -25,7 +26,8 @@ CREATE TABLE IF NOT EXISTS contacts ( last_seen INTEGER, on_radio INTEGER DEFAULT 0, last_contacted INTEGER, - first_seen INTEGER + first_seen INTEGER, + last_read_at INTEGER ); CREATE TABLE IF NOT EXISTS channels ( @@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS channels ( name TEXT NOT NULL, is_hashtag INTEGER DEFAULT 0, on_radio INTEGER DEFAULT 0, - flood_scope_override TEXT + flood_scope_override TEXT, + last_read_at INTEGER ); CREATE TABLE IF NOT EXISTS messages ( diff --git a/app/decoder.py b/app/decoder.py index e0352ae..c34f5d4 100644 --- a/app/decoder.py +++ b/app/decoder.py @@ -60,6 +60,19 @@ class DecryptedDirectMessage: src_hash: str # First byte of sender pubkey as hex +@dataclass +class DecryptedPathPayload: + """Result of decrypting a PATH payload.""" + + dest_hash: str + src_hash: str + returned_path: bytes + returned_path_len: int + returned_path_hash_mode: int + extra_type: int + extra: bytes + + @dataclass class ParsedAdvertisement: """Result of parsing an advertisement packet.""" @@ -563,3 +576,88 @@ def try_decrypt_dm( return None return decrypt_direct_message(packet_info.payload, shared_secret) + + +def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None: + """Decrypt a PATH payload using the ECDH shared secret.""" + if len(payload) < 4: + return None + + dest_hash = format(payload[0], "02x") + src_hash = format(payload[1], "02x") + mac = payload[2:4] + ciphertext = payload[4:] + + if len(ciphertext) == 0 or len(ciphertext) % 16 != 0: + return None + + calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2] + if calculated_mac != mac: + return None + + try: + cipher = AES.new(shared_secret[:16], AES.MODE_ECB) + decrypted = cipher.decrypt(ciphertext) + except Exception as e: + logger.debug("AES decryption failed for PATH payload: %s", e) + return None + + if len(decrypted) < 2: + return None + + from app.path_utils import decode_path_byte + + packed_len = decrypted[0] + try: + returned_path_len, hash_size = decode_path_byte(packed_len) + except ValueError: + return None + + path_byte_len = returned_path_len * hash_size + if len(decrypted) < 1 + path_byte_len + 1: + return None + + offset = 1 + returned_path = decrypted[offset : offset + path_byte_len] + offset += path_byte_len + extra_type = decrypted[offset] & 0x0F + offset += 1 + extra = decrypted[offset:] + + return DecryptedPathPayload( + dest_hash=dest_hash, + src_hash=src_hash, + returned_path=returned_path, + returned_path_len=returned_path_len, + returned_path_hash_mode=hash_size - 1, + extra_type=extra_type, + extra=extra, + ) + + +def try_decrypt_path( + raw_packet: bytes, + our_private_key: bytes, + their_public_key: bytes, + our_public_key: bytes, +) -> DecryptedPathPayload | None: + """Try to decrypt a raw packet as a PATH packet.""" + packet_info = parse_packet(raw_packet) + if packet_info is None or packet_info.payload_type != PayloadType.PATH: + return None + + if len(packet_info.payload) < 4: + return None + + dest_hash = packet_info.payload[0] + src_hash = packet_info.payload[1] + if dest_hash != our_public_key[0] or src_hash != their_public_key[0]: + return None + + try: + shared_secret = derive_shared_secret(our_private_key, their_public_key) + except Exception as e: + logger.debug("Failed to derive shared secret for PATH payload: %s", e) + return None + + return decrypt_path_payload(packet_info.payload, shared_secret) diff --git a/app/event_handlers.py b/app/event_handlers.py index dafa9f5..269de32 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -14,11 +14,11 @@ from app.services.contact_reconciliation import ( promote_prefix_contacts_for_contact, record_contact_name_and_reconcile, ) +from app.services.dm_ack_apply import apply_dm_ack_code from app.services.dm_ingest import ( ingest_fallback_direct_message, resolve_fallback_direct_message_context, ) -from app.services.messages import increment_ack_and_broadcast from app.websocket import broadcast_event if TYPE_CHECKING: @@ -197,11 +197,12 @@ async def on_path_update(event: "Event") -> None: ) normalized_path_hash_mode = None - await ContactRepository.update_path( + await ContactRepository.update_direct_path( contact.public_key, str(path), normalized_path_len, normalized_path_hash_mode, + updated_at=int(time.time()), ) @@ -268,19 +269,10 @@ async def on_ack(event: "Event") -> None: return logger.debug("Received ACK with code %s", ack_code) - - cleanup_expired_acks() - - message_id = dm_ack_tracker.pop_pending_ack(ack_code) - if message_id is not None: - dm_ack_tracker.clear_pending_acks_for_message(message_id) - logger.info("ACK received for message %d", message_id) - # DM ACKs don't carry path data, so paths is intentionally omitted. - # The frontend's mergePendingAck handles the missing field correctly, - # preserving any previously known paths. - await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event) + matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event) + if matched: + logger.info("ACK received for code %s", ack_code) else: - dm_ack_tracker.buffer_unmatched_ack(ack_code) logger.debug("ACK code %s does not match any pending messages", ack_code) diff --git a/app/migrations.py b/app/migrations.py index ef81561..75eb816 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -346,6 +346,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 44) applied += 1 + # Migration 45: Replace legacy contact route columns with direct-route columns + if version < 45: + logger.info("Applying migration 45: rebuild contacts direct-route columns") + await _migrate_045_rebuild_contacts_direct_route_columns(conn) + await set_version(conn, 45) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -2635,3 +2642,140 @@ async def _migrate_044_dedupe_incoming_direct_messages(conn: aiosqlite.Connectio WHERE type = 'PRIV' AND outgoing = 0""" ) await conn.commit() + + +async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Connection) -> None: + """Replace legacy contact route columns with canonical direct-route columns.""" + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'" + ) + if await cursor.fetchone() is None: + await conn.commit() + return + + cursor = await conn.execute("PRAGMA table_info(contacts)") + columns = {row[1] for row in await cursor.fetchall()} + + target_columns = { + "public_key", + "name", + "type", + "flags", + "direct_path", + "direct_path_len", + "direct_path_hash_mode", + "direct_path_updated_at", + "route_override_path", + "route_override_len", + "route_override_hash_mode", + "last_advert", + "lat", + "lon", + "last_seen", + "on_radio", + "last_contacted", + "first_seen", + "last_read_at", + } + if ( + target_columns.issubset(columns) + and "last_path" not in columns + and "out_path_hash_mode" not in columns + ): + await conn.commit() + return + + await conn.execute( + """ + CREATE TABLE contacts_new ( + public_key TEXT PRIMARY KEY, + name TEXT, + type INTEGER DEFAULT 0, + flags INTEGER DEFAULT 0, + direct_path TEXT, + direct_path_len INTEGER, + direct_path_hash_mode INTEGER, + direct_path_updated_at INTEGER, + route_override_path TEXT, + route_override_len INTEGER, + route_override_hash_mode INTEGER, + last_advert INTEGER, + lat REAL, + lon REAL, + last_seen INTEGER, + on_radio INTEGER DEFAULT 0, + last_contacted INTEGER, + first_seen INTEGER, + last_read_at INTEGER + ) + """ + ) + + select_expr = { + "public_key": "public_key", + "name": "NULL", + "type": "0", + "flags": "0", + "direct_path": "NULL", + "direct_path_len": "NULL", + "direct_path_hash_mode": "NULL", + "direct_path_updated_at": "NULL", + "route_override_path": "NULL", + "route_override_len": "NULL", + "route_override_hash_mode": "NULL", + "last_advert": "NULL", + "lat": "NULL", + "lon": "NULL", + "last_seen": "NULL", + "on_radio": "0", + "last_contacted": "NULL", + "first_seen": "NULL", + "last_read_at": "NULL", + } + for name in ("name", "type", "flags"): + if name in columns: + select_expr[name] = name + + if "direct_path" in columns: + select_expr["direct_path"] = "direct_path" + elif "last_path" in columns: + select_expr["direct_path"] = "last_path" + + if "direct_path_len" in columns: + select_expr["direct_path_len"] = "direct_path_len" + elif "last_path_len" in columns: + select_expr["direct_path_len"] = "last_path_len" + + if "direct_path_hash_mode" in columns: + select_expr["direct_path_hash_mode"] = "direct_path_hash_mode" + elif "out_path_hash_mode" in columns: + select_expr["direct_path_hash_mode"] = "out_path_hash_mode" + + for name in ( + "route_override_path", + "route_override_len", + "route_override_hash_mode", + "last_advert", + "lat", + "lon", + "last_seen", + "on_radio", + "last_contacted", + "first_seen", + "last_read_at", + ): + if name in columns: + select_expr[name] = name + + ordered_columns = list(select_expr.keys()) + await conn.execute( + f""" + INSERT INTO contacts_new ({", ".join(ordered_columns)}) + SELECT {", ".join(select_expr[name] for name in ordered_columns)} + FROM contacts + """ + ) + + await conn.execute("DROP TABLE contacts") + await conn.execute("ALTER TABLE contacts_new RENAME TO contacts") + await conn.commit() diff --git a/app/models.py b/app/models.py index 98595f9..65460a9 100644 --- a/app/models.py +++ b/app/models.py @@ -1,8 +1,19 @@ -from typing import Literal +from collections.abc import Mapping +from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator -from app.path_utils import normalize_contact_route +from app.path_utils import normalize_contact_route, normalize_route_override + + +class ContactRoute(BaseModel): + """A normalized contact route.""" + + path: str = Field(description="Hex-encoded path bytes (empty string for direct/flood)") + path_len: int = Field(description="Hop count (-1=flood, 0=direct, >0=explicit route)") + path_hash_mode: int = Field( + description="Path hash mode (-1=flood, 0=1-byte, 1=2-byte, 2=3-byte hop identifiers)" + ) class ContactUpsert(BaseModel): @@ -12,9 +23,10 @@ class ContactUpsert(BaseModel): name: str | None = None type: int = 0 flags: int = 0 - last_path: str | None = None - last_path_len: int = -1 - out_path_hash_mode: int | None = None + direct_path: str | None = None + direct_path_len: int | None = None + direct_path_hash_mode: int | None = None + direct_path_updated_at: int | None = None route_override_path: str | None = None route_override_len: int | None = None route_override_hash_mode: int | None = None @@ -26,6 +38,20 @@ class ContactUpsert(BaseModel): last_contacted: int | None = None first_seen: int | None = None + @model_validator(mode="before") + @classmethod + def _translate_legacy_route_fields(cls, data: Any) -> Any: + if not isinstance(data, Mapping): + return data + translated = dict(data) + if "direct_path" not in translated and "last_path" in translated: + translated["direct_path"] = translated.get("last_path") + if "direct_path_len" not in translated and "last_path_len" in translated: + translated["direct_path_len"] = translated.get("last_path_len") + if "direct_path_hash_mode" not in translated and "out_path_hash_mode" in translated: + translated["direct_path_hash_mode"] = translated.get("out_path_hash_mode") + return translated + @classmethod def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert": return cls.model_validate( @@ -40,7 +66,7 @@ class ContactUpsert(BaseModel): cls, public_key: str, radio_data: dict, on_radio: bool = False ) -> "ContactUpsert": """Convert radio contact data to the contact-row write shape.""" - last_path, last_path_len, out_path_hash_mode = normalize_contact_route( + direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route( radio_data.get("out_path"), radio_data.get("out_path_len", -1), radio_data.get( @@ -53,9 +79,9 @@ class ContactUpsert(BaseModel): name=radio_data.get("adv_name"), type=radio_data.get("type", 0), flags=radio_data.get("flags", 0), - last_path=last_path, - last_path_len=last_path_len, - out_path_hash_mode=out_path_hash_mode, + direct_path=direct_path, + direct_path_len=direct_path_len, + direct_path_hash_mode=direct_path_hash_mode, lat=radio_data.get("adv_lat"), lon=radio_data.get("adv_lon"), last_advert=radio_data.get("last_advert"), @@ -68,9 +94,10 @@ class Contact(BaseModel): name: str | None = None type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor flags: int = 0 - last_path: str | None = None - last_path_len: int = -1 - out_path_hash_mode: int = 0 + direct_path: str | None = None + direct_path_len: int = -1 + direct_path_hash_mode: int = -1 + direct_path_updated_at: int | None = None route_override_path: str | None = None route_override_len: int | None = None route_override_hash_mode: int | None = None @@ -82,38 +109,125 @@ class Contact(BaseModel): last_contacted: int | None = None # Last time we sent/received a message last_read_at: int | None = None # Server-side read state tracking first_seen: int | None = None + effective_route: ContactRoute | None = None + effective_route_source: Literal["override", "direct", "flood"] = "flood" + direct_route: ContactRoute | None = None + route_override: ContactRoute | None = None + + @model_validator(mode="before") + @classmethod + def _translate_legacy_route_fields(cls, data: Any) -> Any: + if not isinstance(data, Mapping): + return data + translated = dict(data) + if "direct_path" not in translated and "last_path" in translated: + translated["direct_path"] = translated.get("last_path") + if "direct_path_len" not in translated and "last_path_len" in translated: + translated["direct_path_len"] = translated.get("last_path_len") + if "direct_path_hash_mode" not in translated and "out_path_hash_mode" in translated: + translated["direct_path_hash_mode"] = translated.get("out_path_hash_mode") + return translated + + def model_post_init(self, __context) -> None: + direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route( + self.direct_path, + self.direct_path_len, + self.direct_path_hash_mode, + ) + self.direct_path = direct_path or None + self.direct_path_len = direct_path_len + self.direct_path_hash_mode = direct_path_hash_mode + + route_override_path, route_override_len, route_override_hash_mode = ( + normalize_route_override( + self.route_override_path, + self.route_override_len, + self.route_override_hash_mode, + ) + ) + self.route_override_path = route_override_path or None + self.route_override_len = route_override_len + self.route_override_hash_mode = route_override_hash_mode + if ( + route_override_path is not None + and route_override_len is not None + and route_override_hash_mode is not None + ): + self.route_override = ContactRoute( + path=route_override_path, + path_len=route_override_len, + path_hash_mode=route_override_hash_mode, + ) + else: + self.route_override = None + + if direct_path_len >= 0: + self.direct_route = ContactRoute( + path=direct_path, + path_len=direct_path_len, + path_hash_mode=direct_path_hash_mode, + ) + else: + self.direct_route = None + + path, path_len, path_hash_mode = self.effective_route_tuple() + if self.has_route_override(): + self.effective_route_source = "override" + elif self.direct_route is not None: + self.effective_route_source = "direct" + else: + self.effective_route_source = "flood" + self.effective_route = ContactRoute( + path=path, + path_len=path_len, + path_hash_mode=path_hash_mode, + ) def has_route_override(self) -> bool: return self.route_override_len is not None - def effective_route(self) -> tuple[str, int, int]: + @property + def last_path(self) -> str | None: + return self.direct_path + + @property + def last_path_len(self) -> int: + return self.direct_path_len + + @property + def out_path_hash_mode(self) -> int: + return self.direct_path_hash_mode + + def effective_route_tuple(self) -> tuple[str, int, int]: if self.has_route_override(): return normalize_contact_route( self.route_override_path, self.route_override_len, self.route_override_hash_mode, ) - return normalize_contact_route( - self.last_path, - self.last_path_len, - self.out_path_hash_mode, - ) + if self.direct_path_len >= 0: + return normalize_contact_route( + self.direct_path, + self.direct_path_len, + self.direct_path_hash_mode, + ) + return "", -1, -1 def to_radio_dict(self) -> dict: """Convert to the dict format expected by meshcore radio commands. The radio API uses different field names (adv_name, out_path, etc.) - than our database schema (name, last_path, etc.). + than our database schema (name, direct_path, etc.). """ - last_path, last_path_len, out_path_hash_mode = self.effective_route() + effective_path, effective_path_len, effective_path_hash_mode = self.effective_route_tuple() return { "public_key": self.public_key, "adv_name": self.name or "", "type": self.type, "flags": self.flags, - "out_path": last_path, - "out_path_len": last_path_len, - "out_path_hash_mode": out_path_hash_mode, + "out_path": effective_path, + "out_path_len": effective_path_len, + "out_path_hash_mode": effective_path_hash_mode, "adv_lat": self.lat if self.lat is not None else 0.0, "adv_lon": self.lon if self.lon is not None else 0.0, "last_advert": self.last_advert if self.last_advert is not None else 0, @@ -149,7 +263,7 @@ class ContactRoutingOverrideRequest(BaseModel): route: str = Field( description=( - "Blank clears the override and resets learned routing to flood, " + "Blank clears the override, " '"-1" forces flood, "0" forces direct, and explicit routes are ' "comma-separated 1/2/3-byte hop hex values" ) diff --git a/app/packet_processor.py b/app/packet_processor.py index 2a39850..12ac466 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -26,6 +26,7 @@ from app.decoder import ( parse_packet, try_decrypt_dm, try_decrypt_packet_with_channel_key, + try_decrypt_path, ) from app.keystore import get_private_key, get_public_key, has_private_key from app.models import ( @@ -44,6 +45,7 @@ from app.services.contact_reconciliation import ( promote_prefix_contacts_for_contact, record_contact_name_and_reconcile, ) +from app.services.dm_ack_apply import apply_dm_ack_code from app.services.messages import ( create_dm_message_from_decrypted as _create_dm_message_from_decrypted, ) @@ -318,8 +320,7 @@ async def process_raw_packet( elif payload_type == PayloadType.ADVERT: # Process all advert arrivals (even payload-hash duplicates) so the - # path-freshness logic in _process_advertisement can pick the shortest - # path heard within the freshness window. + # advert-history table retains recent path observations. await _process_advertisement(raw_bytes, ts, packet_info) elif payload_type == PayloadType.TEXT_MESSAGE: @@ -328,6 +329,9 @@ async def process_raw_packet( if decrypt_result: result.update(decrypt_result) + elif payload_type == PayloadType.PATH: + await _process_path_packet(raw_bytes, ts, packet_info) + # Always broadcast raw packet for the packet feed UI (even duplicates) # This enables the frontend cracker to see all incoming packets in real-time broadcast_payload = RawPacketBroadcast( @@ -430,51 +434,20 @@ async def _process_advertisement( logger.debug("Failed to parse advertisement payload") return - # Extract path info from packet new_path_len = packet_info.path_length new_path_hex = packet_info.path.hex() if packet_info.path else "" # Try to find existing contact existing = await ContactRepository.get_by_key(advert.public_key.lower()) - # Determine which path to use: keep shorter path if heard recently (within 60s) - # This handles advertisement echoes through different routes - PATH_FRESHNESS_SECONDS = 60 - use_existing_path = False - - if existing and existing.last_advert: - path_age = timestamp - existing.last_advert - existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float("inf") - - # Keep existing path if it's fresh and shorter (or equal) - if path_age <= PATH_FRESHNESS_SECONDS and existing_path_len <= new_path_len: - use_existing_path = True - logger.debug( - "Keeping existing shorter path for %s (existing=%d, new=%d, age=%ds)", - advert.public_key[:12], - existing_path_len, - new_path_len, - path_age, - ) - - if use_existing_path: - assert existing is not None # Guaranteed by the conditions that set use_existing_path - path_len = existing.last_path_len if existing.last_path_len is not None else -1 - path_hex = existing.last_path or "" - out_path_hash_mode = existing.out_path_hash_mode - else: - path_len = new_path_len - path_hex = new_path_hex - out_path_hash_mode = packet_info.path_hash_size - 1 - logger.debug( - "Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)", + "Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, advert_path_len=%d)", advert.public_key[:12], advert.name, advert.device_role, advert.lat, advert.lon, - path_len, + new_path_len, ) # Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor). @@ -501,9 +474,6 @@ async def _process_advertisement( lon=advert.lon, last_advert=timestamp, last_seen=timestamp, - last_path=path_hex, - last_path_len=path_len, - out_path_hash_mode=out_path_hash_mode, first_seen=timestamp, # COALESCE in upsert preserves existing value ) @@ -667,3 +637,90 @@ async def _process_direct_message( # Couldn't decrypt with any known contact logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts)) return None + + +async def _process_path_packet( + raw_bytes: bytes, + timestamp: int, + packet_info: PacketInfo | None, +) -> None: + """Process a PATH packet and update the learned direct route.""" + if not has_private_key(): + return + + private_key = get_private_key() + our_public_key = get_public_key() + if private_key is None or our_public_key is None: + return + + if packet_info is None: + packet_info = parse_packet(raw_bytes) + if packet_info is None or packet_info.payload is None or len(packet_info.payload) < 4: + return + + dest_hash = format(packet_info.payload[0], "02x").lower() + src_hash = format(packet_info.payload[1], "02x").lower() + our_first_byte = format(our_public_key[0], "02x").lower() + if dest_hash != our_first_byte: + return + + candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(src_hash) + if not candidate_contacts: + logger.debug("No contacts found matching hash %s for PATH decryption", src_hash) + return + + for contact in candidate_contacts: + if len(contact.public_key) != 64: + continue + try: + contact_public_key = bytes.fromhex(contact.public_key) + except ValueError: + continue + + result = try_decrypt_path( + raw_packet=raw_bytes, + our_private_key=private_key, + their_public_key=contact_public_key, + our_public_key=our_public_key, + ) + if result is None: + continue + + await ContactRepository.update_direct_path( + contact.public_key, + result.returned_path.hex(), + result.returned_path_len, + result.returned_path_hash_mode, + updated_at=timestamp, + ) + + if result.extra_type == PayloadType.ACK and len(result.extra) >= 4: + ack_code = result.extra[:4].hex() + matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event) + if matched: + logger.info( + "Applied bundled PATH ACK for %s via contact %s", + ack_code, + contact.public_key[:12], + ) + else: + logger.debug( + "Buffered bundled PATH ACK %s via contact %s", + ack_code, + contact.public_key[:12], + ) + elif result.extra_type == PayloadType.RESPONSE and len(result.extra) > 0: + logger.debug( + "Observed bundled PATH RESPONSE from %s (%d bytes)", + contact.public_key[:12], + len(result.extra), + ) + + refreshed_contact = await ContactRepository.get_by_key(contact.public_key) + if refreshed_contact is not None: + broadcast_event("contact", refreshed_contact.model_dump()) + return + + logger.debug( + "Could not decrypt PATH packet with any of %d candidate contacts", len(candidate_contacts) + ) diff --git a/app/path_utils.py b/app/path_utils.py index 0234311..6a33faa 100644 --- a/app/path_utils.py +++ b/app/path_utils.py @@ -153,12 +153,12 @@ def first_hop_hex(path_hex: str, hop_count: int) -> str | None: def normalize_contact_route( path_hex: str | None, path_len: int | None, - out_path_hash_mode: int | None, + path_hash_mode: int | None, ) -> tuple[str, int, int]: """Normalize stored contact route fields. Handles legacy/bad rows where the packed wire path byte was stored directly - in `last_path_len` (sometimes as a signed byte, e.g. `-125` for `0x83`). + in the hop-count column (sometimes as a signed byte, e.g. `-125` for `0x83`). Returns `(path_hex, hop_count, hash_mode)`. """ normalized_path = path_hex or "" @@ -169,7 +169,7 @@ def normalize_contact_route( normalized_len = -1 try: - normalized_mode = int(out_path_hash_mode) if out_path_hash_mode is not None else None + normalized_mode = int(path_hash_mode) if path_hash_mode is not None else None except (TypeError, ValueError): normalized_mode = None @@ -207,7 +207,7 @@ def normalize_contact_route( def normalize_route_override( path_hex: str | None, path_len: int | None, - out_path_hash_mode: int | None, + path_hash_mode: int | None, ) -> tuple[str | None, int | None, int | None]: """Normalize optional route-override fields while preserving the unset state.""" if path_len is None: @@ -216,7 +216,7 @@ def normalize_route_override( normalized_path, normalized_len, normalized_mode = normalize_contact_route( path_hex, path_len, - out_path_hash_mode, + path_hash_mode, ) return normalized_path, normalized_len, normalized_mode diff --git a/app/radio_sync.py b/app/radio_sync.py index 9d82499..9ad6c1f 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -46,9 +46,10 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]: return { "type": contact.type, "flags": contact.flags, - "last_path": contact.last_path, - "last_path_len": contact.last_path_len, - "out_path_hash_mode": contact.out_path_hash_mode, + "direct_path": contact.direct_path, + "direct_path_len": contact.direct_path_len, + "direct_path_hash_mode": contact.direct_path_hash_mode, + "direct_path_updated_at": contact.direct_path_updated_at, "route_override_path": contact.route_override_path, "route_override_len": contact.route_override_len, "route_override_hash_mode": contact.route_override_hash_mode, diff --git a/app/repository/contacts.py b/app/repository/contacts.py index 1f40e84..ed72a98 100644 --- a/app/repository/contacts.py +++ b/app/repository/contacts.py @@ -36,11 +36,20 @@ class ContactRepository: @staticmethod async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None: contact_row = ContactRepository._coerce_contact_upsert(contact) - last_path, last_path_len, out_path_hash_mode = normalize_contact_route( - contact_row.last_path, - contact_row.last_path_len, - contact_row.out_path_hash_mode, - ) + if ( + contact_row.direct_path is None + and contact_row.direct_path_len is None + and contact_row.direct_path_hash_mode is None + ): + direct_path = None + direct_path_len = None + direct_path_hash_mode = None + else: + direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route( + contact_row.direct_path, + contact_row.direct_path_len, + contact_row.direct_path_hash_mode, + ) route_override_path, route_override_len, route_override_hash_mode = ( normalize_route_override( contact_row.route_override_path, @@ -51,20 +60,25 @@ class ContactRepository: await db.conn.execute( """ - INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len, - out_path_hash_mode, + INSERT INTO contacts (public_key, name, type, flags, direct_path, direct_path_len, + direct_path_hash_mode, direct_path_updated_at, route_override_path, route_override_len, route_override_hash_mode, last_advert, lat, lon, last_seen, on_radio, last_contacted, first_seen) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(public_key) DO UPDATE SET name = COALESCE(excluded.name, contacts.name), type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END, flags = excluded.flags, - last_path = COALESCE(excluded.last_path, contacts.last_path), - last_path_len = excluded.last_path_len, - out_path_hash_mode = excluded.out_path_hash_mode, + direct_path = COALESCE(excluded.direct_path, contacts.direct_path), + direct_path_len = COALESCE(excluded.direct_path_len, contacts.direct_path_len), + direct_path_hash_mode = COALESCE( + excluded.direct_path_hash_mode, contacts.direct_path_hash_mode + ), + direct_path_updated_at = COALESCE( + excluded.direct_path_updated_at, contacts.direct_path_updated_at + ), route_override_path = COALESCE( excluded.route_override_path, contacts.route_override_path ), @@ -87,9 +101,10 @@ class ContactRepository: contact_row.name, contact_row.type, contact_row.flags, - last_path, - last_path_len, - out_path_hash_mode, + direct_path, + direct_path_len, + direct_path_hash_mode, + contact_row.direct_path_updated_at, route_override_path, route_override_len, route_override_hash_mode, @@ -107,12 +122,12 @@ class ContactRepository: @staticmethod def _row_to_contact(row) -> Contact: """Convert a database row to a Contact model.""" - last_path, last_path_len, out_path_hash_mode = normalize_contact_route( - row["last_path"], - row["last_path_len"], - row["out_path_hash_mode"], - ) available_columns = set(row.keys()) + direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route( + row["direct_path"] if "direct_path" in available_columns else None, + row["direct_path_len"] if "direct_path_len" in available_columns else None, + row["direct_path_hash_mode"] if "direct_path_hash_mode" in available_columns else None, + ) route_override_path = ( row["route_override_path"] if "route_override_path" in available_columns else None ) @@ -136,9 +151,14 @@ class ContactRepository: name=row["name"], type=row["type"], flags=row["flags"], - last_path=last_path, - last_path_len=last_path_len, - out_path_hash_mode=out_path_hash_mode, + direct_path=direct_path, + direct_path_len=direct_path_len, + direct_path_hash_mode=direct_path_hash_mode, + direct_path_updated_at=( + row["direct_path_updated_at"] + if "direct_path_updated_at" in available_columns + else None + ), route_override_path=route_override_path, route_override_len=route_override_len, route_override_hash_mode=route_override_hash_mode, @@ -286,42 +306,63 @@ class ContactRepository: return [ContactRepository._row_to_contact(row) for row in rows] @staticmethod - async def update_path( + async def update_direct_path( public_key: str, path: str, path_len: int, - out_path_hash_mode: int | None = None, + path_hash_mode: int | None = None, + updated_at: int | None = None, ) -> None: normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route( path, path_len, - out_path_hash_mode, + path_hash_mode, ) + ts = updated_at if updated_at is not None else int(time.time()) await db.conn.execute( - """UPDATE contacts SET last_path = ?, last_path_len = ?, - out_path_hash_mode = COALESCE(?, out_path_hash_mode), + """UPDATE contacts SET direct_path = ?, direct_path_len = ?, + direct_path_hash_mode = COALESCE(?, direct_path_hash_mode), + direct_path_updated_at = ?, last_seen = ? WHERE public_key = ?""", ( normalized_path, normalized_path_len, normalized_hash_mode, - int(time.time()), + ts, + ts, public_key.lower(), ), ) await db.conn.commit() + @staticmethod + async def update_path( + public_key: str, + path: str, + path_len: int, + path_hash_mode: int | None = None, + updated_at: int | None = None, + ) -> None: + """Compatibility shim for legacy callers/tests.""" + await ContactRepository.update_direct_path( + public_key, + path, + path_len, + path_hash_mode, + updated_at=updated_at, + ) + @staticmethod async def set_routing_override( public_key: str, path: str | None, path_len: int | None, - out_path_hash_mode: int | None = None, + path_hash_mode: int | None = None, ) -> None: normalized_path, normalized_len, normalized_hash_mode = normalize_route_override( path, path_len, - out_path_hash_mode, + path_hash_mode, ) await db.conn.execute( """ diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 837ebc1..a4289df 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -304,7 +304,6 @@ async def create_contact( contact_upsert = ContactUpsert( public_key=lower_key, name=request.name, - out_path_hash_mode=-1, on_radio=False, ) await ContactRepository.upsert(contact_upsert) @@ -474,7 +473,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse: return_len = int(payload.get("in_path_len") or 0) return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len")) - await ContactRepository.update_path( + await ContactRepository.update_direct_path( contact.public_key, forward_path, forward_len, @@ -524,9 +523,8 @@ async def set_contact_routing_override( route_text = request.route.strip() if route_text == "": await ContactRepository.clear_routing_override(contact.public_key) - await ContactRepository.update_path(contact.public_key, "", -1, -1) logger.info( - "Cleared routing override and reset learned path to flood for %s", + "Cleared routing override for %s", contact.public_key[:12], ) elif route_text == "-1": diff --git a/app/services/dm_ack_apply.py b/app/services/dm_ack_apply.py new file mode 100644 index 0000000..66368dc --- /dev/null +++ b/app/services/dm_ack_apply.py @@ -0,0 +1,26 @@ +"""Shared direct-message ACK application logic.""" + +from collections.abc import Callable +from typing import Any + +from app.services import dm_ack_tracker +from app.services.messages import increment_ack_and_broadcast + +BroadcastFn = Callable[..., Any] + + +async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool: + """Apply a DM ACK code using the shared pending/buffered state machine. + + Returns True when the ACK matched a pending message, False when it was buffered. + """ + dm_ack_tracker.cleanup_expired_acks() + + message_id = dm_ack_tracker.pop_pending_ack(ack_code) + if message_id is None: + dm_ack_tracker.buffer_unmatched_ack(ack_code) + return False + + dm_ack_tracker.clear_pending_acks_for_message(message_id) + await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_fn) + return True diff --git a/app/services/dm_ingest.py b/app/services/dm_ingest.py index 240f748..c1bf42e 100644 --- a/app/services/dm_ingest.py +++ b/app/services/dm_ingest.py @@ -92,7 +92,6 @@ async def resolve_fallback_direct_message_context( last_contacted=received_at, first_seen=received_at, on_radio=False, - out_path_hash_mode=-1, ) await contact_repository.upsert(placeholder_upsert) contact = await contact_repository.get_by_key(normalized_sender) diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 6e8a496..db327f5 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -12,6 +12,7 @@ import { calculateDistance, formatDistance, formatRouteLabel, + getDirectContactRoute, getEffectiveContactRoute, hasRoutingOverride, parsePathHops, @@ -134,11 +135,12 @@ export function ContactInfoPane({ ? calculateDistance(config.lat, config.lon, contact.lat, contact.lon) : null; const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null; + const directRoute = contact ? getDirectContactRoute(contact) : null; const pathHashModeLabel = effectiveRoute && effectiveRoute.pathLen >= 0 ? formatPathHashMode(effectiveRoute.pathHashMode) : null; - const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null; + const learnedRouteLabel = directRoute ? formatRouteLabel(directRoute.path_len, true) : null; const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false; const isUnknownFullKeyResolvedContact = contact !== null && @@ -330,7 +332,7 @@ export function ContactInfoPane({ } /> )} - {contact && hasRoutingOverride(contact) && learnedRouteLabel && ( + {hasRoutingOverride(contact) && learnedRouteLabel && ( )} {pathHashModeLabel && } diff --git a/frontend/src/components/ContactPathDiscoveryModal.tsx b/frontend/src/components/ContactPathDiscoveryModal.tsx index 06f8001..f2fa70a 100644 --- a/frontend/src/components/ContactPathDiscoveryModal.tsx +++ b/frontend/src/components/ContactPathDiscoveryModal.tsx @@ -4,6 +4,7 @@ import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../type import { findContactsByPrefix, formatRouteLabel, + getDirectContactRoute, getEffectiveContactRoute, hasRoutingOverride, parsePathHops, @@ -99,16 +100,17 @@ export function ContactPathDiscoveryModal({ const [result, setResult] = useState(null); const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]); + const directRoute = useMemo(() => getDirectContactRoute(contact), [contact]); const hasForcedRoute = hasRoutingOverride(contact); const learnedRouteSummary = useMemo(() => { - if (contact.last_path_len === -1) { + if (!directRoute) { return 'Flood'; } - const hops = parsePathHops(contact.last_path, contact.last_path_len); + const hops = parsePathHops(directRoute.path, directRoute.path_len); return hops.length > 0 - ? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})` - : formatRouteLabel(contact.last_path_len, true); - }, [contact.last_path, contact.last_path_len]); + ? `${formatRouteLabel(directRoute.path_len, true)} (${hops.join(' -> ')})` + : formatRouteLabel(directRoute.path_len, true); + }, [directRoute]); const forcedRouteSummary = useMemo(() => { if (!hasForcedRoute) { return null; diff --git a/frontend/src/components/ContactRoutingOverrideModal.tsx b/frontend/src/components/ContactRoutingOverrideModal.tsx index c728311..fd1badc 100644 --- a/frontend/src/components/ContactRoutingOverrideModal.tsx +++ b/frontend/src/components/ContactRoutingOverrideModal.tsx @@ -5,6 +5,7 @@ import type { Contact } from '../types'; import { formatRouteLabel, formatRoutingOverrideInput, + getDirectContactRoute, hasRoutingOverride, } from '../utils/pathUtils'; import { Button } from './ui/button'; @@ -28,7 +29,7 @@ interface ContactRoutingOverrideModalProps { } function summarizeLearnedRoute(contact: Contact): string { - return formatRouteLabel(contact.last_path_len, true); + return formatRouteLabel(getDirectContactRoute(contact)?.path_len ?? -1, true); } function summarizeForcedRoute(contact: Contact): string | null { diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 092547c..b18b41f 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -12,6 +12,7 @@ import type { Contact, Message, MessagePath, RadioConfig } from '../types'; import { CONTACT_TYPE_REPEATER } from '../types'; import { formatTime, parseSenderFromText } from '../utils/messageParser'; import { formatHopCounts, type SenderInfo } from '../utils/pathUtils'; +import { getDirectContactRoute } from '../utils/pathUtils'; import { ContactAvatar } from './ContactAvatar'; import { PathModal } from './PathModal'; import { handleKeyboardActivate } from '../utils/a11y'; @@ -500,12 +501,13 @@ export function MessageList({ parsedSender: string | null ): SenderInfo => { if (msg.type === 'PRIV' && contact) { + const directRoute = getDirectContactRoute(contact); return { name: contact.name || contact.public_key.slice(0, 12), publicKeyOrPrefix: contact.public_key, lat: contact.lat, lon: contact.lon, - pathHashMode: contact.out_path_hash_mode, + pathHashMode: directRoute?.path_hash_mode ?? null, }; } if (msg.type === 'CHAN') { @@ -515,12 +517,13 @@ export function MessageList({ ? contacts.find((candidate) => candidate.public_key === msg.sender_key) : null) || (senderName ? getContactByName(senderName) : null); if (senderContact) { + const directRoute = getDirectContactRoute(senderContact); return { name: senderContact.name || senderName || senderContact.public_key.slice(0, 12), publicKeyOrPrefix: senderContact.public_key, lat: senderContact.lat, lon: senderContact.lon, - pathHashMode: senderContact.out_path_hash_mode, + pathHashMode: directRoute?.path_hash_mode ?? null, }; } if (senderName || msg.sender_key) { @@ -538,12 +541,13 @@ export function MessageList({ if (parsedSender) { const senderContact = getContactByName(parsedSender); if (senderContact) { + const directRoute = getDirectContactRoute(senderContact); return { name: parsedSender, publicKeyOrPrefix: senderContact.public_key, lat: senderContact.lat, lon: senderContact.lon, - pathHashMode: senderContact.out_path_hash_mode, + pathHashMode: directRoute?.path_hash_mode ?? null, }; } } diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index fa23a4b..8b9f7cd 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -293,8 +293,8 @@ describe('App startup hash resolution', () => { name: 'Alice', type: 1, flags: 0, - last_path: null, - last_path_len: -1, + direct_path: null, + direct_path_len: -1, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/chatHeaderKeyVisibility.test.tsx b/frontend/src/test/chatHeaderKeyVisibility.test.tsx index e527fb0..29be711 100644 --- a/frontend/src/test/chatHeaderKeyVisibility.test.tsx +++ b/frontend/src/test/chatHeaderKeyVisibility.test.tsx @@ -196,9 +196,9 @@ describe('ChatHeader key visibility', () => { name: 'Alice', type: 1, flags: 0, - last_path: 'AA', - last_path_len: 1, - out_path_hash_mode: 0, + direct_path: 'AA', + direct_path_len: 1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, @@ -242,9 +242,9 @@ describe('ChatHeader key visibility', () => { name: 'Alice', type: 1, flags: 0, - last_path: 'AA', - last_path_len: 1, - out_path_hash_mode: 0, + direct_path: 'AA', + direct_path_len: 1, + direct_path_hash_mode: 0, route_override_path: 'BBDD', route_override_len: 2, route_override_hash_mode: 0, diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx index 8fefb03..615daa9 100644 --- a/frontend/src/test/contactInfoPane.test.tsx +++ b/frontend/src/test/contactInfoPane.test.tsx @@ -39,9 +39,9 @@ function createContact(overrides: Partial = {}): Contact { name: 'Alice', type: 1, flags: 0, - last_path: null, - last_path_len: 0, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: 0, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, @@ -104,7 +104,7 @@ describe('ContactInfoPane', () => { }); it('shows hop width when contact has a stored path hash mode', async () => { - const contact = createContact({ out_path_hash_mode: 1 }); + const contact = createContact({ direct_path_hash_mode: 1, direct_path_len: 1 }); getContactAnalytics.mockResolvedValue(createAnalytics(contact)); render(); @@ -117,7 +117,7 @@ describe('ContactInfoPane', () => { }); it('does not show hop width for flood-routed contacts', async () => { - const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 }); + const contact = createContact({ direct_path_len: -1, direct_path_hash_mode: -1 }); getContactAnalytics.mockResolvedValue(createAnalytics(contact)); render(); @@ -131,8 +131,8 @@ describe('ContactInfoPane', () => { it('shows forced routing override and learned route separately', async () => { const contact = createContact({ - last_path_len: 1, - out_path_hash_mode: 0, + direct_path_len: 1, + direct_path_hash_mode: 0, route_override_path: 'ae92f13e', route_override_len: 2, route_override_hash_mode: 1, diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index ddc3b43..09222da 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -166,9 +166,9 @@ describe('ConversationPane', () => { name: 'Repeater', type: 2, flags: 0, - last_path: null, - last_path_len: 0, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: 0, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, @@ -268,9 +268,9 @@ describe('ConversationPane', () => { name: null, type: 0, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: -1, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, last_advert: null, lat: null, lon: null, @@ -304,9 +304,9 @@ describe('ConversationPane', () => { name: null, type: 0, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: -1, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/integration.test.ts b/frontend/src/test/integration.test.ts index 00eb948..e29796b 100644 --- a/frontend/src/test/integration.test.ts +++ b/frontend/src/test/integration.test.ts @@ -274,8 +274,8 @@ function makeContact(overrides: Partial = {}): Contact { name: 'TestNode', type: 1, flags: 0, - last_path: null, - last_path_len: 0, + direct_path: null, + direct_path_len: 0, last_advert: null, lat: null, lon: null, @@ -285,7 +285,7 @@ function makeContact(overrides: Partial = {}): Contact { last_read_at: null, first_seen: null, ...overrides, - out_path_hash_mode: overrides.out_path_hash_mode ?? 0, + direct_path_hash_mode: overrides.direct_path_hash_mode ?? 0, }; } diff --git a/frontend/src/test/mapView.test.tsx b/frontend/src/test/mapView.test.tsx index effd766..26d5f6f 100644 --- a/frontend/src/test/mapView.test.tsx +++ b/frontend/src/test/mapView.test.tsx @@ -29,9 +29,9 @@ describe('MapView', () => { name: 'Mystery Node', type: 1, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: -1, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, route_override_path: null, route_override_len: null, route_override_hash_mode: null, @@ -63,9 +63,9 @@ describe('MapView', () => { name: 'Almost Stale', type: 1, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: -1, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, route_override_path: null, route_override_len: null, route_override_hash_mode: null, diff --git a/frontend/src/test/newMessageModal.test.tsx b/frontend/src/test/newMessageModal.test.tsx index 9984710..ad242cf 100644 --- a/frontend/src/test/newMessageModal.test.tsx +++ b/frontend/src/test/newMessageModal.test.tsx @@ -23,9 +23,9 @@ const mockContact: Contact = { name: 'Alice', type: 1, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/packetNetworkGraph.test.ts b/frontend/src/test/packetNetworkGraph.test.ts index cb6825d..4b9206a 100644 --- a/frontend/src/test/packetNetworkGraph.test.ts +++ b/frontend/src/test/packetNetworkGraph.test.ts @@ -51,9 +51,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact { name, type, flags: 0, - last_path: null, - last_path_len: 0, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: 0, + direct_path_hash_mode: 0, route_override_path: null, route_override_len: null, route_override_hash_mode: null, diff --git a/frontend/src/test/pathUtils.test.ts b/frontend/src/test/pathUtils.test.ts index 79dfdc4..9113692 100644 --- a/frontend/src/test/pathUtils.test.ts +++ b/frontend/src/test/pathUtils.test.ts @@ -22,8 +22,9 @@ function createContact(overrides: Partial = {}): Contact { name: 'Test Contact', type: CONTACT_TYPE_REPEATER, flags: 0, - last_path: null, - last_path_len: -1, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, last_advert: null, lat: null, lon: null, @@ -33,7 +34,6 @@ function createContact(overrides: Partial = {}): Contact { last_read_at: null, first_seen: null, ...overrides, - out_path_hash_mode: overrides.out_path_hash_mode ?? 0, }; } @@ -139,9 +139,9 @@ describe('contact routing helpers', () => { it('prefers routing override over learned route', () => { const effective = getEffectiveContactRoute( createContact({ - last_path: 'AABB', - last_path_len: 1, - out_path_hash_mode: 0, + direct_path: 'AABB', + direct_path_len: 1, + direct_path_hash_mode: 0, route_override_path: 'AE92F13E', route_override_len: 2, route_override_hash_mode: 1, diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 46bbd92..1d3e21d 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -82,9 +82,9 @@ const contacts: Contact[] = [ name: 'TestRepeater', type: 2, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, @@ -305,9 +305,9 @@ describe('RepeaterDashboard', () => { name: 'Neighbor', type: 1, flags: 0, - last_path: null, - last_path_len: 0, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: 0, + direct_path_hash_mode: 0, route_override_path: null, route_override_len: null, route_override_hash_mode: null, @@ -365,9 +365,9 @@ describe('RepeaterDashboard', () => { name: 'Neighbor', type: 1, flags: 0, - last_path: null, - last_path_len: 0, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: 0, + direct_path_hash_mode: 0, route_override_path: null, route_override_len: null, route_override_hash_mode: null, @@ -520,15 +520,15 @@ describe('RepeaterDashboard', () => { }); describe('path type display and reset', () => { - it('shows flood when last_path_len is -1', () => { + it('shows flood when direct_path_len is -1', () => { render(); expect(screen.getByText('flood')).toBeInTheDocument(); }); - it('shows direct when last_path_len is 0', () => { + it('shows direct when direct_path_len is 0', () => { const directContacts: Contact[] = [ - { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, + { ...contacts[0], direct_path_len: 0, last_seen: 1700000000 }, ]; render(); @@ -536,9 +536,9 @@ describe('RepeaterDashboard', () => { expect(screen.getByText('direct')).toBeInTheDocument(); }); - it('shows N hops when last_path_len > 0', () => { + it('shows N hops when direct_path_len > 0', () => { const hoppedContacts: Contact[] = [ - { ...contacts[0], last_path_len: 3, last_seen: 1700000000 }, + { ...contacts[0], direct_path_len: 3, last_seen: 1700000000 }, ]; render(); @@ -548,7 +548,7 @@ describe('RepeaterDashboard', () => { it('shows 1 hop (singular) for single hop', () => { const oneHopContacts: Contact[] = [ - { ...contacts[0], last_path_len: 1, last_seen: 1700000000 }, + { ...contacts[0], direct_path_len: 1, last_seen: 1700000000 }, ]; render(); @@ -558,7 +558,7 @@ describe('RepeaterDashboard', () => { it('direct path is clickable, underlined, and marked as editable', () => { const directContacts: Contact[] = [ - { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, + { ...contacts[0], direct_path_len: 0, last_seen: 1700000000 }, ]; render(); @@ -573,7 +573,7 @@ describe('RepeaterDashboard', () => { const forcedContacts: Contact[] = [ { ...contacts[0], - last_path_len: 1, + direct_path_len: 1, last_seen: 1700000000, route_override_path: 'ae92f13e', route_override_len: 2, @@ -589,7 +589,7 @@ describe('RepeaterDashboard', () => { it('clicking direct path opens modal and can force direct routing', async () => { const directContacts: Contact[] = [ - { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, + { ...contacts[0], direct_path_len: 0, last_seen: 1700000000 }, ]; const { api } = await import('../api'); @@ -613,7 +613,7 @@ describe('RepeaterDashboard', () => { it('closing the routing override modal does not call the API', async () => { const directContacts: Contact[] = [ - { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, + { ...contacts[0], direct_path_len: 0, last_seen: 1700000000 }, ]; const { api } = await import('../api'); diff --git a/frontend/src/test/searchView.test.tsx b/frontend/src/test/searchView.test.tsx index 540dede..b4e62d4 100644 --- a/frontend/src/test/searchView.test.tsx +++ b/frontend/src/test/searchView.test.tsx @@ -231,9 +231,9 @@ describe('SearchView', () => { name: 'Bob', type: 1, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index 5a1fa1f..e70f63b 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -27,9 +27,9 @@ function makeContact( name, type, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/urlHash.test.ts b/frontend/src/test/urlHash.test.ts index 54f1a71..8e3cd1f 100644 --- a/frontend/src/test/urlHash.test.ts +++ b/frontend/src/test/urlHash.test.ts @@ -225,9 +225,9 @@ describe('resolveContactFromHashToken', () => { name: 'Alice', type: 1, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, @@ -242,9 +242,9 @@ describe('resolveContactFromHashToken', () => { name: 'Alice', type: 1, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, @@ -259,9 +259,9 @@ describe('resolveContactFromHashToken', () => { name: null, type: 1, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/useContactsAndChannels.test.ts b/frontend/src/test/useContactsAndChannels.test.ts index d12c1ac..8f4d0d8 100644 --- a/frontend/src/test/useContactsAndChannels.test.ts +++ b/frontend/src/test/useContactsAndChannels.test.ts @@ -42,9 +42,9 @@ function makeContact(suffix: string): Contact { name: `Contact-${suffix}`, type: 1, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/useConversationActions.test.ts b/frontend/src/test/useConversationActions.test.ts index 1734580..3da22ce 100644 --- a/frontend/src/test/useConversationActions.test.ts +++ b/frontend/src/test/useConversationActions.test.ts @@ -200,9 +200,9 @@ describe('useConversationActions', () => { name: 'Alice', type: 1, flags: 0, - last_path: 'AABB', - last_path_len: 2, - out_path_hash_mode: 0, + direct_path: 'AABB', + direct_path_len: 2, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/useRealtimeAppState.test.ts b/frontend/src/test/useRealtimeAppState.test.ts index c184d98..877fa89 100644 --- a/frontend/src/test/useRealtimeAppState.test.ts +++ b/frontend/src/test/useRealtimeAppState.test.ts @@ -100,9 +100,9 @@ describe('useRealtimeAppState', () => { name: 'Bob', type: 1, flags: 0, - last_path: null, - last_path_len: 0, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: 0, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, @@ -142,9 +142,9 @@ describe('useRealtimeAppState', () => { name: 'Bob', type: 1, flags: 0, - last_path: null, - last_path_len: 0, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: 0, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, @@ -232,9 +232,9 @@ describe('useRealtimeAppState', () => { name: null, type: 0, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: -1, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/useUnreadCounts.test.ts b/frontend/src/test/useUnreadCounts.test.ts index a7151fd..82bfb87 100644 --- a/frontend/src/test/useUnreadCounts.test.ts +++ b/frontend/src/test/useUnreadCounts.test.ts @@ -44,9 +44,9 @@ function makeContact(pubkey: string): Contact { name: `Contact-${pubkey.slice(0, 6)}`, type: 1, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: 0, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/useVisualizerData3D.test.ts b/frontend/src/test/useVisualizerData3D.test.ts index 2f5d94c..74adda0 100644 --- a/frontend/src/test/useVisualizerData3D.test.ts +++ b/frontend/src/test/useVisualizerData3D.test.ts @@ -44,9 +44,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact { name, type, flags: 0, - last_path: null, - last_path_len: 0, - out_path_hash_mode: 0, + direct_path: null, + direct_path_len: 0, + direct_path_hash_mode: 0, route_override_path: null, route_override_len: null, route_override_hash_mode: null, diff --git a/frontend/src/test/useWebSocket.dispatch.test.ts b/frontend/src/test/useWebSocket.dispatch.test.ts index 99d3b8c..eb83f49 100644 --- a/frontend/src/test/useWebSocket.dispatch.test.ts +++ b/frontend/src/test/useWebSocket.dispatch.test.ts @@ -112,9 +112,9 @@ describe('useWebSocket dispatch', () => { name: null, type: 0, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: -1, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/test/wsEvents.test.ts b/frontend/src/test/wsEvents.test.ts index 8faef36..20ec2f1 100644 --- a/frontend/src/test/wsEvents.test.ts +++ b/frontend/src/test/wsEvents.test.ts @@ -25,9 +25,9 @@ describe('wsEvents', () => { name: null, type: 0, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: -1, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, last_advert: null, lat: null, lon: null, @@ -50,9 +50,9 @@ describe('wsEvents', () => { name: null, type: 0, flags: 0, - last_path: null, - last_path_len: -1, - out_path_hash_mode: -1, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, last_advert: null, lat: null, lon: null, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2445d52..4e549a5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -99,12 +99,17 @@ export interface Contact { name: string | null; type: number; flags: number; - last_path: string | null; - last_path_len: number; - out_path_hash_mode: number; + direct_path: string | null; + direct_path_len: number; + direct_path_hash_mode: number; + direct_path_updated_at?: number | null; route_override_path?: string | null; route_override_len?: number | null; route_override_hash_mode?: number | null; + effective_route?: ContactRoute | null; + effective_route_source?: 'override' | 'direct' | 'flood'; + direct_route?: ContactRoute | null; + route_override?: ContactRoute | null; last_advert: number | null; lat: number | null; lon: number | null; @@ -115,6 +120,12 @@ export interface Contact { first_seen: number | null; } +export interface ContactRoute { + path: string; + path_len: number; + path_hash_mode: number; +} + export interface ContactAdvertPath { path: string; path_len: number; diff --git a/frontend/src/utils/pathUtils.ts b/frontend/src/utils/pathUtils.ts index e041252..c98577b 100644 --- a/frontend/src/utils/pathUtils.ts +++ b/frontend/src/utils/pathUtils.ts @@ -1,4 +1,4 @@ -import type { Contact, RadioConfig, MessagePath } from '../types'; +import type { Contact, ContactRoute, RadioConfig, MessagePath } from '../types'; import { CONTACT_TYPE_REPEATER } from '../types'; const MAX_PATH_BYTES = 64; @@ -37,6 +37,7 @@ export interface EffectiveContactRoute { pathLen: number; pathHashMode: number; forced: boolean; + source: 'override' | 'direct' | 'flood'; } function normalizePathHashMode(mode: number | null | undefined): number | null { @@ -114,29 +115,86 @@ export function parsePathHops(path: string | null | undefined, hopCount?: number } export function hasRoutingOverride(contact: Contact): boolean { - return contact.route_override_len !== null && contact.route_override_len !== undefined; + return ( + (contact.route_override !== null && contact.route_override !== undefined) || + (contact.route_override_len !== null && contact.route_override_len !== undefined) + ); +} + +export function getDirectContactRoute(contact: Contact): ContactRoute | null { + if (contact.direct_route) { + return contact.direct_route; + } + + if (contact.direct_path_len < 0) { + return null; + } + + return { + path: contact.direct_path ?? '', + path_len: contact.direct_path_len, + path_hash_mode: + normalizePathHashMode(contact.direct_path_hash_mode) ?? + inferPathHashMode(contact.direct_path, contact.direct_path_len) ?? + 0, + }; +} + +function getRouteOverride(contact: Contact): ContactRoute | null { + if (contact.route_override) { + return contact.route_override; + } + + if (!hasRoutingOverride(contact)) { + return null; + } + + const pathLen = contact.route_override_len ?? -1; + let pathHashMode = normalizePathHashMode(contact.route_override_hash_mode); + if (pathLen === -1) { + pathHashMode = -1; + } else if (pathHashMode == null) { + pathHashMode = inferPathHashMode(contact.route_override_path, pathLen) ?? 0; + } + + return { + path: contact.route_override_path ?? '', + path_len: pathLen, + path_hash_mode: pathHashMode, + }; } export function getEffectiveContactRoute(contact: Contact): EffectiveContactRoute { - const forced = hasRoutingOverride(contact); - const pathLen = forced ? (contact.route_override_len ?? -1) : contact.last_path_len; - const path = forced ? (contact.route_override_path ?? '') : (contact.last_path ?? ''); + const route = contact.effective_route; + if (route) { + return { + path: route.path || null, + pathLen: route.path_len, + pathHashMode: route.path_hash_mode, + forced: contact.effective_route_source === 'override', + source: contact.effective_route_source ?? 'flood', + }; + } - let pathHashMode = forced - ? (contact.route_override_hash_mode ?? null) - : (contact.out_path_hash_mode ?? null); + const directRoute = getDirectContactRoute(contact); + const overrideRoute = getRouteOverride(contact); + const resolvedRoute = overrideRoute ?? directRoute; + const source = overrideRoute ? 'override' : directRoute ? 'direct' : 'flood'; + const pathLen = resolvedRoute?.path_len ?? -1; + let pathHashMode = resolvedRoute?.path_hash_mode ?? null; if (pathLen === -1) { pathHashMode = -1; } else if (pathHashMode == null || pathHashMode < 0 || pathHashMode > 2) { - pathHashMode = inferPathHashMode(path, pathLen) ?? 0; + pathHashMode = inferPathHashMode(resolvedRoute?.path, pathLen) ?? 0; } return { - path: path || null, + path: resolvedRoute?.path || null, pathLen, pathHashMode, - forced, + forced: source === 'override', + source, }; } @@ -151,16 +209,17 @@ export function formatRouteLabel(pathLen: number, capitalize: boolean = false): } export function formatRoutingOverrideInput(contact: Contact): string { - if (!hasRoutingOverride(contact)) { + const routeOverride = getRouteOverride(contact); + if (!routeOverride) { return ''; } - if (contact.route_override_len === -1) { + if (routeOverride.path_len === -1) { return '-1'; } - if (contact.route_override_len === 0) { + if (routeOverride.path_len === 0) { return '0'; } - return parsePathHops(contact.route_override_path, contact.route_override_len) + return parsePathHops(routeOverride.path, routeOverride.path_len) .map((hop) => hop.toLowerCase()) .join(','); } diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index f5319a6..a00cb88 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -90,9 +90,9 @@ export interface Contact { name: string | null; type: number; flags: number; - last_path: string | null; - last_path_len: number; - out_path_hash_mode: number; + direct_path: string | null; + direct_path_len: number; + direct_path_hash_mode: number; route_override_path?: string | null; route_override_len?: number | null; route_override_hash_mode?: number | null; diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 3eb77d2..e5d9ac6 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -588,12 +588,13 @@ class TestRoutingOverride: assert contact.last_path_len == 1 @pytest.mark.asyncio - async def test_blank_route_clears_override_and_resets_learned_path(self, test_db, client): + async def test_blank_route_clears_override_and_preserves_learned_path(self, test_db, client): await _insert_contact( KEY_A, - last_path="11", - last_path_len=1, - out_path_hash_mode=0, + direct_path="11", + direct_path_len=1, + direct_path_hash_mode=0, + direct_path_updated_at=1700000000, route_override_path="ae92f13e", route_override_len=2, route_override_hash_mode=1, @@ -613,9 +614,10 @@ class TestRoutingOverride: contact = await ContactRepository.get_by_key(KEY_A) assert contact is not None assert contact.route_override_len is None - assert contact.last_path == "" - assert contact.last_path_len == -1 - assert contact.out_path_hash_mode == -1 + assert contact.direct_path == "11" + assert contact.direct_path_len == 1 + assert contact.direct_path_hash_mode == 0 + assert contact.direct_path_updated_at == 1700000000 @pytest.mark.asyncio async def test_rejects_invalid_explicit_route(self, test_db, client): diff --git a/tests/test_decoder.py b/tests/test_decoder.py index b7cca75..40fcb23 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -16,12 +16,14 @@ from app.decoder import ( _clamp_scalar, decrypt_direct_message, decrypt_group_text, + decrypt_path_payload, derive_public_key, derive_shared_secret, extract_payload, parse_packet, try_decrypt_dm, try_decrypt_packet_with_channel_key, + try_decrypt_path, ) @@ -298,6 +300,181 @@ class TestGroupTextDecryption: assert result is None +class TestPathDecryption: + """Test PATH payload decryption against the firmware wire format.""" + + WORKED_PATH_PACKET = bytes.fromhex("22007EDE577469F4134F9B00EDD57EB4353A1B5999B7") + WORKED_PATH_SENDER_PRIV = bytes.fromhex( + "489E11DCC0A5E037E65C90D2327AA11A42EAFE0C9F68DEBE82B0F71C88C0874B" + "CC291D9B2B98A54F5C1426B7AB8156B0D684EAA4EBA755AC614A9FD32B74C308" + ) + WORKED_PATH_DEST_PUB = bytes.fromhex( + "7e23132922070404863fe855248ce414b64012c891342c1fc7ee5bd3d51ea405" + ) + + @staticmethod + def _create_encrypted_path_payload( + *, + shared_secret: bytes, + dest_hash: int, + src_hash: int, + packed_path_len: int, + path_bytes: bytes, + extra_type: int, + extra: bytes, + ) -> bytes: + plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra + pad_len = (16 - len(plaintext) % 16) % 16 + if pad_len == 0: + pad_len = 16 + plaintext += bytes(pad_len) + + cipher = AES.new(shared_secret[:16], AES.MODE_ECB) + ciphertext = cipher.encrypt(plaintext) + mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2] + return bytes([dest_hash, src_hash]) + mac + ciphertext + + def test_decrypt_path_payload_matches_firmware_layout(self): + """PATH packets are dest/src hashes plus MAC+ciphertext; decrypted data is path+extra.""" + shared_secret = bytes(range(32)) + payload = self._create_encrypted_path_payload( + shared_secret=shared_secret, + dest_hash=0xAE, + src_hash=0x11, + packed_path_len=0x42, # mode 1 (2-byte hops), 2 hops + path_bytes=bytes.fromhex("aabbccdd"), + extra_type=PayloadType.ACK, + extra=bytes.fromhex("01020304"), + ) + + result = decrypt_path_payload(payload, shared_secret) + + assert result is not None + assert result.dest_hash == "ae" + assert result.src_hash == "11" + assert result.returned_path == bytes.fromhex("aabbccdd") + assert result.returned_path_len == 2 + assert result.returned_path_hash_mode == 1 + assert result.extra_type == PayloadType.ACK + assert result.extra[:4] == bytes.fromhex("01020304") + + def test_decrypt_path_payload_rejects_corrupted_mac(self): + """PATH payloads with a bad MAC must be rejected.""" + shared_secret = bytes(range(32)) + payload = self._create_encrypted_path_payload( + shared_secret=shared_secret, + dest_hash=0xAE, + src_hash=0x11, + packed_path_len=0x00, + path_bytes=b"", + extra_type=PayloadType.RESPONSE, + extra=b"\x99\x88", + ) + corrupted = payload[:2] + bytes([payload[2] ^ 0xFF, payload[3]]) + payload[4:] + + result = decrypt_path_payload(corrupted, shared_secret) + + assert result is None + + def test_decrypt_worked_path_packet_fixture(self): + """Worked PATH sample from the design doc decrypts as a direct route.""" + packet = parse_packet(self.WORKED_PATH_PACKET) + assert packet is not None + assert packet.payload_type == PayloadType.PATH + + shared_secret = derive_shared_secret( + self.WORKED_PATH_SENDER_PRIV, self.WORKED_PATH_DEST_PUB + ) + result = decrypt_path_payload(packet.payload, shared_secret) + + assert result is not None + assert result.dest_hash == "7e" + assert result.src_hash == "de" + assert result.returned_path == b"" + assert result.returned_path_len == 0 + assert result.returned_path_hash_mode == 0 + assert result.extra_type == 0x0F + + +class TestTryDecryptPath: + """Test the full PATH decryption wrapper.""" + + OUR_PRIV = bytes.fromhex( + "58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B" + "77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1" + ) + THEIR_PUB = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7") + + @classmethod + def _make_path_packet( + cls, + *, + packed_path_len: int, + path_bytes: bytes, + extra_type: int, + extra: bytes, + ) -> bytes: + shared_secret = derive_shared_secret(cls.OUR_PRIV, cls.THEIR_PUB) + plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra + pad_len = (16 - len(plaintext) % 16) % 16 + if pad_len == 0: + pad_len = 16 + plaintext += bytes(pad_len) + + cipher = AES.new(shared_secret[:16], AES.MODE_ECB) + ciphertext = cipher.encrypt(plaintext) + mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2] + our_public = derive_public_key(cls.OUR_PRIV) + return ( + bytes([(PayloadType.PATH << 2) | RouteType.DIRECT, 0x00]) + + bytes([our_public[0], cls.THEIR_PUB[0]]) + + mac + + ciphertext + ) + + def test_try_decrypt_path_decrypts_full_packet(self): + """try_decrypt_path validates hashes, derives ECDH, and returns the route.""" + raw_packet = self._make_path_packet( + packed_path_len=0x42, + path_bytes=bytes.fromhex("aabbccdd"), + extra_type=PayloadType.ACK, + extra=bytes.fromhex("01020304"), + ) + + result = try_decrypt_path( + raw_packet=raw_packet, + our_private_key=self.OUR_PRIV, + their_public_key=self.THEIR_PUB, + our_public_key=derive_public_key(self.OUR_PRIV), + ) + + assert result is not None + assert result.returned_path == bytes.fromhex("aabbccdd") + assert result.returned_path_len == 2 + assert result.returned_path_hash_mode == 1 + assert result.extra_type == PayloadType.ACK + assert result.extra[:4] == bytes.fromhex("01020304") + + def test_try_decrypt_path_rejects_hash_mismatch(self): + """Packets addressed to another destination are rejected before decryption.""" + raw_packet = self._make_path_packet( + packed_path_len=0x00, + path_bytes=b"", + extra_type=PayloadType.RESPONSE, + extra=b"\xaa", + ) + wrong_our_public = bytes.fromhex("ff") + derive_public_key(self.OUR_PRIV)[1:] + + result = try_decrypt_path( + raw_packet=raw_packet, + our_private_key=self.OUR_PRIV, + their_public_key=self.THEIR_PUB, + our_public_key=wrong_our_public, + ) + + assert result is None + + class TestTryDecryptPacket: """Test the full packet decryption pipeline.""" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 88b1b30..d320c6c 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1247,23 +1247,25 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 6 - assert await get_version(conn) == 44 + assert applied == 7 + assert await get_version(conn) == 45 cursor = await conn.execute( """ - SELECT public_key, last_path_len, out_path_hash_mode + SELECT public_key, direct_path, direct_path_len, direct_path_hash_mode FROM contacts ORDER BY public_key """ ) rows = await cursor.fetchall() assert rows[0]["public_key"] == "aa" * 32 - assert rows[0]["last_path_len"] == -1 - assert rows[0]["out_path_hash_mode"] == -1 + assert rows[0]["direct_path"] == "" + assert rows[0]["direct_path_len"] == -1 + assert rows[0]["direct_path_hash_mode"] == -1 assert rows[1]["public_key"] == "bb" * 32 - assert rows[1]["last_path_len"] == 1 - assert rows[1]["out_path_hash_mode"] == 0 + assert rows[1]["direct_path"] == "1122" + assert rows[1]["direct_path_len"] == 1 + assert rows[1]["direct_path_hash_mode"] == 0 finally: await conn.close() @@ -1317,12 +1319,12 @@ class TestMigration039: applied = await run_migrations(conn) - assert applied == 6 - assert await get_version(conn) == 44 + assert applied == 7 + assert await get_version(conn) == 45 cursor = await conn.execute( """ - SELECT public_key, out_path_hash_mode + SELECT public_key, direct_path_hash_mode FROM contacts WHERE public_key IN (?, ?) ORDER BY public_key @@ -1331,9 +1333,9 @@ class TestMigration039: ) rows = await cursor.fetchall() assert rows[0]["public_key"] == "cc" * 32 - assert rows[0]["out_path_hash_mode"] == 1 + assert rows[0]["direct_path_hash_mode"] == 1 assert rows[1]["public_key"] == "dd" * 32 - assert rows[1]["out_path_hash_mode"] == -1 + assert rows[1]["direct_path_hash_mode"] == -1 finally: await conn.close() @@ -1371,8 +1373,8 @@ class TestMigration040: applied = await run_migrations(conn) - assert applied == 5 - assert await get_version(conn) == 44 + assert applied == 6 + assert await get_version(conn) == 45 await conn.execute( """ @@ -1433,8 +1435,8 @@ class TestMigration041: applied = await run_migrations(conn) - assert applied == 4 - assert await get_version(conn) == 44 + assert applied == 5 + assert await get_version(conn) == 45 await conn.execute( """ @@ -1486,8 +1488,8 @@ class TestMigration042: applied = await run_migrations(conn) - assert applied == 3 - assert await get_version(conn) == 44 + assert applied == 4 + assert await get_version(conn) == 45 await conn.execute( """ diff --git a/tests/test_packet_pipeline.py b/tests/test_packet_pipeline.py index 4e9360f..d23ee2b 100644 --- a/tests/test_packet_pipeline.py +++ b/tests/test_packet_pipeline.py @@ -12,10 +12,20 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest +from Crypto.Cipher import AES -from app.decoder import DecryptedDirectMessage, PacketInfo, ParsedAdvertisement, PayloadType +from app.decoder import ( + DecryptedDirectMessage, + PacketInfo, + ParsedAdvertisement, + PayloadType, + RouteType, + derive_public_key, + derive_shared_secret, +) from app.repository import ( ChannelRepository, + ContactAdvertPathRepository, ContactRepository, MessageRepository, RawPacketRepository, @@ -27,6 +37,43 @@ with open(FIXTURES_PATH) as f: FIXTURES = json.load(f) +PATH_TEST_OUR_PRIV = bytes.fromhex( + "58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B" + "77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1" +) +PATH_TEST_CONTACT_PUB = bytes.fromhex( + "a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7" +) +PATH_TEST_OUR_PUB = derive_public_key(PATH_TEST_OUR_PRIV) + + +def _build_path_packet( + *, + packed_path_len: int, + path_bytes: bytes, + extra_type: int, + extra: bytes, + route_type: RouteType = RouteType.DIRECT, +) -> bytes: + shared_secret = derive_shared_secret(PATH_TEST_OUR_PRIV, PATH_TEST_CONTACT_PUB) + plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra + pad_len = (16 - len(plaintext) % 16) % 16 + if pad_len == 0: + pad_len = 16 + plaintext += bytes(pad_len) + + cipher = AES.new(shared_secret[:16], AES.MODE_ECB) + ciphertext = cipher.encrypt(plaintext) + + import hmac + from hashlib import sha256 + + mac = hmac.new(shared_secret, ciphertext, sha256).digest()[:2] + header = bytes([(PayloadType.PATH << 2) | route_type, 0x00]) + payload = bytes([PATH_TEST_OUR_PUB[0], PATH_TEST_CONTACT_PUB[0]]) + mac + ciphertext + return header + payload + + class TestChannelMessagePipeline: """Test channel message flow: packet → decrypt → store → broadcast.""" @@ -169,10 +216,13 @@ class TestAdvertisementPipeline: assert contact.lon is not None assert abs(contact.lat - expected["lat"]) < 0.001 assert abs(contact.lon - expected["lon"]) < 0.001 - # This advertisement has path_len=6 (6 hops through repeaters) - assert contact.last_path_len == 6 - assert contact.last_path is not None - assert len(contact.last_path) == 12 # 6 bytes = 12 hex chars + assert contact.last_path_len == -1 + assert contact.last_path in (None, "") + + advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key) + assert len(advert_paths) == 1 + assert advert_paths[0].path_len == 6 + assert len(advert_paths[0].path) == 12 # 6 bytes = 12 hex chars # Verify WebSocket broadcast contact_broadcasts = [b for b in broadcasts if b["type"] == "contact"] @@ -182,7 +232,8 @@ class TestAdvertisementPipeline: assert broadcast["data"]["public_key"] == expected["public_key"] assert broadcast["data"]["name"] == expected["name"] assert broadcast["data"]["type"] == expected["type"] - assert broadcast["data"]["last_path_len"] == 6 + assert broadcast["data"]["direct_path_len"] == -1 + assert "last_path_len" not in broadcast["data"] @pytest.mark.asyncio async def test_advertisement_updates_existing_contact(self, test_db, captured_broadcasts): @@ -216,10 +267,11 @@ class TestAdvertisementPipeline: assert contact.type == expected["type"] # Type updated assert contact.lat is not None # GPS added assert contact.lon is not None - # This advertisement has path_len=0 (direct neighbor) - assert contact.last_path_len == 0 - # Empty path stored as None or "" - assert contact.last_path in (None, "") + + advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key) + assert len(advert_paths) == 1 + assert advert_paths[0].path_len == 0 + assert advert_paths[0].path == "" @pytest.mark.asyncio async def test_advertisement_triggers_historical_decrypt_for_new_contact( @@ -278,42 +330,38 @@ class TestAdvertisementPipeline: assert mock_start.await_count == 0 @pytest.mark.asyncio - async def test_advertisement_keeps_shorter_path_within_window( + async def test_advertisement_records_recent_unique_paths_without_changing_direct_route( self, test_db, captured_broadcasts ): - """When receiving echoed advertisements, keep the shortest path within 60s window.""" + """Advertisement paths are informational and do not replace the stored direct route.""" from app.packet_processor import _process_advertisement - # Create a contact with a longer path (path_len=3) test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" await ContactRepository.upsert( { "public_key": test_pubkey, "name": "TestNode", "type": 1, + "direct_path": "aabbcc", + "direct_path_len": 3, + "direct_path_hash_mode": 0, "last_advert": 1000, "last_seen": 1000, - "last_path_len": 3, - "last_path": "aabbcc", # 3 bytes = 3 hops } ) - # Simulate receiving a shorter path (path_len=1) within 60s - # We'll call _process_advertisement directly with mock packet_info from unittest.mock import MagicMock from app.decoder import ParsedAdvertisement broadcasts, mock_broadcast = captured_broadcasts - # Mock packet_info with shorter path short_packet_info = MagicMock() short_packet_info.path_length = 1 short_packet_info.path = bytes.fromhex("aa") short_packet_info.path_hash_size = 1 - short_packet_info.payload = b"" # Will be parsed by parse_advertisement + short_packet_info.payload = b"" - # Mock parse_advertisement to return our test contact with patch("app.packet_processor.broadcast_event", mock_broadcast): with patch("app.packet_processor.parse_advertisement") as mock_parse: mock_parse.return_value = ParsedAdvertisement( @@ -324,18 +372,13 @@ class TestAdvertisementPipeline: lon=None, device_role=1, ) - # Process at timestamp 1050 (within 60s of last_seen=1000) await _process_advertisement(b"", timestamp=1050, packet_info=short_packet_info) - # Verify the shorter path was stored - contact = await ContactRepository.get_by_key(test_pubkey) - assert contact.last_path_len == 1 # Updated to shorter path - - # Now simulate receiving a longer path (path_len=5) - should keep the shorter one long_packet_info = MagicMock() long_packet_info.path_length = 5 long_packet_info.path = bytes.fromhex("aabbccddee") long_packet_info.path_hash_size = 1 + long_packet_info.payload = b"" with patch("app.packet_processor.broadcast_event", mock_broadcast): with patch("app.packet_processor.parse_advertisement") as mock_parse: @@ -347,35 +390,33 @@ class TestAdvertisementPipeline: lon=None, device_role=1, ) - # Process at timestamp 1055 (within 60s of last update) await _process_advertisement(b"", timestamp=1055, packet_info=long_packet_info) - # Verify the shorter path was kept contact = await ContactRepository.get_by_key(test_pubkey) - assert contact.last_path_len == 1 # Still the shorter path + assert contact is not None + assert contact.direct_path == "aabbcc" + assert contact.direct_path_len == 3 + assert contact.direct_path_hash_mode == 0 + assert contact.direct_path_updated_at is None + + advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey) + assert [(path.path, path.path_len) for path in advert_paths] == [ + ("aabbccddee", 5), + ("aa", 1), + ] @pytest.mark.asyncio async def test_advertisement_path_freshness_uses_receive_time_not_sender_clock( self, test_db, captured_broadcasts ): - """Sender clock skew should not keep an old advert path artificially fresh.""" + """Advert history timestamps use receive time instead of sender clock.""" from unittest.mock import MagicMock from app.decoder import ParsedAdvertisement from app.packet_processor import _process_advertisement test_pubkey = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" - await ContactRepository.upsert( - { - "public_key": test_pubkey, - "name": "TestNode", - "type": 1, - "last_advert": 1000, - "last_seen": 1055, # Simulates later non-advert activity - "last_path_len": 1, - "last_path": "aa", - } - ) + await ContactRepository.upsert({"public_key": test_pubkey, "name": "TestNode", "type": 1}) broadcasts, mock_broadcast = captured_broadcasts @@ -419,19 +460,19 @@ class TestAdvertisementPipeline: contact = await ContactRepository.get_by_key(test_pubkey) assert contact is not None - assert contact.last_path_len == 3 - assert contact.last_path == "aabbcc" assert contact.last_advert == 1200 + advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey) + assert [(path.path, path.last_seen) for path in advert_paths] == [ + ("aabbcc", 1200), + ("aa", 1070), + ] + @pytest.mark.asyncio - async def test_advertisement_default_path_len_treated_as_infinity( + async def test_advertisement_records_path_history_when_no_direct_route_exists( self, test_db, captured_broadcasts ): - """Contact with last_path_len=-1 (unset) is treated as infinite length. - - Any new advertisement should replace the default -1 path since - the code converts -1 to float('inf') for comparison. - """ + """Advertisement path history is still recorded when no direct route exists.""" from app.packet_processor import _process_advertisement test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -441,8 +482,6 @@ class TestAdvertisementPipeline: "name": "TestNode", "type": 1, "last_seen": 1000, - "last_path_len": -1, # Default unset value - "last_path": None, } ) @@ -465,23 +504,21 @@ class TestAdvertisementPipeline: lon=None, device_role=1, ) - # Process within 60s window (last_seen=1000, now=1050) await _process_advertisement(b"", timestamp=1050, packet_info=packet_info) - # Since -1 is treated as infinity, the new path (len=3) should replace it contact = await ContactRepository.get_by_key(test_pubkey) - assert contact.last_path_len == 3 - assert contact.last_path == "aabbcc" + assert contact is not None + assert contact.direct_path_len == -1 + assert contact.direct_path in (None, "") + assert contact.direct_path_updated_at is None + advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey) + assert len(advert_paths) == 1 + assert advert_paths[0].path == "aabbcc" + assert advert_paths[0].path_len == 3 @pytest.mark.asyncio - async def test_advertisement_replaces_stale_path_outside_window( - self, test_db, captured_broadcasts - ): - """When existing path is stale (>60s), a new longer path should replace it. - - In a mesh network, a stale short path may no longer be valid (node moved, repeater - went offline). Accepting the new longer path ensures we have a working route. - """ + async def test_advertisement_adds_new_unique_history_path(self, test_db, captured_broadcasts): + """A new advertisement path is added to history even when an older path already exists.""" from app.packet_processor import _process_advertisement test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -491,10 +528,9 @@ class TestAdvertisementPipeline: "name": "TestNode", "type": 1, "last_seen": 1000, - "last_path_len": 1, # Short path - "last_path": "aa", } ) + await ContactAdvertPathRepository.record_observation(test_pubkey, "aa", 1000, hop_count=1) from unittest.mock import MagicMock @@ -521,10 +557,158 @@ class TestAdvertisementPipeline: ) await _process_advertisement(b"", timestamp=1061, packet_info=long_packet_info) - # Verify the longer path replaced the stale shorter one contact = await ContactRepository.get_by_key(test_pubkey) - assert contact.last_path_len == 4 - assert contact.last_path == "aabbccdd" + assert contact is not None + assert contact.last_path_len == -1 + advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey) + assert [(path.path, path.path_len) for path in advert_paths] == [ + ("aabbccdd", 4), + ("aa", 1), + ] + + +class TestPathPacketPipeline: + """Test PATH packet learning and bundled ACK handling.""" + + @pytest.mark.asyncio + async def test_process_raw_path_packet_updates_direct_route(self, test_db, captured_broadcasts): + """A decryptable PATH packet updates the contact's learned direct route.""" + from app.packet_processor import process_raw_packet + + timestamp = 1700000200 + raw_packet = _build_path_packet( + packed_path_len=0x42, + path_bytes=bytes.fromhex("aabbccdd"), + extra_type=PayloadType.RESPONSE, + extra=b"\x11\x22", + ) + + await ContactRepository.upsert( + { + "public_key": PATH_TEST_CONTACT_PUB.hex(), + "name": "PathPeer", + "type": 1, + } + ) + + broadcasts, mock_broadcast = captured_broadcasts + + with ( + patch("app.packet_processor.broadcast_event", mock_broadcast), + patch("app.packet_processor.has_private_key", return_value=True), + patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV), + patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB), + ): + result = await process_raw_packet(raw_packet, timestamp=timestamp) + + assert result["payload_type"] == "PATH" + contact = await ContactRepository.get_by_key(PATH_TEST_CONTACT_PUB.hex()) + assert contact is not None + assert contact.direct_path == "aabbccdd" + assert contact.direct_path_len == 2 + assert contact.direct_path_hash_mode == 1 + assert contact.direct_path_updated_at == timestamp + + contact_broadcasts = [b for b in broadcasts if b["type"] == "contact"] + assert len(contact_broadcasts) == 1 + assert contact_broadcasts[0]["data"]["effective_route_source"] == "direct" + + @pytest.mark.asyncio + async def test_bundled_path_ack_marks_message_acked(self, test_db, captured_broadcasts): + """Bundled ACKs inside PATH packets satisfy the pending DM ACK tracker.""" + from app.packet_processor import process_raw_packet + from app.services import dm_ack_tracker + + raw_packet = _build_path_packet( + packed_path_len=0x00, + path_bytes=b"", + extra_type=PayloadType.ACK, + extra=bytes.fromhex("01020304feedface"), + ) + + await ContactRepository.upsert( + { + "public_key": PATH_TEST_CONTACT_PUB.hex(), + "name": "AckPeer", + "type": 1, + } + ) + message_id = await MessageRepository.create( + msg_type="PRIV", + text="waiting for ack", + conversation_key=PATH_TEST_CONTACT_PUB.hex(), + sender_timestamp=1700000000, + received_at=1700000000, + outgoing=True, + ) + + prev_pending = dm_ack_tracker._pending_acks.copy() + prev_buffered = dm_ack_tracker._buffered_acks.copy() + dm_ack_tracker._pending_acks.clear() + dm_ack_tracker._buffered_acks.clear() + dm_ack_tracker.track_pending_ack("01020304", message_id, 30000) + dm_ack_tracker.track_pending_ack("05060708", message_id, 30000) + + broadcasts, mock_broadcast = captured_broadcasts + try: + with ( + patch("app.packet_processor.broadcast_event", mock_broadcast), + patch("app.packet_processor.has_private_key", return_value=True), + patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV), + patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB), + ): + await process_raw_packet(raw_packet, timestamp=1700000300) + finally: + pending_after = dm_ack_tracker._pending_acks.copy() + dm_ack_tracker._pending_acks.clear() + dm_ack_tracker._pending_acks.update(prev_pending) + dm_ack_tracker._buffered_acks.clear() + dm_ack_tracker._buffered_acks.update(prev_buffered) + + messages = await MessageRepository.get_all( + msg_type="PRIV", + conversation_key=PATH_TEST_CONTACT_PUB.hex(), + limit=10, + ) + assert len(messages) == 1 + assert messages[0].acked == 1 + assert "01020304" not in pending_after + assert "05060708" not in pending_after + + ack_broadcasts = [b for b in broadcasts if b["type"] == "message_acked"] + assert len(ack_broadcasts) == 1 + assert ack_broadcasts[0]["data"] == {"message_id": message_id, "ack_count": 1} + + @pytest.mark.asyncio + async def test_prefix_only_contacts_are_skipped_for_path_decryption(self, test_db): + """Prefix-only contacts are not treated as valid ECDH peers for PATH packets.""" + from app.packet_processor import _process_path_packet + + raw_packet = _build_path_packet( + packed_path_len=0x00, + path_bytes=b"", + extra_type=PayloadType.RESPONSE, + extra=b"\x01", + ) + + await ContactRepository.upsert( + { + "public_key": PATH_TEST_CONTACT_PUB.hex()[:12], + "name": "PrefixOnly", + "type": 1, + } + ) + + with ( + patch("app.packet_processor.has_private_key", return_value=True), + patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV), + patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB), + patch( + "app.packet_processor.try_decrypt_path", + side_effect=AssertionError("prefix-only contacts should be skipped"), + ), + ): + await _process_path_packet(raw_packet, 1700000400, None) class TestAckPipeline: @@ -1694,8 +1878,12 @@ class TestProcessRawPacketIntegration: contact = await ContactRepository.get_by_key(test_pubkey) assert contact is not None - assert contact.last_path_len == 1 # Shorter path won - assert contact.last_path == "dd" + assert contact.last_path_len == -1 + advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey) + assert [(path.path, path.path_len) for path in advert_paths] == [ + ("dd", 1), + ("aabbcc", 3), + ] @pytest.mark.asyncio async def test_dispatches_text_message(self, test_db, captured_broadcasts): diff --git a/tests/test_path_utils.py b/tests/test_path_utils.py index b127c44..d57cd43 100644 --- a/tests/test_path_utils.py +++ b/tests/test_path_utils.py @@ -200,16 +200,16 @@ class TestParseExplicitHopRoute: class TestContactToRadioDictHashMode: - """Test that Contact.to_radio_dict() preserves the stored out_path_hash_mode.""" + """Test that Contact.to_radio_dict() preserves the stored direct-route hash mode.""" def test_preserves_1byte_mode(self): from app.models import Contact c = Contact( public_key="aa" * 32, - last_path="1a2b3c", - last_path_len=3, - out_path_hash_mode=0, + direct_path="1a2b3c", + direct_path_len=3, + direct_path_hash_mode=0, ) d = c.to_radio_dict() assert d["out_path_hash_mode"] == 0 @@ -219,9 +219,9 @@ class TestContactToRadioDictHashMode: c = Contact( public_key="bb" * 32, - last_path="1a2b3c4d", - last_path_len=2, - out_path_hash_mode=1, + direct_path="1a2b3c4d", + direct_path_len=2, + direct_path_hash_mode=1, ) d = c.to_radio_dict() assert d["out_path_hash_mode"] == 1 @@ -231,9 +231,9 @@ class TestContactToRadioDictHashMode: c = Contact( public_key="cc" * 32, - last_path="1a2b3c4d5e6f", - last_path_len=2, - out_path_hash_mode=2, + direct_path="1a2b3c4d5e6f", + direct_path_len=2, + direct_path_hash_mode=2, ) d = c.to_radio_dict() assert d["out_path_hash_mode"] == 2 @@ -243,9 +243,9 @@ class TestContactToRadioDictHashMode: c = Contact( public_key="dd" * 32, - last_path=None, - last_path_len=-1, - out_path_hash_mode=-1, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, ) d = c.to_radio_dict() assert d["out_path_hash_mode"] == -1 @@ -255,9 +255,9 @@ class TestContactToRadioDictHashMode: c = Contact( public_key="ee" * 32, - last_path="aa00bb00", - last_path_len=2, - out_path_hash_mode=1, + direct_path="aa00bb00", + direct_path_len=2, + direct_path_hash_mode=1, ) d = c.to_radio_dict() assert d["out_path_hash_mode"] == 1 @@ -267,9 +267,9 @@ class TestContactToRadioDictHashMode: c = Contact( public_key="ff" * 32, - last_path="3f3f69de1c7b7e7662", - last_path_len=-125, - out_path_hash_mode=2, + direct_path="3f3f69de1c7b7e7662", + direct_path_len=-125, + direct_path_hash_mode=2, ) d = c.to_radio_dict() assert d["out_path"] == "3f3f69de1c7b7e7662" @@ -281,9 +281,9 @@ class TestContactToRadioDictHashMode: c = Contact( public_key="11" * 32, - last_path="aabb", - last_path_len=1, - out_path_hash_mode=0, + direct_path="aabb", + direct_path_len=1, + direct_path_hash_mode=0, route_override_path="cc00dd00", route_override_len=2, route_override_hash_mode=1, @@ -309,7 +309,9 @@ class TestContactFromRadioDictHashMode: "out_path_hash_mode": 1, }, ) - assert d["out_path_hash_mode"] == 1 + assert d["direct_path"] == "aa00bb00" + assert d["direct_path_len"] == 2 + assert d["direct_path_hash_mode"] == 1 def test_flood_falls_back_to_minus_one(self): from app.models import Contact @@ -322,4 +324,6 @@ class TestContactFromRadioDictHashMode: "out_path_len": -1, }, ) - assert d["out_path_hash_mode"] == -1 + assert d["direct_path"] == "" + assert d["direct_path_len"] == -1 + assert d["direct_path_hash_mode"] == -1 diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index 8e34a46..3f10009 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -71,9 +71,9 @@ async def _insert_contact( contact_type=0, last_contacted=None, last_advert=None, - last_path=None, - last_path_len=-1, - out_path_hash_mode=0, + direct_path=None, + direct_path_len=-1, + direct_path_hash_mode=-1, ): """Insert a contact into the test database.""" await ContactRepository.upsert( @@ -82,9 +82,9 @@ async def _insert_contact( "name": name, "type": contact_type, "flags": 0, - "last_path": last_path, - "last_path_len": last_path_len, - "out_path_hash_mode": out_path_hash_mode, + "direct_path": direct_path, + "direct_path_len": direct_path_len, + "direct_path_hash_mode": direct_path_hash_mode, "last_advert": last_advert, "lat": None, "lon": None, @@ -597,9 +597,9 @@ class TestSyncAndOffloadAll: KEY_A, "Alice", last_contacted=2000, - last_path="aa00bb00", - last_path_len=2, - out_path_hash_mode=1, + direct_path="aa00bb00", + direct_path_len=2, + direct_path_hash_mode=1, ) await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)]) @@ -626,9 +626,9 @@ class TestSyncAndOffloadAll: KEY_A, "Alice", last_contacted=2000, - last_path="3f3f69de1c7b7e7662", - last_path_len=-125, - out_path_hash_mode=2, + direct_path="3f3f69de1c7b7e7662", + direct_path_len=-125, + direct_path_hash_mode=2, ) await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)]) diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index dbb5500..41a8737 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -83,8 +83,9 @@ async def _insert_contact(public_key, name="Alice", **overrides): "name": name, "type": 0, "flags": 0, - "last_path": None, - "last_path_len": -1, + "direct_path": None, + "direct_path_len": -1, + "direct_path_hash_mode": -1, "last_advert": None, "lat": None, "lon": None, @@ -152,16 +153,16 @@ class TestOutgoingDMBroadcast: assert "ambiguous" in exc_info.value.detail.lower() @pytest.mark.asyncio - async def test_send_dm_preserves_stored_out_path_hash_mode(self, test_db): + async def test_send_dm_preserves_stored_direct_path_hash_mode(self, test_db): """Direct-message send pushes the persisted path hash mode back to the radio.""" mc = _make_mc() pub_key = "cd" * 32 await _insert_contact( pub_key, "Alice", - last_path="aa00bb00", - last_path_len=2, - out_path_hash_mode=1, + direct_path="aa00bb00", + direct_path_len=2, + direct_path_hash_mode=1, ) with ( @@ -185,9 +186,9 @@ class TestOutgoingDMBroadcast: await _insert_contact( pub_key, "Alice", - last_path="aabb", - last_path_len=1, - out_path_hash_mode=0, + direct_path="aabb", + direct_path_len=1, + direct_path_hash_mode=0, route_override_path="cc00dd00", route_override_len=2, route_override_hash_mode=1,