mirror of
https://github.com/meshcore-dev/meshcore_py.git
synced 2026-08-01 20:56:12 +00:00
fix(anon-req): allow requests to destinations that are not contacts
send_anon_req() refused to send whenever the destination pubkey was absent from the client-side contact cache, returning ERROR contact_not_found. The contact is consulted for one thing only: building the reply-path bytes appended to the request. The companion firmware needs no contact of its own -- since FIRMWARE_VER_CODE 13 its CMD_SEND_ANON_REQ handler synthesises a transient anon contact for an unknown pubkey with out_path_len = 0 (zero-hop direct). Those entries live in a reserved slot ring, are hidden from CMD_GET_CONTACTS and are never persisted, so nothing is polluted by them. The client-side refusal therefore blocked a case the device supports, such as asking a freshly discovered neighbour for its regions before it has ever been added as a contact. Fall back to a zero-hop reply path instead. Two smaller fixes in the same function: - out_path_len is now read once into a local rather than re-read from the contact dict after the await. That dict is a live reference other commands mutate in place (send_msg_with_retry's flood fallback, reset_path); if it flipped to -1 mid-send the suggested_timeout multiplier became 4000 * 0 = 0, registering the binary request with a zero timeout so the response was dropped the moment it arrived. - The value is clamped at 0. update_contact() normally reflects the change back onto the dict, but if it fails the dict stays -1 and the unsigned to_bytes raises OverflowError -- which skipped the reset_path at the end of the method and left the contact pinned to zero-hop on the device. Verified against a companion radio on fw ver 13: without the change all five discovered repeaters were refused client-side in 0.0s with no RF sent; with it all three answered with their region scopes in ~1.1s. test_send_anon_req_contact_not_found is replaced -- it codified the removed limitation -- but its original regression (a TypeError on the NoneType subscript) stays covered.
This commit is contained in:
@@ -203,21 +203,199 @@ async def test_set_multi_acks_error(
|
||||
assert result.is_error()
|
||||
|
||||
|
||||
# ── send_anon_req returns ERROR on contact not found ─────────
|
||||
# ── send_anon_req falls back to zero-hop when no contact exists ─────────
|
||||
|
||||
async def test_send_anon_req_contact_not_found(
|
||||
command_handler, mock_dispatcher
|
||||
async def test_send_anon_req_without_contact_sends_zero_hop(
|
||||
command_handler, mock_connection, mock_dispatcher
|
||||
):
|
||||
"""send_anon_req returns ERROR event when contact prefix not found,
|
||||
instead of raising TypeError on NoneType subscript."""
|
||||
"""An unknown destination is sent with a zero-hop reply path.
|
||||
|
||||
The contact is only used to build the reply path. Companion firmware from
|
||||
FIRMWARE_VER_CODE 13 synthesises a transient anon contact for an unknown
|
||||
pubkey, so refusing to send here would needlessly block probing any node
|
||||
the client has not already added. Must still not raise TypeError on the
|
||||
NoneType subscript that this test originally guarded.
|
||||
"""
|
||||
command_handler._get_contact_by_prefix = MagicMock(return_value=None)
|
||||
command_handler.change_contact_path = AsyncMock()
|
||||
command_handler.reset_path = AsyncMock()
|
||||
setup_event_response(
|
||||
mock_dispatcher, EventType.MSG_SENT,
|
||||
{"expected_ack": b"\x01\x02\x03\x04", "suggested_timeout": 4000},
|
||||
)
|
||||
|
||||
result = await command_handler.send_anon_req(
|
||||
VALID_PUBKEY_HEX, MagicMock(value=1)
|
||||
)
|
||||
|
||||
assert result.is_error()
|
||||
assert result.payload["reason"] == "contact_not_found"
|
||||
assert not result.is_error()
|
||||
sent = mock_connection.send.await_args.args[0]
|
||||
# \x39 | 32-byte pubkey | request type | reply-path-len 0 (no path bytes)
|
||||
assert sent == b"\x39" + bytes.fromhex(VALID_PUBKEY_HEX) + b"\x01" + b"\x00"
|
||||
|
||||
# No contact to mutate, so no device round-trips for path changes.
|
||||
command_handler.change_contact_path.assert_not_awaited()
|
||||
command_handler.reset_path.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_send_anon_req_without_contact_does_not_scale_timeout(
|
||||
command_handler, mock_dispatcher
|
||||
):
|
||||
"""suggested_timeout must not be multiplied by a path length we don't have."""
|
||||
command_handler._get_contact_by_prefix = MagicMock(return_value=None)
|
||||
reader = MagicMock()
|
||||
reader.register_binary_request = MagicMock()
|
||||
command_handler._reader = reader
|
||||
setup_event_response(
|
||||
mock_dispatcher, EventType.MSG_SENT,
|
||||
{"expected_ack": b"\x01\x02\x03\x04", "suggested_timeout": 4000},
|
||||
)
|
||||
|
||||
result = await command_handler.send_anon_req(
|
||||
VALID_PUBKEY_HEX, MagicMock(value=1)
|
||||
)
|
||||
|
||||
# out_path_len 0 -> (0 + 1) -> unchanged from the device's own estimate.
|
||||
assert result.payload["suggested_timeout"] == 4000
|
||||
reader.register_binary_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_send_anon_req_with_contact_still_uses_its_path(
|
||||
command_handler, mock_connection, mock_dispatcher
|
||||
):
|
||||
"""A known contact's reply path and timeout scaling are both unchanged.
|
||||
|
||||
_reader must be set: the out_path_len-based timeout scaling is gated behind
|
||||
it, and that scaling is the only behaviour the no-contact refactor touches on
|
||||
this path -- without a reader it is never executed.
|
||||
"""
|
||||
command_handler._get_contact_by_prefix = MagicMock(return_value={
|
||||
"public_key": VALID_PUBKEY_HEX,
|
||||
"out_path_len": 3,
|
||||
"out_path": "aabbcc",
|
||||
})
|
||||
command_handler.change_contact_path = AsyncMock()
|
||||
command_handler.reset_path = AsyncMock()
|
||||
reader = MagicMock()
|
||||
command_handler._reader = reader
|
||||
setup_event_response(
|
||||
mock_dispatcher, EventType.MSG_SENT,
|
||||
{"expected_ack": b"\x01\x02\x03\x04", "suggested_timeout": 4000},
|
||||
)
|
||||
|
||||
result = await command_handler.send_anon_req(VALID_PUBKEY_HEX, MagicMock(value=1))
|
||||
|
||||
sent = mock_connection.send.await_args.args[0]
|
||||
# \x39 | pubkey | request type | reply-path-len 3 | path reversed
|
||||
assert sent == (
|
||||
b"\x39" + bytes.fromhex(VALID_PUBKEY_HEX) + b"\x01"
|
||||
+ b"\x03" + bytes.fromhex("ccbbaa")
|
||||
)
|
||||
# Scaled by out_path_len + 1 = 4.
|
||||
assert result.payload["suggested_timeout"] == 16000
|
||||
reader.register_binary_request.assert_called_once()
|
||||
assert reader.register_binary_request.call_args.args[3] == 16000 / 800.0
|
||||
command_handler.change_contact_path.assert_not_awaited()
|
||||
command_handler.reset_path.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_send_anon_req_timeout_uses_path_len_as_sent(
|
||||
command_handler, mock_connection, mock_dispatcher
|
||||
):
|
||||
"""The timeout multiplier must match the path length actually transmitted.
|
||||
|
||||
The contact dict is a live reference that other commands mutate in place, so
|
||||
re-reading it after the await could scale the timeout by a path length that
|
||||
was never sent.
|
||||
"""
|
||||
contact = {"public_key": VALID_PUBKEY_HEX, "out_path_len": 3, "out_path": "aabbcc"}
|
||||
command_handler._get_contact_by_prefix = MagicMock(return_value=contact)
|
||||
command_handler._reader = MagicMock()
|
||||
|
||||
def fake_subscribe(evt_type, handler, attr_filters=None):
|
||||
sub = MagicMock(spec=Subscription)
|
||||
sub.unsubscribe = MagicMock()
|
||||
if evt_type == EventType.MSG_SENT:
|
||||
# Simulate another command flipping the contact to flood mid-send.
|
||||
contact["out_path_len"] = -1
|
||||
contact["out_path"] = ""
|
||||
asyncio.get_event_loop().call_soon(
|
||||
handler,
|
||||
Event(EventType.MSG_SENT,
|
||||
{"expected_ack": b"\x01\x02\x03\x04", "suggested_timeout": 4000}),
|
||||
)
|
||||
return sub
|
||||
|
||||
mock_dispatcher.subscribe = MagicMock(side_effect=fake_subscribe)
|
||||
|
||||
result = await command_handler.send_anon_req(VALID_PUBKEY_HEX, MagicMock(value=1))
|
||||
|
||||
sent = mock_connection.send.await_args.args[0]
|
||||
assert sent.endswith(b"\x03" + bytes.fromhex("ccbbaa"))
|
||||
# 4 x 4000, matching the 3-hop path in the frame -- not 0 from the mutated -1.
|
||||
assert result.payload["suggested_timeout"] == 16000
|
||||
|
||||
|
||||
async def test_send_anon_req_flood_contact_still_forces_and_restores_zero_hop(
|
||||
command_handler, mock_connection, mock_dispatcher
|
||||
):
|
||||
"""out_path_len == -1 keeps its existing force-zero-hop-then-restore path."""
|
||||
contact = {
|
||||
"public_key": VALID_PUBKEY_HEX,
|
||||
"out_path_len": -1,
|
||||
"out_path": "",
|
||||
}
|
||||
|
||||
async def fake_change_path(c, path):
|
||||
# update_contact() reflects the change onto the dict; -1 would otherwise
|
||||
# raise OverflowError on the unsigned to_bytes below.
|
||||
c["out_path_len"] = 0
|
||||
c["out_path"] = ""
|
||||
|
||||
command_handler._get_contact_by_prefix = MagicMock(return_value=contact)
|
||||
command_handler.change_contact_path = AsyncMock(side_effect=fake_change_path)
|
||||
command_handler.reset_path = AsyncMock()
|
||||
setup_event_response(
|
||||
mock_dispatcher, EventType.MSG_SENT,
|
||||
{"expected_ack": b"\x01\x02\x03\x04", "suggested_timeout": 4000},
|
||||
)
|
||||
|
||||
await command_handler.send_anon_req(VALID_PUBKEY_HEX, MagicMock(value=1))
|
||||
|
||||
command_handler.change_contact_path.assert_awaited_once()
|
||||
command_handler.reset_path.assert_awaited_once()
|
||||
sent = mock_connection.send.await_args.args[0]
|
||||
assert sent == b"\x39" + bytes.fromhex(VALID_PUBKEY_HEX) + b"\x01" + b"\x00"
|
||||
|
||||
|
||||
async def test_send_anon_req_survives_change_contact_path_not_mutating(
|
||||
command_handler, mock_connection, mock_dispatcher
|
||||
):
|
||||
"""A failed change_contact_path must not crash and must still restore the path.
|
||||
|
||||
update_contact() normally writes out_path_len back onto the dict, but if it
|
||||
errors the value stays -1. Unclamped, the unsigned to_bytes raises
|
||||
OverflowError, which skips reset_path and leaves the contact pinned to
|
||||
zero-hop on the device.
|
||||
"""
|
||||
contact = {"public_key": VALID_PUBKEY_HEX, "out_path_len": -1, "out_path": ""}
|
||||
command_handler._get_contact_by_prefix = MagicMock(return_value=contact)
|
||||
# Returns an error without reflecting the change onto the dict.
|
||||
command_handler.change_contact_path = AsyncMock(
|
||||
return_value=Event(EventType.ERROR, {"reason": "device_query_failed"})
|
||||
)
|
||||
command_handler.reset_path = AsyncMock()
|
||||
setup_event_response(
|
||||
mock_dispatcher, EventType.MSG_SENT,
|
||||
{"expected_ack": b"\x01\x02\x03\x04", "suggested_timeout": 4000},
|
||||
)
|
||||
|
||||
result = await command_handler.send_anon_req(VALID_PUBKEY_HEX, MagicMock(value=1))
|
||||
|
||||
assert not result.is_error()
|
||||
sent = mock_connection.send.await_args.args[0]
|
||||
assert sent == b"\x39" + bytes.fromhex(VALID_PUBKEY_HEX) + b"\x01" + b"\x00"
|
||||
command_handler.reset_path.assert_awaited_once()
|
||||
|
||||
|
||||
# ── send_trace handles unknown path_hash_len without NameError ──
|
||||
|
||||
Reference in New Issue
Block a user