Files
wadamesh/test/test_sender_ext_field.cpp
Ruben Laban 7eada9b8c3 touch: store the whole sender name, not the first 24 characters
MeshCore names run to 31 characters (ContactInfo::name[32]), but
UIMessage::sender held 24, so every longer name was cut on the way in.
d304d3f made the cut visible instead of letting it push the author into
the message body; this makes it stop happening. MAX_SENDER_NAME goes to
31 — exactly the wire width, so nothing is lost.

Truncation could never fix three of these. "Ack" and "Mention" insert
@[<sender>] into the composer, and textMentionsMe needs the whole name
followed by ']', so a mention of a long-named node never reached them.
Block-by-name compares the stored name against the sender on the RX
path; for ROOM posts those two came from different widths already — the
check sees the untruncated 31-char author while the stored entry came
from the 24-char field — so a room author with a long name has simply
been unblockable. Info and the chat-list preview showed a cut name.

The obvious change — widening UiHistoryMsg::sender — would have been
quietly destructive, so the on-disk width is frozen at 25 instead and
the overflow is APPENDED to UiSegMsg, after seq. sender sits between
thread and text, so widening it in place shifts text for every record
already on disk, and nothing would have caught that: the segment store
is what actually holds messages now, and uiSegOpenValidated checks only
magic/version/rec_size — it never consults k_ui_history_version or
k_ui_history_min_version, which guard the legacy files alone. An old
segment would have passed validation, prefix-read into the new offsets,
lost the first 8 characters of every message body and read seq as 0 (so
loadMsgsFromSegments renumbers the lot), and the next compaction would
have written that back permanently.

Appending at the tail is the evolution path UiSegHeader already
documents, and it is correct in both directions: an old 233-byte record
zero-fills the new field and yields its original short name, and older
firmware reading a new 240-byte record copies the first 233 bytes and
ignores the tail. That second half is why k_ui_seg_version STAYS 1 —
bumping it would make older firmware reject the file outright and
quarantine a history it could have read. So there is no migration, no
version bump, and no wiped chat history. static_asserts now pin the
offsets an already-written record depends on.

TOUCH_IGNORED_NAME_LEN has to move in lockstep (28 -> 32) or blocking
breaks: a slot narrower than a sender stores a cut name the full-width
check can never match, and the block sits in Settings doing nothing.
Its NVS blob has no header — the entry count is its length divided by
the slot width — so re-slotting in place would mangle every entry after
the first and the next write would make that stick. The key is renamed
ign_nm -> ign_nm2 and the old blob folded in once at first read, which
is unambiguous in a way that sniffing the blob length is not (28 and 32
share multiples at 224 and 448, both inside the 16-entry cap). The
invariant the header only stated in prose is a static_assert now.

Split/rejoin is a host-tested header next to ChannelSenderSplit, with
the field-width bounds spelled out: a corrupt record can leave the name
field unterminated, and the message body is what follows it on disk.

Signed-off-by: Ruben Laban <ruben@tun0.nl>
2026-09-01 08:10:57 +02:00

90 lines
4.0 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
#include <assert.h>
#include <string.h>
#include "ui-touch/SenderExtField.h"
// The on-disk shape: base is the frozen v6 field (24 chars + NUL), ext is the appended
// tail, and together they hold MAX_SENDER_NAME == 31 chars.
static const size_t kBaseCap = 25;
static const size_t kExtCap = 7;
static const size_t kOutCap = 32;
int main() {
char base[kBaseCap], ext[kExtCap], out[kOutCap];
// Short name: fits the base field, ext stays empty.
SenderExtField::store("nick", base, sizeof base, ext, sizeof ext);
assert(strcmp(base, "nick") == 0);
for (size_t i = 0; i < kExtCap; ++i) assert(ext[i] == '\0');
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, "nick") == 0);
// Exactly 24: fills the base field with no spill. The tail must stay empty, or the
// "base is full" test below would resurrect garbage.
const char* exactly24 = "123456789012345678901234";
assert(strlen(exactly24) == 24);
SenderExtField::store(exactly24, base, sizeof base, ext, sizeof ext);
assert(strcmp(base, exactly24) == 0);
for (size_t i = 0; i < kExtCap; ++i) assert(ext[i] == '\0');
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, exactly24) == 0);
// A full 31-char name — the case the whole split exists for. Round-trips intact.
const char* full = "Ouderkerk Repeater Node Twelve";
assert(strlen(full) == 30);
SenderExtField::store(full, base, sizeof base, ext, sizeof ext);
assert(strncmp(base, full, 24) == 0 && base[24] == '\0');
assert(memcmp(ext, full + 24, 6) == 0);
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, full) == 0);
// The longest possible name fills ext completely, leaving it unterminated. load() is
// bounded by the field size, so it must not read past.
const char* longest = "1234567890123456789012345678901";
assert(strlen(longest) == 31);
SenderExtField::store(longest, base, sizeof base, ext, sizeof ext);
assert(memcmp(ext, longest + 24, kExtCap) == 0); // no NUL anywhere in ext
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, longest) == 0);
// An OLD record: base written by a pre-widening build, ext zero-filled by the loader's
// prefix read. Must yield exactly the stored short name.
memset(ext, 0, sizeof ext);
memset(base, 0, sizeof base);
strcpy(base, "olde name");
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, "olde name") == 0);
// Garbage in ext behind a NON-full base must be ignored — that is the corrupt-record
// case, and appending it would splice junk onto a valid short name.
memset(ext, 'X', sizeof ext);
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, "olde name") == 0);
// An unterminated base field (corrupt record) must not read into the message body that
// follows it on disk: the copy stops at the field width.
char raw_base[kBaseCap];
memset(raw_base, 'A', sizeof raw_base); // no NUL at all
memset(ext, 0, sizeof ext);
SenderExtField::load(raw_base, sizeof raw_base, ext, sizeof ext, out, sizeof out);
assert(strlen(out) == 24);
// A multi-byte character straddling the boundary: split anywhere, rejoin exact. The
// halves are never used apart, so the byte-level split is deliberately not aligned.
const char* utf8 = "Ouderkerkkkkkkkkkkkkkkk\xE2\x98\x80"; // 23 ASCII + 3-byte U+2600
assert(strlen(utf8) == 26);
SenderExtField::store(utf8, base, sizeof base, ext, sizeof ext);
assert((unsigned char)base[23] == 0xE2); // sequence is cut mid-character on disk
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(strcmp(out, utf8) == 0); // and comes back whole
// Degenerate inputs.
SenderExtField::store(nullptr, base, sizeof base, ext, sizeof ext);
assert(base[0] == '\0');
SenderExtField::load(base, sizeof base, ext, sizeof ext, out, sizeof out);
assert(out[0] == '\0');
return 0;
}