Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/run-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ jobs:

- name: Run Unit Tests
run: pio test -e native -vv
env:
# Fail CI on leaks or memory errors reported by ASAN.
ASAN_OPTIONS: detect_leaks=1:halt_on_error=1
# Stop immediately and print a stack trace for undefined behavior.
UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1

- name: Upload Test Results
# Upload test results even if the test step failed.
Expand Down
7 changes: 7 additions & 0 deletions lib/ed25519/library.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"build": {
"flags": [
"-fno-sanitize=undefined"
]
}
}
13 changes: 13 additions & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,24 @@ lib_deps =
[env:native]
platform = native
build_flags = -std=c++17
; Preserve stack frames and debug info so ASAN/UBSAN reports are actionable.
-g
-fno-omit-frame-pointer
; Run native unit tests with AddressSanitizer and UndefinedBehaviorSanitizer.
-fsanitize=address
-fsanitize=undefined
-I src
-I test/mocks
test_build_src = yes
build_src_filter =
-<*>
+<../src/Dispatcher.cpp>
+<../src/Identity.cpp>
+<../src/Mesh.cpp>
+<../src/Packet.cpp>
+<../src/Utils.cpp>
+<../src/helpers/AdvertDataHelpers.cpp>
+<../src/helpers/BaseChatMesh.cpp>
+<../src/helpers/TxtDataHelpers.cpp>
lib_deps =
google/googletest @ 1.17.0
23 changes: 20 additions & 3 deletions src/helpers/AdvertDataHelpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,36 @@
AdvertDataParser::AdvertDataParser(const uint8_t app_data[], uint8_t app_data_len) {
_name[0] = 0;
_lat = _lon = 0;
_flags = app_data[0];
_flags = 0;
_valid = false;
_extra1 = _extra2 = 0;


if (app_data_len < 1) {
return;
}

_flags = app_data[0];
int i = 1;
if (_flags & ADV_LATLON_MASK) {
if (app_data_len < i + 8) {
return;
}
memcpy(&_lat, &app_data[i], 4); i += 4;
memcpy(&_lon, &app_data[i], 4); i += 4;
if (_lat < -90000000 || _lat > 90000000 || _lon < -180000000 || _lon > 180000000) {
return;
}
}
if (_flags & ADV_FEAT1_MASK) {
if (app_data_len < i + 2) {
return;
}
memcpy(&_extra1, &app_data[i], 2); i += 2;
}
if (_flags & ADV_FEAT2_MASK) {
if (app_data_len < i + 2) {
return;
}
memcpy(&_extra2, &app_data[i], 2); i += 2;
}

Expand Down Expand Up @@ -84,4 +101,4 @@ void AdvertTimeHelper::formatRelativeTimeDiff(char dest[], int32_t seconds_from_
}
}
}
}
}
18 changes: 18 additions & 0 deletions test/mocks/Arduino.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#pragma once

#include <stdlib.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <stdio.h>

// Mock Arduino compatibility header for native testing.
// Provides the small libc-backed helpers needed by Arduino-oriented code.
inline char* ltoa(long value, char* buffer, int base) {
if (base == 10) {
snprintf(buffer, 32, "%ld", value);
} else {
buffer[0] = 0;
}
return buffer;
}
20 changes: 20 additions & 0 deletions test/mocks/Ed25519.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#pragma once

#ifdef __cplusplus
extern "C" {
#endif
#include <ed_25519.h>
#ifdef __cplusplus
}
#endif

#include <stdint.h>

// Mock Ed25519 wrapper for native testing.
// Adapts the test-only C ed25519 implementation to the Arduino-style class API.
class Ed25519 {
public:
static bool verify(const uint8_t* sig, const uint8_t* pub_key, const uint8_t* message, int msg_len) {
return ed25519_verify(sig, message, msg_len, pub_key) != 0;
}
};
42 changes: 36 additions & 6 deletions test/mocks/SHA256.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,43 @@

#include <stdint.h>
#include <stddef.h>
#include <string.h>

// Mock SHA256 class for testing
// Provides minimal interface to allow Utils.cpp to compile
// Mock SHA256 class for native testing.
// Provides a deterministic stand-in so code can hash data without Arduino crypto deps.

@nextgens nextgens May 1, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's evil.

We shouldn't depend on a mock implementation that is not doing what it says on the tin!

How do you propose we do crypto tests (regression tests in particular)? We need a way to test interfaces including those that have arduino deps (like rweather's Crypto lib).

@mtlynch mtlynch May 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still a WIP, and I haven't decided for sure on this implementation yet.

That said, it's pretty normal for mocks to not literally do what they say for the sake of testing. I imagine it's uncontroversial to mock out a clock and have it always claim to be a fixed time like 2000-01-01T00:00:00Z.

Another possibility is to let the client hardcode the inputs and outputs, so the test can say, "if you get the input "hello meshcore", return 17f4227296ecf3043173ab392f5496ad8ebe86a061a4c7ac015ec38831a3a0a2."

Another option is to re-route it to a real SHA256 implementation.

class SHA256 {
uint32_t state_ = 2166136261u;

public:
void update(const uint8_t* data, size_t len) {}
void finalize(uint8_t* hash, size_t hashLen) {}
void resetHMAC(const uint8_t* key, size_t keyLen) {}
void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* hash, size_t hashLen) {}
void update(const void* data, size_t len) {
update(static_cast<const uint8_t*>(data), len);
}

void update(const uint8_t* data, size_t len) {
for (size_t i = 0; i < len; ++i) {
state_ ^= data[i];
state_ *= 16777619u;
state_ += 0x9E3779B9u;
}
}

void finalize(uint8_t* hash, size_t hashLen) {
uint32_t value = state_;
for (size_t i = 0; i < hashLen; ++i) {
value ^= value >> 13;
value *= 1274126177u;
hash[i] = static_cast<uint8_t>((value >> ((i & 3) * 8)) & 0xFF);
}
}

void resetHMAC(const uint8_t* key, size_t keyLen) {
state_ = 2166136261u;
update(key, keyLen);
state_ ^= 0x36363636u;
}

void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* hash, size_t hashLen) {
update(key, keyLen);
finalize(hash, hashLen);
}
};
13 changes: 9 additions & 4 deletions test/mocks/Stream.h
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
#pragma once

