Files
Ben MeadorsandClaude Opus 5 83fd62b756 test(native): add 14 suites for routing, persistence, parsing and identity gaps (#11515)
* test(native): add 14 suites for routing, persistence, parsing and identity gaps

Coverage audit of the native test tree; adds the highest-value untested
logic as 11 new suites and extends 3 existing ones (200 test functions).

New: test_stream_framing, test_nodedb_boot_recovery,
test_nodedb_legacy_migration, test_nodedb_v25_roundtrip,
test_nodedb_identity_hygiene, test_channel_keys, test_reliable_ack_matrix,
test_hop_start_policy, test_routing_response_hops,
test_phone_api_config_dump, test_observer.
Extended: test_rtc, test_mqtt, test_xmodem.

Two source changes the audit produced:

- StreamAPI::handleRecStream copied stream->read()'s `cInt < 0` EOF check
  into the buffer-fed path, where there is no EOF sentinel; with signed
  char any byte >= 0x80 (START1 is 0x94) aborted the parse. Read the byte
  as uint8_t directly. Latent on develop (no callers), pinned by
  test_stream_framing.
- Extract the post-decode pre-hop predicate from Router::handleReceived
  into shouldSkipHandleForPostDecodeHop() (NodeDB.h) so
  test_hop_start_policy drives the exact expression the router calls.
  No behavior change.

test/state-manifest.tsv declares the suites that construct a NodeDB.
Full 68-suite Docker coverage run matches the pre-change baseline.

* test(native): address review - harden observer dispatch, trim comments

Review follow-ups on the coverage-audit suites:

- Observable::notifyObservers() erased list nodes while holding an iterator
  into them, so an observer that unobserves itself from onNotify corrupted the
  dispatch. Today the only self-detacher (PhoneAPI::onNotify ->
  checkConnectionTimeout -> close -> unobserve) survives solely because it
  returns -1 and aborts the chain before the increment; that unwritten contract
  is now gone. Removal during a dispatch nulls the entry and the outermost
  notify sweeps afterwards, which keeps self-detach, next-detach and
  destruction-during-notify all safe without an allocation. Hoisting the next
  iterator instead would have inverted the hazard and broken the existing
  next-detach case. Two regression tests added.

- Correct the documented caller of shouldSkipHandleForPostDecodeHop: the call
  is in Router::dispatchReceived, not handleReceived.

- Cast hop fields to unsigned at the %u call site in test_hop_start_policy.

- Trim the new suites' file headers to the one-or-two-line rule in AGENTS.md.

- Rename eight test functions whose names were exactly `test_` + 35 chars:
  that is the shape of a Lob API key, so trufflehog flagged them as secrets
  and failed the Trunk CI check.

Full 68-suite Docker coverage run matches the pre-change baseline.

* test(native): revert the observer dispatch change, keep the contract test

Backs out the notifyObservers() deferred-removal hardening from the previous
commit. It was reviewer-driven scope creep: nothing in the coverage audit
needed it, no test required it, and it changes dispatch semantics in a header
with ~76 observe() call sites on native verification alone.

The hazard it addressed is not reachable today. The only observer that
unobserves itself from onNotify is PhoneAPI (onNotify ->
checkConnectionTimeout -> close -> unobserve), and it returns -1, which aborts
the chain before the iterator is advanced past the erased node.

test_self_detach_with_abort_during_notify stays: it passes against the
unmodified dispatch and pins that the -1 is load-bearing, so a later cleanup
that "simplifies" it away goes red. The unsafe variant (self-detach returning
0) is documented in a comment rather than tested, since asserting it would be
asserting UB.

* fix(serial): recover the frame behind a stray framing marker

A byte that failed the START2 check was discarded rather than re-tested as
a possible START1, so 0x94 0x94 0xc3 ... lost the real frame: one corrupted
byte on a noisy UART silently dropped the frame behind it. Re-test the byte
in place instead.

Applied to both copies of the receive state machine. readStream() is the one
that matters in the field - it is the serial path every phone client uses -
while handleRecStream() still has no callers on develop.

Strictly widens what the parser accepts; no frame that parsed before parses
differently. test_stream_framing covers it on both receive paths, plus a run
of stray markers and a START1-then-unrelated-byte resync.

This was originally documented as a known gap in the framing suite. Fixing it
instead was NomDeTom's call on review: a passing test asserting the bad
behavior is what makes it hard to change later, and it is the same defect
shape as the signedness fix three functions away.

Also: use Throttle::deadlinePassed() in test_reliable_ack_matrix rather than
a bare millis() compare, matching the house deadline rule.

* test(native): cover the stray-marker resync on the buffer path too

The stray-marker fix went into both copies of the receive state machine, but
only test_stray_start1_before_frame_still_delivers drove both. The repeated-
marker and unrelated-byte cases drove readStream() alone, so a regression in
handleRecStream() would have gone unnoticed by two of the three.

Verified load-bearing: reverting only the handleRecStream() half of the fix
turns test_repeated_stray_start1_before_frame_still_delivers red on the new
assertion. test_start1_then_unrelated_byte_resyncs stays green under that
mutation by design - its failing byte is 0x00, where both branches reset to 0 -
and covers the other half of the ternary.

Also drops the stale header on test_stray_start1_before_frame_still_delivers,
which still described the gap as pinned-as-is after the fix landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(native): make the hop-start truth table assert the rows it prints

test_truth_table_summary was six TEST_MESSAGE lines and no assertion, so it
reported as a case that could not fail - the anti-pattern #11517 names in its
unfinished assertion-presence lint, and the one exception to NomDeTom's "no
RUN_TEST without an assertion" pass over this PR.

The printed row and the checked expectation now come from one struct, so the
summary cannot narrate a table the predicates no longer implement. It also
covers the consequence columns the per-row tests do not assert together:
classifyHopStart, shouldDropPacketForPreHop and shouldSkipHandleForPostDecodeHop
for the same packet, with the expectations gated on MESHTASTIC_PREHOP_DROP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:41:08 +00:00

539 lines
20 KiB
C++

// Tests for the XModem file-transfer adapter (src/xmodem.cpp).
//
// Group 1: XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer
// handler. The filename in a SOH/STX control frame is attacker-controlled and drives FSCom
// open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".." component could
// escape the mountpoint. Absolute/subdirectory paths must still be accepted.
//
// Group 2 onward: the handlePacket() state machine itself - session start, per-packet seq + CRC
// validation, NAK/retransmit, CAN cleanup, EOT close, and the getForPhone()/resetForPhone()
// contract PhoneAPI uses to drain replies. PhoneAPI feeds handlePacket attacker-controllable
// ToRadio protobufs, and none of this had pinning coverage. These tests assert what the code does
// today; the two tests marked "documents current behaviour" pin known state-confusion edges so a
// deliberate fix has to update them consciously.
#include "TestUtil.h"
#include "xmodem.h"
#include <unity.h>
#ifdef FSCom
#include "SPILock.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
void test_xmodem_rejects_dotdot_traversal(void)
{
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename(".."));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("../secret"));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("/prefs/../../etc/passwd"));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("a/../b"));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir/.."));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("/.."));
}
void test_xmodem_rejects_backslash_traversal(void)
{
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("..\\secret"));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("..\\..\\Windows\\System32\\drivers\\etc\\hosts"));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir\\..\\..\\x"));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir/..\\x"));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir\\.."));
}
void test_xmodem_rejects_drive_qualified(void)
{
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("C:\\Windows\\System32\\x"));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("C:/Windows/System32/x"));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("c:relative.txt"));
}
void test_xmodem_rejects_empty(void)
{
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename(""));
TEST_ASSERT_FALSE(XModemAdapter::isValidFilename(nullptr));
}
void test_xmodem_allows_legit_paths(void)
{
// The file manager transfers absolute, subdirectoried paths from the manifest.
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("/prefs/config.proto"));
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("firmware.bin"));
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("dir/sub/file.txt"));
// ".." only inside a name (not a whole component) is a valid filename, not traversal.
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("my..file"));
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("..."));
// A colon that cannot form a drive qualifier is a legal filename on the POSIX daemon.
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("1:30pm.txt"));
TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("dir/1:30pm.txt"));
}
// --- handlePacket state-machine fixture ---
// Exposes the protected CRC helpers so crafted packets carry the exact checksum the adapter
// computes, and so the transmit-side crc16 field can be cross-checked.
class XModemTestShim : public XModemAdapter
{
public:
using XModemAdapter::check;
using XModemAdapter::crc16_ccitt;
};
static XModemTestShim *xm = nullptr;
static constexpr size_t kChunk = sizeof(meshtastic_XModem_buffer_t::bytes); // 128
static const char *kRxPath = "/xmodem_test_rx.bin";
static const char *kTxPath = "/xmodem_test_tx.bin";
// Control-only frame (EOT/ACK/NAK/CAN).
static meshtastic_XModem makeControl(meshtastic_XModem_Control control)
{
meshtastic_XModem p = meshtastic_XModem_init_zero;
p.control = control;
return p;
}
// Session-start frame: seq 0, filename in the buffer (NUL included, as the phone sends it).
static meshtastic_XModem makeStart(meshtastic_XModem_Control control, const char *path)
{
meshtastic_XModem p = meshtastic_XModem_init_zero;
p.control = control;
p.seq = 0;
p.buffer.size = strlen(path) + 1;
memcpy(p.buffer.bytes, path, p.buffer.size);
return p;
}
// Data frame with a correct (or deliberately corrupted) CRC.
static meshtastic_XModem makeData(uint16_t seq, const uint8_t *data, size_t len, bool goodCrc = true)
{
meshtastic_XModem p = meshtastic_XModem_init_zero;
p.control = meshtastic_XModem_Control_SOH;
p.seq = seq;
p.buffer.size = len;
memcpy(p.buffer.bytes, data, len);
p.crc16 = xm->crc16_ccitt(p.buffer.bytes, (int)len);
if (!goodCrc)
p.crc16 ^= 0x1;
return p;
}
static void fillPattern(uint8_t *buf, size_t len, uint8_t seed)
{
for (size_t i = 0; i < len; i++)
buf[i] = (uint8_t)(seed + i * 7);
}
static void writeAll(const char *path, const uint8_t *data, size_t len)
{
File f = FSCom.open(path, FILE_O_WRITE);
TEST_ASSERT_TRUE_MESSAGE(f, path);
TEST_ASSERT_EQUAL_size_t(len, f.write(data, len));
f.close();
}
static size_t readAll(const char *path, uint8_t *buf, size_t maxLen)
{
File f = FSCom.open(path, FILE_O_READ);
TEST_ASSERT_TRUE_MESSAGE(f, path);
size_t n = f.read(buf, maxLen);
f.close();
return n;
}
// Starts a receive session into kRxPath and asserts the adapter accepted it.
static void startReceive(void)
{
xm->handlePacket(makeStart(meshtastic_XModem_Control_SOH, kRxPath));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
TEST_ASSERT_TRUE(xm->isBusy());
}
// Writes a patterned file at kTxPath and starts a transmit session; returns the first outbound
// packet after asserting its shape.
static meshtastic_XModem startTransmit(const uint8_t *payload, size_t len)
{
writeAll(kTxPath, payload, len);
xm->handlePacket(makeStart(meshtastic_XModem_Control_STX, kTxPath));
meshtastic_XModem out = xm->getForPhone();
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, out.control);
TEST_ASSERT_EQUAL_UINT16(1, out.seq);
TEST_ASSERT_TRUE(xm->isBusy());
return out;
}
// --- CRC ---
void test_xmodem_crc16_known_answer(void)
{
// CRC-16/XMODEM check value: crc("123456789") == 0x31C3, and the zero-length CRC is 0.
const uint8_t check[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'};
TEST_ASSERT_EQUAL_HEX16(0x31C3, xm->crc16_ccitt(check, sizeof(check)));
TEST_ASSERT_EQUAL_HEX16(0x0000, xm->crc16_ccitt(check, 0));
TEST_ASSERT_TRUE(xm->check(check, sizeof(check), 0x31C3));
TEST_ASSERT_FALSE(xm->check(check, sizeof(check), 0x31C2));
}
// --- Receive path ---
void test_xmodem_receive_happy_path(void)
{
uint8_t payload[300];
fillPattern(payload, sizeof(payload), 31);
startReceive();
size_t off = 0;
uint16_t seq = 1;
while (off < sizeof(payload)) {
const size_t chunk = std::min(kChunk, sizeof(payload) - off);
xm->handlePacket(makeData(seq, payload + off, chunk));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
off += chunk;
seq++;
}
xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
TEST_ASSERT_FALSE(xm->isBusy());
uint8_t readBack[400];
TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack)));
TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload));
}
void test_xmodem_receive_truncates_a_stale_file(void)
{
// FILE_O_WRITE on Adafruit_LittleFS is append, not truncate; xmodem.cpp removes the target
// before opening. A shorter transfer over a longer stale file must leave no tail bytes.
uint8_t stale[400];
memset(stale, 'Z', sizeof(stale));
writeAll(kRxPath, stale, sizeof(stale));
uint8_t payload[10];
fillPattern(payload, sizeof(payload), 3);
startReceive();
xm->handlePacket(makeData(1, payload, sizeof(payload)));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT));
uint8_t readBack[400];
TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack)));
TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload));
}
void test_xmodem_receive_rejects_wrong_seq(void)
{
uint8_t p1[kChunk], p2[kChunk];
fillPattern(p1, sizeof(p1), 11);
fillPattern(p2, sizeof(p2), 97);
startReceive();
xm->handlePacket(makeData(1, p1, sizeof(p1)));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
// Duplicate of an already-accepted packet: rejected (NAK), not rewritten.
xm->handlePacket(makeData(1, p1, sizeof(p1)));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control);
// Skip ahead: also rejected, and packetno must not have advanced past 2.
xm->handlePacket(makeData(3, p2, sizeof(p2)));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control);
// The expected seq still works after both rejections.
xm->handlePacket(makeData(2, p2, sizeof(p2)));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT));
uint8_t readBack[3 * kChunk];
TEST_ASSERT_EQUAL_size_t(2 * kChunk, readAll(kRxPath, readBack, sizeof(readBack)));
TEST_ASSERT_EQUAL_HEX8_ARRAY(p1, readBack, kChunk);
TEST_ASSERT_EQUAL_HEX8_ARRAY(p2, readBack + kChunk, kChunk);
}
void test_xmodem_receive_rejects_bad_crc(void)
{
uint8_t payload[64];
fillPattern(payload, sizeof(payload), 55);
startReceive();
xm->handlePacket(makeData(1, payload, sizeof(payload), /*goodCrc=*/false));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control);
// The sender retries the same seq with a good CRC; only that copy lands in the file.
xm->handlePacket(makeData(1, payload, sizeof(payload)));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT));
uint8_t readBack[2 * kChunk];
TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack)));
TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload));
}
void test_xmodem_receive_naks_traversal_filename(void)
{
xm->handlePacket(makeStart(meshtastic_XModem_Control_SOH, "../evil"));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control);
TEST_ASSERT_FALSE(xm->isBusy());
// isReceiving stayed false, so a follow-up data packet falls through with no reply at all.
xm->resetForPhone();
uint8_t junk[16];
fillPattern(junk, sizeof(junk), 1);
xm->handlePacket(makeData(1, junk, sizeof(junk)));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control);
TEST_ASSERT_FALSE(xm->isBusy());
}
// NOTE: the receive-side open-failure NAK branch (xmodem.cpp "open(%s, WRITE) failed") is not
// testable on native: Portduino's VFSImpl::open() returns a truthy File whenever the mode permits
// creation, even when the underlying fopen fails, so the branch is unreachable here.
void test_xmodem_can_mid_receive_removes_the_file(void)
{
uint8_t payload[kChunk];
fillPattern(payload, sizeof(payload), 42);
startReceive();
xm->handlePacket(makeData(1, payload, sizeof(payload)));
xm->handlePacket(makeData(2, payload, sizeof(payload)));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
xm->handlePacket(makeControl(meshtastic_XModem_Control_CAN));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
TEST_ASSERT_FALSE(xm->isBusy());
TEST_ASSERT_FALSE(FSCom.exists(kRxPath));
}
void test_xmodem_can_after_eot_removes_completed_file(void)
{
// Documents current behaviour: the CAN handler acts on the stale filename from the previous
// session even when no transfer is in flight, deleting a file that completed successfully.
// A deliberate fix (ignoring CAN while idle) should update this test.
uint8_t payload[8];
fillPattern(payload, sizeof(payload), 5);
startReceive();
xm->handlePacket(makeData(1, payload, sizeof(payload)));
xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT));
TEST_ASSERT_FALSE(xm->isBusy());
TEST_ASSERT_TRUE(FSCom.exists(kRxPath));
xm->handlePacket(makeControl(meshtastic_XModem_Control_CAN));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
TEST_ASSERT_FALSE(FSCom.exists(kRxPath));
}
// --- Transmit path ---
void test_xmodem_transmit_happy_path(void)
{
uint8_t payload[300];
fillPattern(payload, sizeof(payload), 7);
meshtastic_XModem out = startTransmit(payload, sizeof(payload));
TEST_ASSERT_EQUAL_UINT16(kChunk, out.buffer.size);
TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out.buffer.bytes, kChunk);
TEST_ASSERT_EQUAL_HEX16(xm->crc16_ccitt(out.buffer.bytes, out.buffer.size), out.crc16);
// ACK-drive the whole stream and reassemble it; the last (short) packet latches EOT, which
// arrives on the following ACK.
uint8_t reassembled[sizeof(payload) + kChunk];
size_t got = 0;
uint16_t expectSeq = 1;
for (int guard = 0; guard < 10; guard++) {
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, out.control);
TEST_ASSERT_EQUAL_UINT16(expectSeq, out.seq);
TEST_ASSERT_EQUAL_HEX16(xm->crc16_ccitt(out.buffer.bytes, out.buffer.size), out.crc16);
memcpy(reassembled + got, out.buffer.bytes, out.buffer.size);
got += out.buffer.size;
expectSeq++;
xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK));
out = xm->getForPhone();
if (out.control == meshtastic_XModem_Control_EOT)
break;
}
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_EOT, out.control);
TEST_ASSERT_FALSE(xm->isBusy());
TEST_ASSERT_EQUAL_size_t(sizeof(payload), got);
TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, reassembled, sizeof(payload));
}
void test_xmodem_transmit_nak_resends_same_packet(void)
{
uint8_t payload[300];
fillPattern(payload, sizeof(payload), 61);
meshtastic_XModem first = startTransmit(payload, sizeof(payload));
// NAK seeks back and re-reads the same block: identical seq, bytes and CRC.
xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK));
meshtastic_XModem resent = xm->getForPhone();
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, resent.control);
TEST_ASSERT_EQUAL_UINT16(first.seq, resent.seq);
TEST_ASSERT_EQUAL_UINT16(first.buffer.size, resent.buffer.size);
TEST_ASSERT_EQUAL_HEX8_ARRAY(first.buffer.bytes, resent.buffer.bytes, first.buffer.size);
TEST_ASSERT_EQUAL_HEX16(first.crc16, resent.crc16);
// A subsequent ACK still advances to the next block.
xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK));
meshtastic_XModem next = xm->getForPhone();
TEST_ASSERT_EQUAL_UINT16(2, next.seq);
TEST_ASSERT_EQUAL_HEX8_ARRAY(payload + kChunk, next.buffer.bytes, kChunk);
}
void test_xmodem_transmit_retry_cap_cancels(void)
{
uint8_t payload[300];
fillPattern(payload, sizeof(payload), 23);
startTransmit(payload, sizeof(payload));
// retrans starts at MAXRETRANS on a fresh adapter; NAKs 1..MAXRETRANS-1 resend, the
// MAXRETRANS'th decrements it to zero and aborts with CAN.
for (int i = 1; i < MAXRETRANS; i++) {
xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, xm->getForPhone().control);
TEST_ASSERT_EQUAL_UINT16(1, xm->getForPhone().seq);
}
xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control);
TEST_ASSERT_FALSE(xm->isBusy());
}
void test_xmodem_transmit_naks_missing_file(void)
{
xm->handlePacket(makeStart(meshtastic_XModem_Control_STX, "/xmodem_test_missing.bin"));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control);
TEST_ASSERT_FALSE(xm->isBusy());
}
void test_xmodem_soh_mid_transmit_cancels(void)
{
uint8_t payload[300];
fillPattern(payload, sizeof(payload), 89);
startTransmit(payload, sizeof(payload));
// A data frame arriving while we are the sender is protocol confusion: cancel the transfer.
uint8_t junk[16];
fillPattern(junk, sizeof(junk), 2);
xm->handlePacket(makeData(5, junk, sizeof(junk)));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control);
TEST_ASSERT_FALSE(xm->isBusy());
}
void test_xmodem_eot_mid_transmit_leaves_state_busy(void)
{
// Documents current behaviour: the EOT handler only clears isReceiving, so an EOT received
// while transmitting ACKs, closes the file, and leaves the adapter wedged busy. A deliberate
// fix should update this test.
uint8_t payload[300];
fillPattern(payload, sizeof(payload), 13);
startTransmit(payload, sizeof(payload));
xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control);
TEST_ASSERT_TRUE(xm->isBusy());
}
// --- Idle replies and the getForPhone/resetForPhone contract ---
void test_xmodem_ack_nak_while_idle_provoke_can(void)
{
xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control);
// getForPhone() is a read, not a drain: the reply stays until resetForPhone() clears it.
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control);
xm->resetForPhone();
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control);
xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control);
TEST_ASSERT_FALSE(xm->isBusy());
}
void test_xmodem_unknown_control_ignored(void)
{
xm->handlePacket(makeControl(meshtastic_XModem_Control_CTRLZ));
TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control);
TEST_ASSERT_FALSE(xm->isBusy());
}
// --- Unity lifecycle ---
void setUp(void)
{
FSCom.remove(kRxPath);
FSCom.remove(kTxPath);
xm = new XModemTestShim();
}
void tearDown(void)
{
delete xm; // File member closes any handle still held
xm = nullptr;
FSCom.remove(kRxPath);
FSCom.remove(kTxPath);
}
#else // !FSCom
void setUp(void) {}
void tearDown(void) {}
#endif // FSCom
void setup()
{
initializeTestEnvironment();
#ifdef FSCom
// handlePacket brackets every FSCom touch with spiLock; nothing in the test environment
// creates it, so do it here (initSPI asserts it only runs once).
if (!spiLock)
initSPI();
#endif
UNITY_BEGIN();
#ifdef FSCom
printf("\n=== isValidFilename ===\n");
RUN_TEST(test_xmodem_rejects_dotdot_traversal);
RUN_TEST(test_xmodem_rejects_backslash_traversal);
RUN_TEST(test_xmodem_rejects_drive_qualified);
RUN_TEST(test_xmodem_rejects_empty);
RUN_TEST(test_xmodem_allows_legit_paths);
printf("\n=== CRC ===\n");
RUN_TEST(test_xmodem_crc16_known_answer);
printf("\n=== Receive path ===\n");
RUN_TEST(test_xmodem_receive_happy_path);
RUN_TEST(test_xmodem_receive_truncates_a_stale_file);
RUN_TEST(test_xmodem_receive_rejects_wrong_seq);
RUN_TEST(test_xmodem_receive_rejects_bad_crc);
RUN_TEST(test_xmodem_receive_naks_traversal_filename);
RUN_TEST(test_xmodem_can_mid_receive_removes_the_file);
RUN_TEST(test_xmodem_can_after_eot_removes_completed_file);
printf("\n=== Transmit path ===\n");
RUN_TEST(test_xmodem_transmit_happy_path);
RUN_TEST(test_xmodem_transmit_nak_resends_same_packet);
RUN_TEST(test_xmodem_transmit_retry_cap_cancels);
RUN_TEST(test_xmodem_transmit_naks_missing_file);
RUN_TEST(test_xmodem_soh_mid_transmit_cancels);
RUN_TEST(test_xmodem_eot_mid_transmit_leaves_state_busy);
printf("\n=== Idle replies / phone contract ===\n");
RUN_TEST(test_xmodem_ack_nak_while_idle_provoke_can);
RUN_TEST(test_xmodem_unknown_control_ignored);
#endif
exit(UNITY_END());
}
void loop() {}