Files
pyxis/tests/native/test_map_tile_store_path_capacity.cpp
T
Torlando 0f6309c5a6 fix: widen MapTileStore path buffer for 31-char pack IDs
Raise MapTileStore::PATH_CAPACITY from 64 to 80 so the mount buffer
(PATH_CAPACITY + 4) holds the 80-character mounted tile path produced by
a maximum-length (31-char) pack ID with full-width tile coordinates.
Pre-fix, any tile with a two-digit x or y returned INVALID_ARGUMENT from
the mount-prefix snprintf, which MapTilePack maps to IO_ERROR and the UI
shows as 'Tile I/O error' even though the file exists and is intact.

The host regression test added in the prior commit now passes under both
strict C++11 and ASan/UBSan; reverting this change fails it.
2026-08-28 22:44:27 +00:00

466 lines
19 KiB
C++

// Regression test: MapTileStoreSD mount-path capacity with a maximum-length
// pack ID.
//
// The SD store maps every firmware path onto the mounted card by prepending
// the "/sd" prefix (makeMountedPath in MapTileStoreSD.cpp). With the real
// 31-character world pack ID ("world-osm-bright-z0-z9-20260801"), any tile
// whose x or y coordinate is two digits makes the mounted path longer than
// the PATH_CAPACITY + 4 mount buffer, so beginRead rejects it before the file
// lookup with INVALID_ARGUMENT -- which MapTilePack::beginGet surfaces on the
// device as "Tile I/O error". Observed on the physical T-Deck at zoom 5
// (single-digit quadrants rendered, two-digit quadrants failed) and on x86
// against the real firmware build.
//
// The core regression section drives the UNMODIFIED production MapTileStoreSD
// (compiled against the sdhostshim headers): a path whose mounted form
// overflowed the old 68-byte buffer must be accepted once the buffer is
// sized correctly. The result is file-independent: overflow is rejected
// before stat(), and the control path (which fits) reaches the file lookup
// and reports MISS when no card content is present. The optional
// end-to-end section additionally initializes a real MapTilePack and reads
// tiles byte-for-byte, but only when "/sd" is a live card mount (e.g. the
// bind mount set up for the device harness).
//
// NOTE: the test is written to the FIXED behavior. On the pre-fix tree it
// fails in the core section (the two-digit z4/z5 reads return
// INVALID_ARGUMENT instead of OK) -- that failure is the TDD red step.
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <string>
#include <vector>
#include "Hardware/TDeck/MapTilePack.h"
#include "Hardware/TDeck/MapTileStore.h"
#include "Hardware/TDeck/MapTileStoreSD.h"
#include "Hardware/TDeck/SDAccess.h"
#include "Hardware/TDeck/ActiveMapSetCodec.h"
#include "UI/LXMF/MapPackManifest.h"
#include <SD.h>
using Hardware::TDeck::ActiveMapSetCodec;
using Hardware::TDeck::MapTilePack;
using Hardware::TDeck::MapTilePackResult;
using Hardware::TDeck::MapTileStorage;
using Hardware::TDeck::MapTileStore;
using Hardware::TDeck::MapTileStoreSD;
using Hardware::TDeck::SDAccess;
using Hardware::TDeck::TileKey;
using Hardware::TDeck::TileStoreResult;
using Pyxis::MapPackManifest;
namespace {
std::size_t tests_run = 0U;
void fail(const char* expression, int line) {
std::fprintf(stderr, "line %d: %s\n", line, expression);
std::exit(1);
}
#define CHECK(expression) do { if (!(expression)) fail(#expression, __LINE__); } while (false)
void beginTest() { ++tests_run; }
const char* const kPackId = "world-osm-bright-z0-z9-20260801"; // 31 chars (max)
const char* const kMapSetId = "osm-bright";
const char* const kAttribution = "(c) OpenMapTiles (c) OpenStreetMap contributors";
// The production mount buffer (MapTileStoreSD.cpp): PATH_CAPACITY + 4 bytes
// for the "/sd" prefix. The model store below pins the pre-fix width so the
// overflow boundary stays exercised no matter how PATH_CAPACITY changes.
const std::size_t kModelMountCapacity = 68U;
std::uint32_t crc32_ieee(const std::uint8_t* data, std::size_t length) {
std::uint32_t crc = 0xffffffffU;
for (std::size_t i = 0U; i < length; ++i) {
crc ^= data[i];
for (std::uint8_t bit = 0U; bit < 8U; ++bit) {
crc = (crc >> 1U) ^ ((crc & 1U) != 0U ? 0xedb88320U : 0U);
}
}
return ~crc;
}
// Storage model of the pre-fix MapTileStoreSD mount-prefix arithmetic:
// paths whose "/sd" form overflowed the 68-byte mount buffer are rejected
// with INVALID_ARGUMENT before stat(), exactly as the real store rejected
// them on the device.
class ModelSDStorage : public MapTileStorage {
public:
struct Entry { std::string path; std::vector<std::uint8_t> bytes; };
ModelSDStorage() : open_index(-1), position(0U), open_length(0U) {}
bool isAvailable() const override { return true; }
TileStoreResult beginRead(const char* name, std::uint32_t& size) override {
if (!fits(name)) return TileStoreResult::INVALID_ARGUMENT;
const std::string n(name);
for (std::size_t i = 0U; i < files.size(); ++i) {
if (files[i].path == n) {
open_index = static_cast<int>(i);
position = 0U;
open_length = files[i].bytes.size();
size = static_cast<std::uint32_t>(open_length);
return TileStoreResult::OK;
}
}
return TileStoreResult::MISS;
}
TileStoreResult readChunk(std::uint8_t* out, std::size_t capacity, std::size_t& count) override {
if (open_index < 0) return TileStoreResult::IO_ERROR;
const std::size_t remaining = open_length - position;
count = capacity < remaining ? capacity : remaining;
if (count != 0U) {
std::memcpy(out, &files[static_cast<std::size_t>(open_index)].bytes[position], count);
}
position += count;
return TileStoreResult::OK;
}
void endRead() override { open_index = -1; }
TileStoreResult beginWrite(const char*) override { return TileStoreResult::IO_ERROR; }
TileStoreResult writeChunk(const std::uint8_t*, std::size_t, std::size_t&) override {
return TileStoreResult::IO_ERROR;
}
TileStoreResult commitWrite() override { return TileStoreResult::IO_ERROR; }
void abortWrite() override {}
TileStoreResult remove(const char*) override { return TileStoreResult::IO_ERROR; }
TileStoreResult rename(const char*, const char*) override { return TileStoreResult::IO_ERROR; }
TileStoreResult stat(const char* name, std::uint32_t& size) override {
if (!fits(name)) return TileStoreResult::INVALID_ARGUMENT;
const std::string n(name);
for (std::size_t i = 0U; i < files.size(); ++i) {
if (files[i].path == n) {
size = static_cast<std::uint32_t>(files[i].bytes.size());
return TileStoreResult::OK;
}
}
return TileStoreResult::MISS;
}
TileStoreResult beginList() override { return TileStoreResult::OK; }
TileStoreResult nextList(char*, std::size_t, bool& done) override {
done = true;
return TileStoreResult::OK;
}
void endList() override {}
void add(const char* name, const std::uint8_t* bytes, std::size_t length) {
Entry e;
e.path = name;
e.bytes.assign(bytes, bytes + length);
files.push_back(e);
}
// Mirrors makeMountedPath: snprintf(buffer, PATH_CAPACITY+4, "/sd%s", name).
bool fits(const char* name) const {
return (std::strlen(name) + 3U) < kModelMountCapacity;
}
private:
std::vector<Entry> files;
int open_index;
std::size_t position;
std::size_t open_length;
};
std::string tilePathFor(const char* pack_id, std::uint8_t zoom, std::uint32_t x, std::uint32_t y) {
// Larger than MapTileStore::PATH_CAPACITY on purpose: with the 31-character
// pack ID, two-digit z5 tile paths are 69 bytes -- exactly the overflow
// class this test pins.
char buffer[128] = {};
CHECK(MapTilePack::tilePath(pack_id, TileKey{zoom, x, y}, buffer, sizeof(buffer))
== MapTilePackResult::OK);
return buffer;
}
std::vector<std::uint8_t> build_pmas_v3(const char* map_set_id, const char* attribution,
const char* pack_id) {
// PMAS v3 layout (must match ActiveMapSetCodec::decode byte-for-byte):
// 0..3 "PMAS"
// 4 format_version (3 = indexless)
// 5 reserved (0)
// 6..7 u16 total record length (little-endian)
// 8..11 u32 generation (non-zero)
// 12.. u8 len + map_set_id
// u8 len + attribution
// u8 pack_count
// [u8 len + pack_id] * pack_count (no spans in v3)
// tail u32 CRC-32 (IEEE) over everything before it
std::vector<std::uint8_t> payload;
const auto put_str = [&payload](const char* text) {
const std::size_t n = std::strlen(text);
payload.push_back(static_cast<std::uint8_t>(n));
payload.insert(payload.end(), text, text + n);
};
const auto put_u32 = [&payload](std::uint32_t v) {
payload.push_back(static_cast<std::uint8_t>(v));
payload.push_back(static_cast<std::uint8_t>(v >> 8U));
payload.push_back(static_cast<std::uint8_t>(v >> 16U));
payload.push_back(static_cast<std::uint8_t>(v >> 24U));
};
put_u32(1U); // generation
put_str(map_set_id);
put_str(attribution);
payload.push_back(1U); // pack_count
put_str(pack_id);
const std::size_t total = 12U + payload.size();
CHECK(total <= ActiveMapSetCodec::MAX_SERIALIZED_SIZE);
std::vector<std::uint8_t> out;
out.reserve(total);
out.insert(out.end(), {'P', 'M', 'A', 'S', 3U, 0U});
out.push_back(static_cast<std::uint8_t>(total));
out.push_back(static_cast<std::uint8_t>(total >> 8U));
out.insert(out.end(), payload.begin(), payload.end());
const std::uint32_t crc = crc32_ieee(out.data(), out.size());
out.push_back(static_cast<std::uint8_t>(crc));
out.push_back(static_cast<std::uint8_t>(crc >> 8U));
out.push_back(static_cast<std::uint8_t>(crc >> 16U));
out.push_back(static_cast<std::uint8_t>(crc >> 24U));
CHECK(out.size() == total);
return out;
}
std::vector<std::uint8_t> build_manifest_v3(const char* pack_id) {
MapPackManifest m = {};
std::strcpy(m.pack_id, pack_id);
std::strcpy(m.name, "World OSM Bright z0-z9");
std::strcpy(m.attribution, kAttribution);
std::strcpy(m.source, "Oxed's Map Tile Downloader (OSM Bright)");
std::strcpy(m.license, "OSM ODbL; style CC-BY-4.0/BSD-3-Clause");
m.min_zoom = 0U;
m.max_zoom = 9U;
m.tile_count = 83567U;
m.format_version = MapPackManifest::INDEXLESS_FORMAT_VERSION;
std::uint8_t out[MapPackManifest::MAX_SERIALIZED_SIZE] = {};
std::size_t written = 0U;
CHECK(MapPackManifest::serializeIndexless(m, out, sizeof(out), written)
== Pyxis::ManifestResult::OK);
CHECK(written <= sizeof(out));
return std::vector<std::uint8_t>(out, out + written);
}
void read_tile_via(MapTilePack& pack, const TileKey& key, std::vector<std::uint8_t>& out) {
std::uint32_t size = 0U;
CHECK(pack.beginGet(key, size) == MapTilePackResult::OK);
out.clear();
while (out.size() < static_cast<std::size_t>(size)) {
std::uint8_t chunk[256];
std::size_t count = 0U;
const MapTilePackResult r = pack.readGetChunk(chunk, sizeof(chunk), count);
CHECK(r == MapTilePackResult::OK);
CHECK(count > 0U);
out.insert(out.end(), chunk, chunk + count);
}
CHECK(out.size() == static_cast<std::size_t>(size));
pack.endGet();
}
std::string shell_dir(const std::string& dir) {
// The dir is generated by this test from a compiler-provided temp path.
std::string quoted;
for (std::size_t i = 0U; i < dir.size(); ++i) {
const char c = dir[i];
if (c == '\'') quoted += "'\\''";
else quoted += c;
}
return "'" + quoted + "'";
}
void write_file(const std::string& path, const std::vector<std::uint8_t>& bytes) {
const std::size_t slash = path.find_last_of('/');
const std::string dir = path.substr(0U, slash);
if (!dir.empty() && dir != "/") {
(void)system(("mkdir -p " + shell_dir(dir)).c_str());
}
FILE* fp = std::fopen(path.c_str(), "wb");
CHECK(fp != NULL);
if (!bytes.empty()) {
CHECK(std::fwrite(bytes.data(), 1U, bytes.size(), fp) == bytes.size());
}
std::fclose(fp);
}
// True when "/sd" is a writable directory the production store can use for
// the end-to-end card-content section (a bind mount set up by the test
// harness, or a user-owned dir). A root-owned empty dir (stat-able but not
// writable) is NOT ready: the fixture write would fail.
bool sd_mount_ready() {
struct stat st;
if (::stat("/sd", &st) != 0 || !S_ISDIR(st.st_mode)) return false;
return ::access("/sd", W_OK) == 0;
}
} // namespace
// Shim globals: SDAccess.cpp references the Arduino globals `SD` and `SPI`;
// the FreeRTOS shim needs a mutex factory.
HostSD SD;
HostSPI SPI;
std::string HostSD::root_ = "/tmp";
bool HostSD::present_ = false;
SemaphoreHandle_t hostsd_create_mutex() { return (void*)1; }
int main(int argc, char** argv) {
CHECK(argc == 2);
const std::string root = argv[1];
HostSD::set_root(root.c_str());
CHECK(std::strlen(kPackId) == 31U);
CHECK(std::strlen(kPackId) == MapPackManifest::PACK_ID_CAPACITY - 1U);
// -- Wire records decode through the production codecs -------------------
std::vector<std::uint8_t> pmas = build_pmas_v3(kMapSetId, kAttribution, kPackId);
beginTest();
{
Hardware::TDeck::ActiveMapSetView view = {};
CHECK(ActiveMapSetCodec::decode(pmas.data(), pmas.size(), view));
CHECK(view.format_version == ActiveMapSetCodec::INDEXLESS_FORMAT_VERSION);
CHECK(view.generation == 1U);
CHECK(view.pack_count == 1U);
CHECK(std::strcmp(view.packs[0].pack_id, kPackId) == 0);
}
std::vector<std::uint8_t> manifest = build_manifest_v3(kPackId);
beginTest();
{
MapPackManifest parsed = {};
CHECK(MapPackManifest::parse(manifest.data(), manifest.size(), parsed)
== Pyxis::ManifestResult::OK);
CHECK(std::strcmp(parsed.pack_id, kPackId) == 0);
}
// -- The boundary itself, derived from the real path builders ------------
beginTest();
{
// Single-digit quadrants fit the pre-fix 68-byte mount buffer...
CHECK(tilePathFor(kPackId, 4U, 9U, 9U).size() + 3U < kModelMountCapacity);
// ...and the two-digit quadrants that failed on the device do not.
CHECK(tilePathFor(kPackId, 4U, 15U, 15U).size() + 3U >= kModelMountCapacity);
CHECK(tilePathFor(kPackId, 5U, 10U, 0U).size() + 3U >= kModelMountCapacity);
}
// The fix: the production mount buffer must hold the worst case. With a
// 31-character pack ID the longest z0-z22 mounted tile path is 82 bytes,
// so PATH_CAPACITY must be at least 80 (giving an 84-byte buffer). Pre-fix
// PATH_CAPACITY is 64, so this section is red until the fix lands.
beginTest();
{
CHECK(MapTileStore::PATH_CAPACITY >= 80U);
CHECK(MapTileStore::PATH_CAPACITY + 4U >= 84U);
}
// -- Model store: the pre-fix arithmetic, pinned --------------------------
beginTest();
{
ModelSDStorage store;
const std::vector<std::uint8_t> tile(64U, 0x5AU);
store.add(tilePathFor(kPackId, 4U, 9U, 9U).c_str(), tile.data(), tile.size());
std::uint32_t size = 0U;
CHECK(store.beginRead(tilePathFor(kPackId, 4U, 9U, 9U).c_str(), size)
== TileStoreResult::OK);
CHECK(size == tile.size());
store.endRead();
// Two-digit x or y: rejected before the file lookup, pre-fix.
CHECK(store.beginRead(tilePathFor(kPackId, 4U, 15U, 15U).c_str(), size)
== TileStoreResult::INVALID_ARGUMENT);
CHECK(store.beginRead(tilePathFor(kPackId, 5U, 10U, 0U).c_str(), size)
== TileStoreResult::INVALID_ARGUMENT);
}
// -- CORE REGRESSION: the real, unmodified MapTileStoreSD ------------------
// Production makeMountedPath rejects an overflowing mounted path with
// INVALID_ARGUMENT before stat(); a path that fits reaches the file
// lookup. Neither depends on card content, so this section runs
// everywhere. Pre-fix, the two-digit reads below return
// INVALID_ARGUMENT instead of OK and this test fails -- the TDD red.
beginTest();
{
CHECK(SDAccess::init(xSemaphoreCreateMutex()));
MapTileStoreSD store;
CHECK(store.isAvailable());
std::uint32_t size = 0U;
// Fits the old buffer: passes the overflow check, reaches the lookup,
// and reports MISS because no file exists on the (empty) mount.
CHECK(store.beginRead(tilePathFor(kPackId, 4U, 9U, 9U).c_str(), size)
== TileStoreResult::MISS);
// The device repro class. With the fixed buffer width these pass the
// overflow check and reach the lookup as well.
CHECK(store.beginRead(tilePathFor(kPackId, 4U, 15U, 15U).c_str(), size)
== TileStoreResult::MISS);
CHECK(store.beginRead(tilePathFor(kPackId, 5U, 10U, 0U).c_str(), size)
== TileStoreResult::MISS);
CHECK(store.beginRead(tilePathFor(kPackId, 5U, 0U, 10U).c_str(), size)
== TileStoreResult::MISS);
CHECK(store.beginRead(tilePathFor(kPackId, 5U, 31U, 31U).c_str(), size)
== TileStoreResult::MISS);
}
// -- Optional end-to-end: real MapTilePack over card content ---------------
// Only meaningful when "/sd" holds a pack (device-harness bind mount).
if (sd_mount_ready()) {
// The production store stats the literal "/sd" prefix and opens via
// SD.open(device_path); point the shim at the same mount point.
HostSD::set_root("/sd");
const auto make_tile = [](std::uint8_t seed) {
std::vector<std::uint8_t> v(1024U);
for (std::size_t i = 0U; i < v.size(); ++i) {
v[i] = static_cast<std::uint8_t>((i & 0xffU) ^ seed);
}
return v;
};
const std::string pmap = "/sd/pyxis-map";
{
std::string cmd = "mkdir -p " + shell_dir(pmap + "/map-sets");
CHECK(system(cmd.c_str()) == 0);
}
write_file(pmap + "/active-pack.0", pmas);
write_file(pmap + "/map-sets/" + std::string(kMapSetId) + ".pmas", pmas);
write_file(pmap + "/packs/" + kPackId + "/manifest.pmp", manifest);
const auto write_tile = [&](std::uint8_t z, std::uint32_t x, std::uint32_t y,
std::uint8_t seed) {
// The production store reads the literal "/sd" mount point, so the
// fixture must place tiles under /sd (device-relative path + prefix).
write_file(std::string("/sd") + tilePathFor(kPackId, z, x, y), make_tile(seed));
};
write_tile(3U, 7U, 7U, 0xA1U);
write_tile(4U, 9U, 9U, 0xA4U);
write_tile(4U, 15U, 15U, 0xA5U);
write_tile(5U, 10U, 0U, 0xA6U);
write_tile(5U, 0U, 10U, 0xA7U);
write_tile(5U, 31U, 31U, 0xA8U);
MapTileStoreSD sd_store;
MapTilePack pack(sd_store);
beginTest();
CHECK(pack.initialize() == MapTilePackResult::OK);
CHECK(pack.hasSelection());
CHECK(pack.selectionGeneration() == 1U);
beginTest();
{
std::vector<std::uint8_t> data;
read_tile_via(pack, TileKey{3U, 7U, 7U}, data);
CHECK(data[0] == static_cast<std::uint8_t>(0x00U ^ 0xA1U));
}
beginTest();
{
std::vector<std::uint8_t> data;
read_tile_via(pack, TileKey{4U, 15U, 15U}, data);
CHECK(data[0] == static_cast<std::uint8_t>(0x00U ^ 0xA5U));
}
beginTest();
{
std::vector<std::uint8_t> data;
read_tile_via(pack, TileKey{5U, 31U, 31U}, data);
CHECK(data[0] == static_cast<std::uint8_t>(0x00U ^ 0xA8U));
}
} else {
std::fprintf(stderr, "note: /sd not present; end-to-end card section skipped\n");
}
std::printf("map tile store path capacity: %lu tests passed\n",
static_cast<unsigned long>(tests_run));
return 0;
}