// Mock Stream class for native testing
// Provides minimal interface needed by Utils.h
#include <stddef.h>
#include <stdint.h>

// Mock Stream class for native testing.
// Provides the minimal interface needed by code that depends on Arduino Stream.
class Stream {
public:
virtual void print(char c) {}
virtual void print(const char* str) {}
virtual size_t readBytes(uint8_t*, size_t) { return 0; }
virtual size_t write(const uint8_t*, size_t len) { return len; }
virtual void print(char) {}
virtual void print(const char*) {}
virtual void println() {}
};
126 changes: 126 additions & 0 deletions test/mocks/mesh.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#pragma once

#include <cstdint>
#include <optional>

#include "helpers/BaseChatMesh.h"
#include "helpers/SimpleMeshTables.h"
#include "radio.h"
#include "random.h"
#include "time.h"

// No-op packet manager for native tests.
// Satisfies Mesh dependencies while preventing packet allocation or queuing.
class NoopPacketManager final : public mesh::PacketManager {
public:
mesh::Packet* allocNew() override {
return nullptr;
}

void free(mesh::Packet*) override {}

void queueOutbound(mesh::Packet*, uint8_t, uint32_t) override {}

mesh::Packet* getNextOutbound(uint32_t) override {
return nullptr;
}

int getOutboundCount(uint32_t) const override {
return 0;
}

int getOutboundTotal() const override {
return 0;
}

int getFreeCount() const override {
return 0;
}

mesh::Packet* getOutboundByIdx(int) override {
return nullptr;
}

mesh::Packet* removeOutboundByIdx(int) override {
return nullptr;
}

void queueInbound(mesh::Packet*, uint32_t) override {}

mesh::Packet* getNextInbound(uint32_t) override {
return nullptr;
}
};

// Test chat mesh for native tests.
// Exposes packet receive handling and captures discovered contacts for assertions.
class TestChatMesh final : public BaseChatMesh {
public:
TestChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc,
mesh::PacketManager& mgr, mesh::MeshTables& tables)
: BaseChatMesh(radio, ms, rng, rtc, mgr, tables) {}

mesh::DispatcherAction recv(mesh::Packet* pkt) {
return onRecvPacket(pkt);
}

std::optional<ContactInfo> discovered_contact;

protected:
void onDiscoveredContact(ContactInfo& contact, bool, uint8_t, const uint8_t*) override {
discovered_contact = contact;
}

ContactInfo* processAck(const uint8_t*) override {
return nullptr;
}

void onContactPathUpdated(const ContactInfo&) override {}

void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {}

void onCommandDataRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {}

void onSignedMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const uint8_t*, const char*) override {}

uint32_t calcFloodTimeoutMillisFor(uint32_t) const override {
return 0;
}

uint32_t calcDirectTimeoutMillisFor(uint32_t, uint8_t) const override {
return 0;
}

void onSendTimeout() override {}

void onChannelMessageRecv(const mesh::GroupChannel&, mesh::Packet*, uint32_t, const char*) override {}

uint8_t onContactRequest(const ContactInfo&, uint32_t, const uint8_t*, uint8_t, uint8_t*) override {
return 0;
}

void onContactResponse(const ContactInfo&, const uint8_t*, uint8_t) override {}
};

// Test mesh context for native tests.
// Owns mock dependencies in construction order and exposes the mesh via operator->.
struct TestMeshContext {
explicit TestMeshContext(uint32_t current_timestamp)
: rtc(current_timestamp), mesh(radio, ms, rng, rtc, packet_manager, tables) {}

TestChatMesh* operator->() {
return &mesh;
}

const TestChatMesh* operator->() const {
return &mesh;
}

FakeRadio radio;
FakeMillis ms;
FakeRng rng;
FakeRtc rtc;
NoopPacketManager packet_manager;
SimpleMeshTables tables;
TestChatMesh mesh;
};
36 changes: 36 additions & 0 deletions test/mocks/radio.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#pragma once

#include <cstdint>

#include "Dispatcher.h"

// Fake radio for native tests.
// Provides successful no-op send/receive behavior without hardware access.
class FakeRadio final : public mesh::Radio {
public:
int recvRaw(uint8_t*, int) override {
return 0;
}

uint32_t getEstAirtimeFor(int) override {
return 1;
}

float packetScore(float, int) override {
return 1.0f;
}

bool startSendRaw(const uint8_t*, int) override {
return true;
}

bool isSendComplete() override {
return true;
}

void onSendFinished() override {}

bool isInRecvMode() const override {
return true;
}
};
25 changes: 25 additions & 0 deletions test/mocks/random.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#pragma once

#if defined(__has_include_next)
#if __has_include_next(<random.h>)
#include_next <random.h>
#endif
#endif

#ifdef __cplusplus

#include <cstddef>
#include <cstring>

#include "Utils.h"

// Fake random generator for native tests.
// Fills buffers with deterministic bytes so generated packets are repeatable.
class FakeRng final : public mesh::RNG {
public:
void random(uint8_t* dest, size_t sz) override {
memset(dest, 0x5A, sz);
}
};

#endif
Loading