diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index c6541065c..2af77d46a 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -801,7 +801,16 @@ bool BaseChatMesh::shareContactZeroHop(const ContactInfo& contact) { auto packet = obtainNewPacket(); if (packet == NULL) return false; // no Packets available - packet->readFrom(temp_buf, plen); // restore Packet from 'blob' + // A cache read may be short or corrupt. Never share a partial packet or a + // different identity that happens to occupy the same cache-key prefix. + if (!packet->readFrom(temp_buf, plen) + || packet->getPayloadType() != PAYLOAD_TYPE_ADVERT + || packet->payload_len < PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + || packet->payload_len > PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE + || memcmp(packet->payload, contact.id.pub_key, PUB_KEY_SIZE) != 0) { + releasePacket(packet); + return false; + } uint16_t codes[2]; codes[0] = codes[1] = 0; // { 0, 0 } means 'send this nowhere' sendZeroHop(packet, codes); @@ -830,6 +839,24 @@ bool BaseChatMesh::importContact(const uint8_t src_buf[], uint8_t len) { return false; // error } +// Keep the identical request/login routing tail shared on flash-limited nodes. +__attribute__((noinline)) +int BaseChatMesh::sendContactPacket(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t& est_timeout) { + if (pkt) { + uint32_t t = getTransmitAirtime(pkt); + if (recipient.out_path_len == OUT_PATH_UNKNOWN) { + if (!sendFloodScoped(recipient, pkt)) return MSG_SEND_FAILED; + est_timeout = calcFloodTimeoutMillisFor(t); + return MSG_SEND_SENT_FLOOD; + } else { + if (!sendDirect(pkt, recipient.getPath(), recipient.out_path_len)) return MSG_SEND_FAILED; + est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); + return MSG_SEND_SENT_DIRECT; + } + } + return MSG_SEND_FAILED; +} + int BaseChatMesh::sendLogin(const ContactInfo& recipient, const char* password, uint32_t& est_timeout) { mesh::Packet* pkt; { @@ -850,19 +877,7 @@ int BaseChatMesh::sendLogin(const ContactInfo& recipient, const char* password, pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, recipient.id, recipient.getSharedSecret(self_id), temp, tlen); } - if (pkt) { - uint32_t t = getTransmitAirtime(pkt); - if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - if (!sendFloodScoped(recipient, pkt)) return MSG_SEND_FAILED; - est_timeout = calcFloodTimeoutMillisFor(t); - return MSG_SEND_SENT_FLOOD; - } else { - if (!sendDirect(pkt, recipient.getPath(), recipient.out_path_len)) return MSG_SEND_FAILED; - est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); - return MSG_SEND_SENT_DIRECT; - } - } - return MSG_SEND_FAILED; + return sendContactPacket(recipient, pkt, est_timeout); } bool BaseChatMesh::allocateRequestTag(uint32_t& tag) { @@ -880,19 +895,7 @@ int BaseChatMesh::sendAnonReq(const ContactInfo& recipient, const uint8_t* data, pkt = createAnonDatagram(PAYLOAD_TYPE_ANON_REQ, self_id, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + len); } - if (pkt) { - uint32_t t = getTransmitAirtime(pkt); - if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - if (!sendFloodScoped(recipient, pkt)) return MSG_SEND_FAILED; - est_timeout = calcFloodTimeoutMillisFor(t); - return MSG_SEND_SENT_FLOOD; - } else { - if (!sendDirect(pkt, recipient.getPath(), recipient.out_path_len)) return MSG_SEND_FAILED; - est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); - return MSG_SEND_SENT_DIRECT; - } - } - return MSG_SEND_FAILED; + return sendContactPacket(recipient, pkt, est_timeout); } int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_data, uint8_t data_len, uint32_t& tag, uint32_t& est_timeout) { @@ -907,19 +910,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t* req_ pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, 4 + data_len); } - if (pkt) { - uint32_t t = getTransmitAirtime(pkt); - if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - if (!sendFloodScoped(recipient, pkt)) return MSG_SEND_FAILED; - est_timeout = calcFloodTimeoutMillisFor(t); - return MSG_SEND_SENT_FLOOD; - } else { - if (!sendDirect(pkt, recipient.getPath(), recipient.out_path_len)) return MSG_SEND_FAILED; - est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); - return MSG_SEND_SENT_DIRECT; - } - } - return MSG_SEND_FAILED; + return sendContactPacket(recipient, pkt, est_timeout); } int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, uint32_t& tag, uint32_t& est_timeout) { @@ -934,19 +925,7 @@ int BaseChatMesh::sendRequest(const ContactInfo& recipient, uint8_t req_type, u pkt = createDatagram(PAYLOAD_TYPE_REQ, recipient.id, recipient.getSharedSecret(self_id), temp, sizeof(temp)); } - if (pkt) { - uint32_t t = getTransmitAirtime(pkt); - if (recipient.out_path_len == OUT_PATH_UNKNOWN) { - if (!sendFloodScoped(recipient, pkt)) return MSG_SEND_FAILED; - est_timeout = calcFloodTimeoutMillisFor(t); - return MSG_SEND_SENT_FLOOD; - } else { - if (!sendDirect(pkt, recipient.getPath(), recipient.out_path_len)) return MSG_SEND_FAILED; - est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); - return MSG_SEND_SENT_DIRECT; - } - } - return MSG_SEND_FAILED; + return sendContactPacket(recipient, pkt, est_timeout); } bool BaseChatMesh::startConnection(const ContactInfo& contact, uint16_t keep_alive_secs) { diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index d7f9850c6..89ae28eff 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -97,6 +97,7 @@ class BaseChatMesh : public mesh::Mesh { ConnectionInfo connections[MAX_CONNECTIONS]; mesh::Packet* composeMsgPacket(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char *text, uint32_t& expected_ack); + int sendContactPacket(const ContactInfo& recipient, mesh::Packet* packet, uint32_t& est_timeout); protected: void sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, uint8_t ack_len=4); diff --git a/test/fixtures/advert_cache/FakeFilesystem.h b/test/fixtures/advert_cache/FakeFilesystem.h index 61171dfd2..2eedc81ad 100644 --- a/test/fixtures/advert_cache/FakeFilesystem.h +++ b/test/fixtures/advert_cache/FakeFilesystem.h @@ -43,6 +43,7 @@ public: unsigned fail_rename = 0, fail_remove = 0, fail_config = 0; bool fail_metadata = false, fail_open = false, partial_open = false; bool fail_read = false, short_write = false, fail_close = false, fail_format = false; + size_t read_limit = SIZE_MAX; size_t max_backend_write = 0; bool exists(const char* path) const { return files.count(path) != 0; } bool mkdir(const char*) { ++operations; return true; } @@ -113,7 +114,8 @@ inline size_t File::read(uint8_t* data, size_t length) { assert(stream && !stream->writer); ++stream->fs->operations; if (stream->fs->fail_read) return 0; const auto& bytes = stream->fs->files.at(stream->path); - size_t n = std::min(length, bytes.size()-std::min(stream->position, bytes.size())); + size_t n = std::min(std::min(length, stream->fs->read_limit), + bytes.size()-std::min(stream->position, bytes.size())); if (n) memcpy(data, bytes.data()+stream->position, n); stream->position += n; return n; } diff --git a/test/fixtures/advert_cache/share_contact.cpp b/test/fixtures/advert_cache/share_contact.cpp new file mode 100644 index 000000000..a8a3e81fe --- /dev/null +++ b/test/fixtures/advert_cache/share_contact.cpp @@ -0,0 +1,170 @@ +// Execute production cache reads, packet restoration, pool reuse and sharing. +// Only storage peripherals and the final radio queue boundary are isolated. +#include "FakeFilesystem.h" +#include +#include +#include +#include + +#define FILESYSTEM FakeFilesystem +using mesh::FLOOD_RETRY_POLICY_DEFAULT; +using mesh::RADIO_TX_AUTO; + +class DataStore { +public: + FakeFilesystem* _fs; +#include "advert_state.h" + explicit DataStore(FakeFilesystem& fs) : _fs(&fs) {} + File openRead(FakeFilesystem* fs, const char* path) { return fs->open(path); } + uint8_t getBlobByKey(const uint8_t[], int, uint8_t[]); + bool putBlobByKey(const uint8_t[], int, const uint8_t[], uint8_t); + bool queueAdvertByKey(const uint8_t[], int, const uint8_t[], uint8_t); +}; +#include "share_store.h" + +struct ContactInfo { + struct { uint8_t pub_key[PUB_KEY_SIZE] = {}; } id; +}; + +class BaseChatMesh { +public: + DataStore& store; + StaticPoolPacketManager* _mgr; + unsigned _err_flags = 0; + bool receive_context_active = false; + uint8_t receive_context_profile = 0; + uint32_t receive_context_generation = 0; + uint8_t temp_buf[MAX_TRANS_UNIT] = {}; + unsigned released = 0, sent = 0; + mesh::Packet last; + struct Tables { + unsigned marked = 0; + void markSent(const mesh::Packet*) { ++marked; } + } table, *_tables = &table; + BaseChatMesh(DataStore& s, StaticPoolPacketManager& p) : store(s), _mgr(&p) {} + int getBlobByKey(const uint8_t* key, int length, uint8_t* bytes) { + return store.getBlobByKey(key, length, bytes); + } + mesh::Packet* obtainNewPacket(); + void releasePacket(mesh::Packet* packet) { ++released; _mgr->free(packet); } + bool shareContactZeroHop(const ContactInfo&); + void sendZeroHop(mesh::Packet*, uint16_t*, uint32_t = 0); + bool sendPacket(mesh::Packet* packet, uint8_t, uint32_t) { + // The actual Dispatcher shape guards still apply at this isolated final + // boundary; no radio, crypto verification or RF delivery is simulated. + const bool accepted = !packet->violatesRoutePolicy() + && mesh::Packet::isValidPathLen(packet->path_len) + && packet->payload_len <= MAX_PACKET_PAYLOAD; + if (accepted) { ++sent; last = *packet; } + releasePacket(packet); + return accepted; + } +}; +#include "share_methods.h" + +static void require(bool condition, const char* message) { + if (condition) return; + fprintf(stderr, "CHECK: %s\n", message); + abort(); +} + +int main(int argc, char** argv) { + require(argc == 2, "one share scenario is required"); + const std::string scenario = argv[1]; + FakeFilesystem fs; + DataStore store(fs); + StaticPoolPacketManager pool(1); + BaseChatMesh chat(store, pool); + ContactInfo contact; + contact.id.pub_key[0] = 0x42; + mesh::Packet advert; + advert.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); + advert.setPathHashSizeAndCount(1, 2); + advert.path[0] = 0x12; + advert.path[1] = 0x34; + if (scenario == "short3") advert.path_len = 0; + const size_t fixed = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE; + const char name[] = "Probe node"; + advert.payload_len = fixed + sizeof(name); + memset(advert.payload, 0xA5, advert.payload_len); + memcpy(advert.payload, contact.id.pub_key, PUB_KEY_SIZE); + advert.payload[fixed] = 0x81; // ADV_TYPE_CHAT | ADV_NAME_MASK. + memcpy(advert.payload + fixed + 1, name, sizeof(name) - 1); + std::vector expected_payload(advert.payload, advert.payload + advert.payload_len); + uint8_t bytes[MAX_TRANS_UNIT]; + const auto length = advert.writeTo(bytes); + require(store.putBlobByKey(contact.id.pub_key, PUB_KEY_SIZE, bytes, length), + "valid cache preparation failed"); + char path[64]; + makeBlobPath(contact.id.pub_key, PUB_KEY_SIZE, path, sizeof(path)); + bool expected = scenario == "valid" || scenario == "valid-reused" + || scenario == "valid-min" || scenario == "valid-max" || scenario == "ram-valid"; + bool allocation_failed = false; + mesh::Packet* held = nullptr; + + if (scenario == "short1") fs.read_limit = 1; + else if (scenario == "short3") fs.read_limit = 3; + else if (scenario == "truncated-path") fs.files.at(path).resize(3); + else if (scenario == "reserved-path") fs.files.at(path)[1] = 0xC0; + else if (scenario == "bad-version") fs.files.at(path)[0] |= 0x40; + else if (scenario == "truncated-transport") { + fs.files.at(path)[0] = ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); + fs.files.at(path).resize(3); + } else if (scenario == "wrong-type") { + fs.files.at(path)[0] = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); + } else if (scenario == "short-advert") fs.files.at(path).resize(4 + fixed - 1); + else if (scenario == "oversize-advert") fs.files.at(path).resize(4 + fixed + MAX_ADVERT_DATA_SIZE + 1); + else if (scenario == "wrong-key") fs.files.at(path)[4 + PUB_KEY_SIZE - 1] ^= 0x80; + else if (scenario == "empty-cache") fs.files.at(path).clear(); + else if (scenario == "missing-cache") fs.files.erase(path); + else if (scenario == "no-allocation") { held = pool.allocNew(); allocation_failed = true; } + else if (scenario == "valid-min") { + fs.files.at(path).resize(4 + fixed); + expected_payload.resize(fixed); + } else if (scenario == "valid-max") { + fs.files.at(path).resize(4 + fixed + MAX_ADVERT_DATA_SIZE); + expected_payload.resize(fixed + MAX_ADVERT_DATA_SIZE); + } + else if (scenario == "ram-valid" || scenario == "ram-wrong-key") { + ContactInfo cached = contact; + if (scenario == "ram-wrong-key") bytes[4 + PUB_KEY_SIZE - 1] ^= 0x80; + require(store.queueAdvertByKey(cached.id.pub_key, PUB_KEY_SIZE, bytes, length), + "RAM advert preparation failed"); + fs.files.erase(path); + } else require(scenario == "valid" || scenario == "valid-reused", "unknown share scenario"); + + if (scenario == "valid-reused" || scenario == "short1") { + // Actual pool reuse retains the prior header. obtainNewPacket resets + // lengths, so ignoring readFrom's early failure can send an empty ACK. + auto* previous = pool.allocNew(); + previous->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_ACK << PH_TYPE_SHIFT); + pool.free(previous); + } + + const bool result = chat.shareContactZeroHop(contact); + if (expected) { + require(result && chat.sent == 1, "valid cached advert was not shared"); + require(chat.last.getPayloadType() == PAYLOAD_TYPE_ADVERT, + "shared packet type changed"); + require(chat.last.getRouteType() == ROUTE_TYPE_TRANSPORT_DIRECT && chat.last.path_len == 0, + "valid share must remain zero-hop transport-direct"); + require(chat.last.transport_codes[0] == 0 && chat.last.transport_codes[1] == 0, + "valid share must retain zero transport codes"); + require(!memcmp(chat.last.payload, contact.id.pub_key, PUB_KEY_SIZE), + "valid share identity changed"); + require(chat.last.payload_len == expected_payload.size() + && !memcmp(chat.last.payload, expected_payload.data(), expected_payload.size()), + "valid share must preserve every cached payload byte"); + require(chat.table.marked == 1 && chat.released == 1, + "valid share must mark sent once and retire once"); + } else { + require(!result && chat.sent == 0, "invalid cached advert must fail without transmission"); + require(chat.table.marked == 0, "rejected advert must not be marked sent"); + const unsigned releases = scenario == "empty-cache" || scenario == "missing-cache" + || allocation_failed ? 0 : 1; + require(chat.released == releases, "failed share must release exactly its owned packet"); + } + if (held) pool.free(held); + require(pool.getFreeCount() == 1, "share leaked or double-freed its packet"); + printf("PASS: cached advert share %s\n", scenario.c_str()); +} diff --git a/test/fixtures/advert_cache/share_mocks/Identity.h b/test/fixtures/advert_cache/share_mocks/Identity.h new file mode 100644 index 000000000..68353cd92 --- /dev/null +++ b/test/fixtures/advert_cache/share_mocks/Identity.h @@ -0,0 +1,5 @@ +#pragma once + +// Dispatcher includes identity declarations, but the packet-pool/share +// methods exercised here do not invoke identity or crypto operations. +namespace mesh { class Identity {}; class LocalIdentity {}; } diff --git a/test/fixtures/advert_cache/share_mocks/Utils.h b/test/fixtures/advert_cache/share_mocks/Utils.h new file mode 100644 index 000000000..bc9ed3b2a --- /dev/null +++ b/test/fixtures/advert_cache/share_mocks/Utils.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace mesh { +class Utils { +public: + static void toHex(char* dest, const uint8_t* bytes, size_t length) { + static const char hex[] = "0123456789ABCDEF"; + for (size_t i = 0; i < length; ++i) { + dest[2 * i] = hex[bytes[i] >> 4]; + dest[2 * i + 1] = hex[bytes[i] & 15]; + } + dest[2 * length] = 0; + } +}; +} diff --git a/test/fixtures/companion_delayed_reply_delivery/test.cpp b/test/fixtures/companion_delayed_reply_delivery/test.cpp index b453732d6..039a0c0ea 100644 --- a/test/fixtures/companion_delayed_reply_delivery/test.cpp +++ b/test/fixtures/companion_delayed_reply_delivery/test.cpp @@ -108,6 +108,7 @@ struct BaseChatMesh { uint32_t calcFloodTimeoutMillisFor(uint32_t) const { return 5000; } uint32_t calcDirectTimeoutMillisFor(uint32_t, uint8_t) const { return direct_timeout; } void startConnection(const ContactInfo&, uint16_t) { ++connection_starts; } + int sendContactPacket(const ContactInfo&, mesh::Packet*, uint32_t&); int sendLogin(const ContactInfo&, const char*, uint32_t&); int sendAnonReq(const ContactInfo&, const uint8_t*, uint8_t, uint32_t&, uint32_t&); int sendRequest(const ContactInfo&, const uint8_t*, uint8_t, uint32_t&, uint32_t&); diff --git a/test/fixtures/contact_cache/test_contact_cache.cpp b/test/fixtures/contact_cache/test_contact_cache.cpp index f35308592..5d72f9461 100644 --- a/test/fixtures/contact_cache/test_contact_cache.cpp +++ b/test/fixtures/contact_cache/test_contact_cache.cpp @@ -153,9 +153,15 @@ protected: const char* getLogDateTime() { return "test"; } public: Packet packet; - unsigned released = 0, queued = 0; + unsigned released = 0, queued = 0, queue_attempts = 0; + bool queue_accepts = true; void releasePacket(Packet*) { ++released; } - bool sendPacket(Packet*, uint8_t, uint32_t) { ++queued; return true; } + bool sendPacket(Packet* packet, uint8_t, uint32_t) { + ++queue_attempts; + if (!queue_accepts) { releasePacket(packet); return false; } + ++queued; + return true; + } bool sendDirect(Packet*, const uint8_t*, uint8_t, uint32_t = 0); }; } @@ -166,6 +172,7 @@ public: #define ADV_TYPE_ROOM 3 class BaseChatMesh : public mesh::Mesh { uint32_t getTransmitAirtime(const mesh::Packet* packet) const { + ++airtime_queries; return _radio->getEstAirtimeFor(packet->getRawLength()); } struct Radio { uint32_t getEstAirtimeFor(int) { return 20; } } radio; @@ -176,23 +183,38 @@ class BaseChatMesh : public mesh::Mesh { Clock* getRTCClock() { return &clock; } Rng* getRNG() { return &rng; } mesh::Packet* createDatagram(uint8_t type, const mesh::Identity&, - const uint8_t*, const uint8_t*, size_t length) { + const uint8_t* secret, const uint8_t* data, size_t length) { + ++allocations; + payload.assign(data, data + length); + memcpy(shared_secret.data(), secret, shared_secret.size()); + if (!allocation_succeeds) return nullptr; packet = mesh::Packet(); packet.header = type << PH_TYPE_SHIFT; packet.payload_len = length; + assert(length <= sizeof(packet.payload)); + memcpy(packet.payload, data, length); return &packet; } mesh::Packet* createAnonDatagram(uint8_t type, const mesh::LocalIdentity&, const mesh::Identity& peer, const uint8_t* secret, const uint8_t* data, size_t length) { return createDatagram(type, peer, secret, data, length); } - bool sendFloodScoped(const ContactInfo&, mesh::Packet* p) { return sendPacket(p, 0, 0); } + bool sendFloodScoped(const ContactInfo&, mesh::Packet* p) { + ++flood_calls; + return sendPacket(p, 0, 0); + } uint32_t calcFloodTimeoutMillisFor(uint32_t t) { return t * 2; } uint32_t calcDirectTimeoutMillisFor(uint32_t t, uint8_t n) { return t * (n + 1); } + int sendContactPacket(const ContactInfo&, mesh::Packet*, uint32_t&); public: + unsigned allocations = 0, tag_checks = 0, flood_calls = 0; + mutable unsigned airtime_queries = 0; + bool allocation_succeeds = true, tag_allowed = true; + std::vector payload; + std::array shared_secret = {}; int sendLogin(const ContactInfo&, const char*, uint32_t&); int sendAnonReq(const ContactInfo&, const uint8_t*, uint8_t, uint32_t&, uint32_t&); - virtual bool allowRequestTag(uint32_t) { return true; } + virtual bool allowRequestTag(uint32_t) { ++tag_checks; return tag_allowed; } virtual bool allocateRequestTag(uint32_t&); int sendRequest(const ContactInfo&, const uint8_t*, uint8_t, uint32_t&, uint32_t&); int sendRequest(const ContactInfo&, uint8_t, uint32_t&, uint32_t&); @@ -392,6 +414,108 @@ static void failed_path_reads_fail_sends_without_leaking_packets() { } } +static void shared_request_tail_keeps_payload_routes_and_timeouts() { + Fixture f(1); + mesh::contactSecretCache().attach(&f.store); + BaseChatMesh mesh; + ContactInfo recipient = f.host.contacts[0]; + recipient.sync_since = 0x12345678; + std::vector request(MAX_PACKET_PAYLOAD, 0x6D); + std::array first_secret = {}; + bool have_secret = false; + unsigned traces = 0; + for (unsigned kind = 0; kind != 4; ++kind) { + for (uint8_t path_len : {uint8_t(OUT_PATH_UNKNOWN), uint8_t(0), uint8_t(0x42)}) { + recipient.out_path_len = path_len; + for (unsigned outcome = 0; outcome != 4; ++outcome) { + if (kind == 0 && outcome == 3) continue; // Login does not allocate request tags. + const unsigned variants = kind == 0 ? 6 : kind == 3 ? 1 : 2; + for (unsigned variant = 0; variant != variants; ++variant) { + mesh.allocation_succeeds = outcome != 2; + mesh.queue_accepts = outcome != 1; + mesh.tag_allowed = outcome != 3; + const unsigned allocations = mesh.allocations, attempted = mesh.queue_attempts; + const unsigned queued = mesh.queued, released = mesh.released; + const unsigned airtime_queries = mesh.airtime_queries, floods = mesh.flood_calls; + const unsigned tag_checks = mesh.tag_checks; + uint32_t timeout = 0xDEADBEEF, tag = 0xCAFEBABE; + int result; + std::vector expected(4); + const uint32_t timestamp = 100; + memcpy(expected.data(), ×tamp, sizeof(timestamp)); + if (kind == 0) { + recipient.type = variant < 3 ? ADV_TYPE_ROOM : 1; + const size_t lengths[] = {0, 15, 16}; + const std::string password(lengths[variant % 3], 'p'); + if (recipient.type == ADV_TYPE_ROOM) { + const auto* since = reinterpret_cast(&recipient.sync_since); + expected.insert(expected.end(), since, since + 4); + } + expected.insert(expected.end(), std::min(password.size(), size_t(15)), 'p'); + result = mesh.sendLogin(recipient, password.c_str(), timeout); + } else if (kind == 1 || kind == 2) { + // Leave the same timestamp/encryption envelope required by the + // production datagram builders, not just the caller's scratch. + const unsigned overhead = kind == 1 ? 1 + PUB_KEY_SIZE : CIPHER_MAC_SIZE; + const uint8_t length = variant == 0 ? 0 + : MAX_PACKET_PAYLOAD - 4 - overhead - (CIPHER_BLOCK_SIZE - 1); + expected.insert(expected.end(), request.begin(), request.begin() + length); + result = kind == 1 + ? mesh.sendAnonReq(recipient, request.data(), length, tag, timeout) + : mesh.sendRequest(recipient, request.data(), length, tag, timeout); + } else { + expected.push_back(0x71); + expected.insert(expected.end(), 4, 0); + expected.insert(expected.end(), 4, 42); + result = mesh.sendRequest(recipient, 0x71, tag, timeout); + } + ++traces; + assert(mesh.tag_checks == tag_checks + unsigned(kind != 0)); + if (outcome == 3) { + assert(result == MSG_SEND_FAILED && tag == timestamp); + assert(mesh.allocations == allocations && mesh.queue_attempts == attempted); + assert(mesh.airtime_queries == airtime_queries && mesh.released == released); + assert(timeout == 0xDEADBEEF); + continue; + } + assert(mesh.allocations == allocations + 1 && mesh.payload == expected); + if (mesh.allocation_succeeds) + assert(mesh.packet.getPayloadType() == (kind < 2 ? PAYLOAD_TYPE_ANON_REQ : PAYLOAD_TYPE_REQ)); + if (kind != 0) assert(tag == timestamp); + if (!have_secret) { first_secret = mesh.shared_secret; have_secret = true; } + assert(mesh.shared_secret == first_secret); + if (outcome == 2) { + assert(result == MSG_SEND_FAILED && timeout == 0xDEADBEEF); + assert(mesh.queue_attempts == attempted && mesh.released == released); + assert(mesh.airtime_queries == airtime_queries && mesh.flood_calls == floods); + continue; + } + assert(mesh.airtime_queries == airtime_queries + 1); + assert(mesh.queue_attempts == attempted + 1); + assert(mesh.flood_calls == floods + unsigned(path_len == OUT_PATH_UNKNOWN)); + if (outcome == 1) { + assert(result == MSG_SEND_FAILED && timeout == 0xDEADBEEF); + assert(mesh.queued == queued && mesh.released == released + 1); + } else { + const bool flood = path_len == OUT_PATH_UNKNOWN; + assert(result == (flood ? MSG_SEND_SENT_FLOOD : MSG_SEND_SENT_DIRECT)); + assert(timeout == (flood ? 40u : 20u * (path_len + 1u))); + assert(mesh.queued == queued + 1 && mesh.released == released); + if (!flood) { + assert(mesh.packet.getRouteType() == ROUTE_TYPE_DIRECT); + const unsigned path_bytes = (path_len & 63) * ((path_len >> 6) + 1); + assert(mesh.packet.path_len == path_len); + assert(!memcmp(mesh.packet.path, route(0).data(), path_bytes)); + } + } + } + } + } + } + assert(traces == 114); + printf("PASS: %u shared request/login payload and routing traces\n", traces); +} + static void contact_reload_preserves_disk_on_cache_exhaustion() { Fixture f; #if defined(NRF52_PLATFORM) @@ -841,6 +965,7 @@ static void deferred_cleanup_publishes_actual_cached_handles() { int main() { contact_tx_policy_round_trips_without_growing_records(); + shared_request_tail_keeps_payload_routes_and_timeouts(); #if defined(ESP32_PLATFORM) deferred_cleanup_publishes_actual_cached_handles(); cooperative_save_services_commands_and_preserves_snapshots(); diff --git a/test/test_companion_delayed_reply_delivery.py b/test/test_companion_delayed_reply_delivery.py index fa9d511df..9de14815d 100644 --- a/test/test_companion_delayed_reply_delivery.py +++ b/test/test_companion_delayed_reply_delivery.py @@ -47,7 +47,8 @@ def production_methods(source, base, packet, dispatcher): )) methods += "\n#if COMPANION_FEATURE_TEXT_TERMINAL\n" + terminal + "\n#endif\n" methods += "\n" + "\n".join(extract_braced(base, signature) for signature in ( - "bool BaseChatMesh::onContactPathRecv(", "int BaseChatMesh::sendLogin(", + "bool BaseChatMesh::onContactPathRecv(", "int BaseChatMesh::sendContactPacket(", + "int BaseChatMesh::sendLogin(", "bool BaseChatMesh::allocateRequestTag(", "int BaseChatMesh::sendAnonReq(", "int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t*", diff --git a/test/test_contact_cache.py b/test/test_contact_cache.py index 43056388e..b75e934af 100644 --- a/test/test_contact_cache.py +++ b/test/test_contact_cache.py @@ -107,6 +107,7 @@ class ContactCacheTest(unittest.TestCase): (temp / "send_under_test.h").write_text( "namespace mesh {\n" + method(mesh, "bool Mesh::sendDirect(") + "\n}\n" + "\n".join(method(chat, signature) for signature in ( + "int BaseChatMesh::sendContactPacket(", "int BaseChatMesh::sendLogin(", "int BaseChatMesh::sendAnonReq(", "bool BaseChatMesh::allocateRequestTag(", "int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t*", @@ -133,6 +134,7 @@ class ContactCacheTest(unittest.TestCase): self.assertIn("crc_read_passes == 104", result.stderr) else: self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('PASS: 114 shared request/login payload and routing traces', result.stdout) if __name__ == "__main__": diff --git a/test/test_esp32_advert_cache.py b/test/test_esp32_advert_cache.py index 307b68d14..0c703da2a 100644 --- a/test/test_esp32_advert_cache.py +++ b/test/test_esp32_advert_cache.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 """Exercise production ESP advert jobs and their MyMesh route under sanitizers.""" from pathlib import Path +import os +import shutil import subprocess import tempfile import unittest from test_t096_full_memory import method +from test_serial_wifi_sessions import SANITIZER_FLAGS ROOT = Path(__file__).resolve().parents[1] @@ -19,6 +22,82 @@ class AdvertCacheTest(unittest.TestCase): def test_synchronous_received_advert_is_detected(self): self.run_fixture(0, synchronous=True) + def test_contact_sharing_validates_restored_cached_adverts(self): + self.run_share_fixture() + + def test_unchecked_cached_advert_share_negative_control(self): + self.run_share_fixture(legacy=True) + + def run_share_fixture(self, legacy=False): + compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++') + if compiler is None: + self.skipTest('a host C++17 compiler is required') + store = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text() + header = (ROOT / 'examples/companion_radio/DataStore.h').read_text() + start = header.index(' struct AdvertWriteState {') + state = header[start:header.index('\n#endif', start)] + blob = store[store.rindex('inline void makeBlobPath('):] + presence = method(store, 'static bool companionPathPresence(').replace( + '::stat(vfs_path, &info)', 'fixtureStat(fs, vfs_path, &info)') + implementation = '\n'.join((presence, method(store, 'static File openWrite('))) + implementation += '\n' + '\n'.join(method(blob, signature) for signature in ( + 'inline void makeBlobPath(', 'uint8_t DataStore::getBlobByKey(', + 'bool DataStore::putBlobByKey(')) + implementation += '\n' + '\n'.join(method(store, signature) for signature in ( + 'void DataStore::invalidateAdvertWrite(', 'void DataStore::retireAdvertWrite(', + 'bool DataStore::queueAdvertByKey(')) + share = method((ROOT / 'src/helpers/BaseChatMesh.cpp').read_text(), + 'bool BaseChatMesh::shareContactZeroHop(') + if legacy: + start = share.index(' // A cache read may be short or corrupt.') + stop = share.index(' uint16_t codes[2];', start) + share = share[:start] + " packet->readFrom(temp_buf, plen);\n" + share[stop:] + obtain = method((ROOT / 'src/Dispatcher.cpp').read_text(), + 'Packet* Dispatcher::obtainNewPacket()').replace( + 'Packet* Dispatcher::', 'mesh::Packet* BaseChatMesh::', 1) + zero_hop = method((ROOT / 'src/Mesh.cpp').read_text(), + 'void Mesh::sendZeroHop(Packet* packet, uint16_t*').replace( + 'void Mesh::sendZeroHop(Packet*', + 'void BaseChatMesh::sendZeroHop(mesh::Packet*', 1) + scenarios = ('valid', 'valid-reused', 'valid-min', 'valid-max', 'ram-valid', + 'short1', 'short3', 'truncated-path', 'reserved-path', 'bad-version', + 'truncated-transport', 'wrong-type', 'short-advert', 'oversize-advert', + 'wrong-key', 'ram-wrong-key', 'empty-cache', 'missing-cache', 'no-allocation') + controls = {'valid', 'valid-reused', 'valid-min', 'valid-max', 'ram-valid', + 'empty-cache', 'missing-cache', 'no-allocation'} + fixture = ROOT / 'test/fixtures/advert_cache' + with tempfile.TemporaryDirectory(prefix='mesh-advert-share-') as directory: + temp = Path(directory) + (temp / 'advert_state.h').write_text(state, encoding='ascii') + (temp / 'share_store.h').write_text(implementation, encoding='ascii') + (temp / 'share_methods.h').write_text('\n'.join((obtain, zero_hop, share)), + encoding='ascii') + binary = temp / 'share.exe' + built = subprocess.run([ + compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', + '-Wno-unused-parameter', '-Wno-reorder', *SANITIZER_FLAGS, + '-DESP32_PLATFORM=1', '-I' + str(fixture / 'share_mocks'), + '-I' + str(ROOT / 'test/mocks'), '-I' + str(fixture), + '-I' + str(temp), '-I' + str(ROOT / 'src'), + str(fixture / 'share_contact.cpp'), str(ROOT / 'src/Packet.cpp'), + str(ROOT / 'src/helpers/StaticPoolPacketManager.cpp'), '-o', str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + # The actual pool owns firmware-lifetime allocations and has no + # destructor. Keep ASan/UBSan active without treating those as leaks. + environment = dict(os.environ, ASAN_OPTIONS='detect_leaks=0') + for scenario in scenarios: + with self.subTest(scenario=scenario, legacy=legacy): + result = subprocess.run([str(binary), scenario], capture_output=True, + text=True, timeout=10, env=environment) + if legacy and scenario not in controls: + self.assertNotEqual(result.returncode, 0) + self.assertIn('invalid cached advert must fail without transmission', + result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('PASS: cached advert share ' + scenario, result.stdout) + def run_fixture(self, cache, synchronous=False): source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text() header = (ROOT / 'examples/companion_radio/DataStore.h').read_text()