Commit Graph
2 Commits
Author SHA1 Message Date
agessaman 8d1a0eb333 fix(mqtt): reject invalid path encodings before serializing
canSerialize() validated payload_len and the destination size but not whether the
path encoding is one writePath() will actually emit. writePath() self-guards
against overrunning the path array, but it does so by writing nothing and
returning 0 — a correctness problem, not a safety one, because getRawLength()
still counts the path. An over-long or reserved encoding therefore passed the
size check and then serialized to a truncated frame that was published as the
packet.

Worst case is path_len 0xFF with no payload: 63 hops of 4 bytes counts as 254
bytes, inside the 255-byte buffer, while writeTo() emits just the 2-byte header.
The `raw` field would carry 4 hex chars presented as the frame. Reserved 4-byte
hash encodings passed too, producing frames Packet::readFrom() rejects.

Now gated on Packet::isValidPathLen(), which rejects the reserved 4-byte hash
size and any count * size above MAX_PATH_SIZE in one predicate. It is the same
check readFrom() applies to every received packet, and TX packets are built via
setPathHashSizeAndCount() with real hash sizes, so no decodable packet is turned
away.

Two tests added for the cases a destination-size check cannot reach. The existing
truncation test passed for the wrong reason -- its payload_len of 4 pushed
getRawLength() to 258 and tripped the size check, masking the hole -- so it is
split into the >0xFF truncation case and the counted-length-fits case, with the
254/2-byte asymmetry asserted explicitly so it cannot be masked again.

274/274 native tests; both observer envs and an nRF52 repeater build clean.
2026-08-04 14:06:32 -07:00
agessaman e242cc6fd3 fix(mqtt): address review of the demand-driven slot work
[P1] A failed setup no longer strands the slot. setupSlot() returns bool and the
startup loops count only successful activations, so a slot that fails on a client
allocation neither consumes an active-slot position (starving a later healthy
broker on capped hardware) nor sits dead forever: maintainSlotConnections()
previously skipped clientless slots and the reconnect ladder is gated on
initial_connect_done, so nothing retried it. It now retries an enabled but
unactivated slot on a 60 s timer, one per cycle, gated on the same
_slots_setup_done ordering so the NTP-deferred setup sequence is preserved.

[P2] JWT setup no longer proceeds without a usable token. Both the preset and
custom-audience paths returned after ignoring createSlotAuthToken()'s result,
then called connect() and latched initial_connect_done -- so the token-allocation
failure introduced by the previous commit produced an unauthenticated attempt
exactly when memory was exhausted. They now return false and let the retry path
handle it.

[P2] ensureSlotAuthToken() no longer clears an existing token. It cleared
unconditionally, so every renewal wiped the current token before JWTHelper ran;
a renewal that then failed left an empty password where the inline buffer used to
preserve working credentials (JWTHelper writes only on success). Only freshly
allocated buffers are initialised now.

[P2] Raw publications reuse the shared document. buildRawJSON() reached
MQTTPayloadBuilder::buildRawMessage(), which constructed its own default
JsonDocument and therefore malloc'd and freed an internal-heap variant pool per
message -- on the highest-rate topic. The document is threaded through both
builders and the bridge passes _json_scratch_doc.

[P3] The writeTo() guard validates the source fields, not just the destination.
A corrupt payload_len of MAX_PACKET_PAYLOAD + 1 still leaves getRawLength()
inside MAX_TRANS_UNIT, so writeTo() read past packet->payload. Sizing and
validation moved to a pure MQTTWireScratch header with host tests covering the
accept/reject edges, matching the MQTTPacketFilter/MQTTConnectionPolicy pattern.
Two findings fell out: MAX_PATH_SIZE one-byte hops is not encodable (the hop
count is 6 bits, so 64 & 63 == 0; 32 two-byte hops is the widest real path), and
a zero-payload packet serializes but does not survive readFrom() -- pinned as a
test because it constrains any future wire-only queue.

[P3] Corrected the pool-size comment: these targets are 32-bit, so
ARDUINOJSON_SLOT_ID_SIZE is 2 and a pool block is 128 slots / 1024 bytes, not
4096. The 4096 figure came from a pre-existing comment near
NEIGHBORS_DOC_POOL_BUDGET, which is left alone -- its byte measurements are
empirical and still stand, only the block-size attribution is wrong.

Activation is now centralized in activatedSlotCount()/canActivateSlot(), used by
both startup loops, the retry path, and applySlotPreset(). That closes the
pre-existing divergence where a live preset change called setupSlot() without
consulting _max_active_slots, letting a non-PSRAM board reach three concurrent
TLS sessions against a cap of two. BEHAVIOUR CHANGE: a reconfigure that would
exceed the cap now logs and leaves the slot inactive instead of connecting.
Reconfiguring an already-active slot still works, because teardownSlot() releases
its position first.

272/272 native tests pass (5 new); both observer envs and an nRF52 repeater build
clean. Flash 1593249 B non-PSRAM, 1555625 B PSRAM.
2026-08-04 10:13:04 -07:00