mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-13 06:41:45 -04:00
* 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>
539 lines
20 KiB
C++
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() {}
|