test: add host regression for MapTileStoreSD mount-path overflow

A 31-character pack ID makes mounted tile paths
(/sd/pyxis-map/packs/<id>/tiles/<z>/<x>/<y>.png) exceed the 68-byte mount
buffer once either x or y is two digits, so beginRead returns
INVALID_ARGUMENT and the UI shows 'Tile I/O error' at z4+ even though the
file exists and is intact.

Compiles the unmodified MapTilePack/MapTileStoreSD/SDAccess/codec/manifest
sources against small Arduino/FreeRTOS host shims. The core section (no
card needed) drives the real store with a model of the mount-prefix
arithmetic and asserts the 31-char-ID boundary loads at z4/z5; an optional
end-to-end section runs the real MapTilePack over a bound card root when
/sd is writable.

Fails on the current 64-byte PATH_CAPACITY: red by design.
This commit is contained in:
Torlando committed 2026-08-28 22:44:27 +00:00
1 parent 30c0d73565
commit b7ab97d624
10 files changed
+905

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
// Host shim for <Arduino.h> — compiles the real T-Deck SD storage sources
// (MapTileStoreSD.cpp, SDAccess.cpp) unmodified on x86.
//
// MapTileStoreSD.cpp uses Arduino.h only for fs::File (pulled via SD.h);
// SDAccess.cpp uses pinMode/digitalWrite/Serial, modelled here as no-ops.
// The ARDUINO macro itself is supplied by the test driver (-DARDUINO=100).
#ifndef SDHOSTSHIM_ARDUINO_H
#define SDHOSTSHIM_ARDUINO_H
#include <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#define HIGH 1
#define LOW 0
#define INPUT 0
#define OUTPUT 1
namespace {
struct HostSerial {
template <typename... Args>
void println(const char* const format, Args&&...) {
std::fputs(format ? format : "\n", stderr);
std::fputc('\n', stderr);
}
void println() { std::fputc('\n', stderr); }
void printf(const char* format, ...) {
va_list args;
va_start(args, format);
std::vfprintf(stderr, format, args);
va_end(args);
}
};
HostSerial __attribute__((unused)) host_serial;
} // namespace
#define Serial host_serial
inline void pinMode(std::uint8_t, std::uint8_t) {}
inline void digitalWrite(std::uint8_t, std::uint8_t) {}
inline void delay(std::uint32_t) {}
#endif
+8
View File
@@ -0,0 +1,8 @@
// Host shim for <FS.h> — MapTileStoreSD.h includes it for the fs::File type,
// which the SD.h shim provides.
#ifndef SDHOSTSHIM_FS_H
#define SDHOSTSHIM_FS_H
#include <SD.h>
#endif
+199
View File
@@ -0,0 +1,199 @@
// Host shim for <SD.h> — models the SD card as a host-filesystem tree rooted
// at HostSD::root(), mounted under the same "/sd" prefix the firmware uses.
//
// HostFile mirrors the Arduino sdio File semantics the production code relies
// on: copy constructs duplicate the open handle (both stay usable and close
// independently), read() returns data in bounded SPI bursts, and close()
// releases the underlying descriptor. Directory handles expose the
// openNextFile()/isDirectory()/path() iteration API used by
// MapTileStoreSD::nextList.
#ifndef SDHOSTSHIM_SD_H
#define SDHOSTSHIM_SD_H
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <dirent.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include "SPI.h"
#define FILE_READ 1
#define FILE_WRITE 2
#define CARD_NONE 0
#define CARD_MMC 1
#define CARD_SD 2
#define CARD_SDHC 3
class HostFile;
class HostSD {
public:
static void set_root(const char* path) { root_ = path; present_ = true; }
static const char* root() { return root_.empty() ? "/tmp" : root_.c_str(); }
static bool ready() { return present_; }
static bool begin(std::uint8_t, const HostSPI&, std::uint32_t,
const char* mount, std::uint8_t, bool) {
(void)mount;
return true;
}
static std::uint8_t cardType() { return present_ ? CARD_SDHC : CARD_NONE; }
static std::uint64_t cardSize() { return 119850ULL * 1024ULL * 1024ULL; }
static std::string absolute(const char* path) { return std::string(root()) + path; }
static bool exists(const char* path) {
struct stat st;
return ::stat(absolute(path).c_str(), &st) == 0;
}
static bool mkdir(const char* path) { return ::mkdir(absolute(path).c_str(), 0755) == 0; }
static bool remove(const char* path) { return ::unlink(absolute(path).c_str()) == 0; }
static bool rename(const char* from, const char* to) {
return ::rename(absolute(from).c_str(), absolute(to).c_str()) == 0;
}
static HostFile open(const char* path, int);
private:
static std::string root_;
static bool present_;
};
extern HostSD SD;
class HostFile {
public:
HostFile() : fd_(-1), dir_(NULL) {}
HostFile(const HostFile& other)
: fd_(other.fd_ >= 0 ? ::dup(other.fd_) : -1),
dir_(other.isDir() ? ::opendir(other.absolute().c_str()) : NULL),
path_(other.path_) {}
HostFile& operator=(const HostFile& other) {
if (this == &other) return *this;
close();
fd_ = other.fd_ >= 0 ? ::dup(other.fd_) : -1;
dir_ = other.isDir() ? ::opendir(other.absolute().c_str()) : NULL;
path_ = other.path_;
return *this;
}
~HostFile() { close(); }
explicit operator bool() const { return fd_ >= 0 || dir_ != NULL; }
bool isDirectory() const { return dir_ != NULL; }
// Device-relative path of this entry ("" for a closed handle).
const char* path() const { return path_.c_str(); }
std::string name() const {
const std::size_t slash = path_.find_last_of('/');
return path_.substr(slash + 1);
}
// Bounded SPI read burst: the SD layer never returns more than this many
// bytes per transfer, so callers always loop. Returns the number of bytes
// actually read (0 at end-of-file or error), matching Arduino File::read.
static const int SPI_BURST_BYTES = 512;
std::size_t read(void* buffer, std::size_t count) {
if (fd_ < 0) return 0U;
const std::size_t burst = count < static_cast<std::size_t>(SPI_BURST_BYTES)
? count : static_cast<std::size_t>(SPI_BURST_BYTES);
if (burst == 0U) return 0U;
const ssize_t got = ::read(fd_, buffer, burst);
return (got < 0) ? 0U : static_cast<std::size_t>(got);
}
std::size_t size() const {
if (fd_ < 0) return 0U;
struct stat st;
if (::fstat(fd_, &st) != 0) return 0U;
return static_cast<std::size_t>(st.st_size);
}
bool available() const {
if (fd_ < 0) return false;
const off_t cur = ::lseek(fd_, 0, SEEK_CUR);
if (cur < 0) return false;
const off_t end = ::lseek(fd_, 0, SEEK_END);
(void)::lseek(fd_, cur, SEEK_SET);
return end > cur;
}
void close() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
if (dir_ != NULL) {
::closedir(dir_);
dir_ = NULL;
}
}
// Next entry inside a directory handle (dotfiles skipped), or a closed
// handle at end-of-directory (errno=0).
HostFile openNextFile() const {
if (dir_ == NULL) {
errno = ENOTDIR;
return HostFile();
}
struct dirent* entry = NULL;
while ((entry = ::readdir(dir_)) != NULL) {
if (entry->d_name[0] == '.') continue;
const std::string abs_parent = absolute();
const std::string rel_child = path_ + "/" + entry->d_name;
const std::string abs_child = abs_parent + "/" + entry->d_name;
struct stat st;
if (::stat(abs_child.c_str(), &st) != 0) continue;
HostFile child;
child.path_ = rel_child;
if (S_ISDIR(st.st_mode)) {
child.dir_ = ::opendir(abs_child.c_str());
if (child.dir_ == NULL) child = HostFile();
} else {
child.fd_ = ::open(abs_child.c_str(), O_RDONLY);
if (child.fd_ < 0) child = HostFile();
}
if (child) {
errno = 0;
return child;
}
}
errno = 0;
return HostFile();
}
private:
friend class HostSD;
explicit HostFile(int fd, const std::string& path) : fd_(fd), dir_(NULL), path_(path) {}
HostFile(DIR* dir, const std::string& path) : fd_(-1), dir_(dir), path_(path) {}
std::string absolute() const { return HostSD::absolute(path_.c_str()); }
bool isDir() const { return dir_ != NULL; }
int fd_;
DIR* dir_;
std::string path_;
};
using File = HostFile;
namespace fs {
using File = HostFile;
}
inline HostFile HostSD::open(const char* path, int) {
const std::string absolute = HostSD::absolute(path);
struct stat st;
if (::stat(absolute.c_str(), &st) != 0) return HostFile();
if (S_ISDIR(st.st_mode)) {
DIR* dir = ::opendir(absolute.c_str());
return (dir == NULL) ? HostFile() : HostFile(dir, path);
}
const int fd = ::open(absolute.c_str(), O_RDONLY);
return (fd < 0) ? HostFile() : HostFile(fd, path);
}
#endif
+15
View File
@@ -0,0 +1,15 @@
// Host shim for <SPI.h> — compiles the real T-Deck SD sources (SDAccess.cpp)
// unmodified on x86. Only the call sites used by SDAccess::init are modelled.
#ifndef SDHOSTSHIM_SPI_H
#define SDHOSTSHIM_SPI_H
#include <cstdint>
class HostSPI {
public:
void begin(std::uint8_t, std::uint8_t, std::uint8_t) {}
};
extern HostSPI SPI;
#endif
+16
View File
@@ -0,0 +1,16 @@
// Host shim for <esp_heap_caps.h> — MapTilePack.cpp selects PSRAM-backed
// buffers only on ARDUINO_ARCH_ESP32; on the host the static fallback
// members are used and these are never called. Provided for completeness.
#ifndef SDHOSTSHIM_ESP_HEAP_CAPS_H
#define SDHOSTSHIM_ESP_HEAP_CAPS_H
#include <cstdlib>
#include <cstdint>
#define MALLOC_CAP_SPIRAM 1
#define MALLOC_CAP_8BIT 2
inline void* heap_caps_malloc(std::size_t size, std::uint32_t) { return std::malloc(size); }
inline void heap_caps_free(void* pointer) { std::free(pointer); }
#endif
@@ -0,0 +1,22 @@
// Host shim for <freertos/FreeRTOS.h> — compiles the real T-Deck SD sources
// (SDAccess.cpp) unmodified on x86. Models the shared SPI bus mutex as a
// plain always-acquirable semaphore; the tests exercise storage logic, not
// bus arbitration.
#ifndef SDHOSTSHIM_FREERTOS_FREERTOS_H
#define SDHOSTSHIM_FREERTOS_FREERTOS_H
#include <cstddef>
#include <cstdint>
typedef void* SemaphoreHandle_t;
typedef long BaseType_t;
typedef unsigned long TickType_t;
#define pdTRUE 1
#define pdFALSE 0
#define pdMS_TO_TICKS(ms) (ms)
#define portMAX_DELAY 0xFFFFFFFFU
extern SemaphoreHandle_t hostsd_create_mutex(void);
#endif
+11
View File
@@ -0,0 +1,11 @@
#ifndef SDHOSTSHIM_FREERTOS_SEMPHR_H
#define SDHOSTSHIM_FREERTOS_SEMPHR_H
#include <freertos/FreeRTOS.h>
inline SemaphoreHandle_t xSemaphoreCreateMutex() { return hostsd_create_mutex(); }
// Single-threaded host build: the shared SPI bus mutex is always free.
inline BaseType_t xSemaphoreTake(SemaphoreHandle_t, TickType_t) { return pdTRUE; }
inline BaseType_t xSemaphoreGive(SemaphoreHandle_t) { return pdTRUE; }
#endif
@@ -0,0 +1,13 @@
// Host shim for <microReticulum/Log.h> — SDAccess.cpp pulls in
// `using namespace RNS;` for its logging helpers.
#ifndef SDHOSTSHIM_MICRORETICULUM_LOG_H
#define SDHOSTSHIM_MICRORETICULUM_LOG_H
namespace RNS {
inline void log(const char*) {}
inline void logf(const char*, ...) {}
inline void logError(const char*) {}
inline void logWarning(const char*) {}
}
#endif
@@ -0,0 +1,464 @@
// 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 = "Map data (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.
write_file(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;
}
@@ -0,0 +1,109 @@
from __future__ import annotations
from pathlib import Path
import os
import shutil
import subprocess
import pytest
from native_test import find_cxx
ROOT = Path(__file__).resolve().parents[2]
TEST_SOURCE = ROOT / "tests/native/test_map_tile_store_path_capacity.cpp"
PACK_SOURCE = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTilePack.cpp"
STORE_SOURCE = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileStoreSD.cpp"
SDACCESS_SOURCE = ROOT / "lib/tdeck_ui/Hardware/TDeck/SDAccess.cpp"
CODEC_SOURCE = ROOT / "lib/tdeck_ui/Hardware/TDeck/ActiveMapSetCodec.cpp"
MANIFEST_SOURCE = ROOT / "lib/tdeck_ui/UI/LXMF/MapPackManifest.cpp"
SHIM_DIR = ROOT / "tests/native/sdhostshim"
def sudo_ok() -> bool:
return shutil.which("sudo") is not None and subprocess.run(
["sudo", "-n", "true"], capture_output=True, timeout=10
).returncode == 0
def sd_writable() -> bool:
return os.access("/sd", os.W_OK)
def run_test(binary: Path, card_root: Path, env: dict, timeout: int) -> subprocess.CompletedProcess[str]:
"""Run the test binary, arranging a /sd bind mount for the end-to-end
section when possible.
The production store reads the literal "/sd" mount point, so the
end-to-end card-content section only runs when "/sd" is a writable
directory. Without a usable password-less sudo we still run the binary
normally: the core regression section (mount-independent) executes, and
the binary prints a skip note for the card section.
"""
command = [str(binary), str(card_root)]
mounted = False
run_as_root = False
if not sd_writable() and sudo_ok():
probe = subprocess.run(["mountpoint", "-q", "/sd"], capture_output=True, timeout=10)
if probe.returncode == 0:
# A live mount we did not create: leave it alone, run normally.
pass
else:
subprocess.run(
["sudo", "-n", "mkdir", "-p", "/sd"], capture_output=True, timeout=10)
mnt = subprocess.run(
["sudo", "-n", "mount", "--bind", str(card_root), "/sd"],
capture_output=True, text=True, timeout=30,
)
if mnt.returncode == 0:
mounted = True
run_as_root = True # bind mount content is root-owned
else:
raise pytest.skip(f"could not bind-mount card root at /sd: {mnt.stderr.strip()}")
if run_as_root:
command = ["sudo", "-n"] + command
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, env=env)
finally:
if mounted:
subprocess.run(["sudo", "-n", "umount", "/sd"], capture_output=True, timeout=30)
@pytest.mark.parametrize("sanitize", [False, True], ids=["strict-cxx11", "asan-ubsan"])
def test_map_tile_store_path_capacity(tmp_path: Path, sanitize: bool) -> None:
# Note: -Wconversion/-Wsign-conversion are deliberately omitted here (and
# only here): SDAccess.cpp is ARDUINO-gated production code that only ever
# compiles under PlatformIO's ESP32 flag set, where the uint8_t/int
# Arduino API conversions are routine. The other flags above are kept so
# the unmodified store/pack sources stay strictly checked.
binary = tmp_path / "test_map_tile_store_path_capacity"
card_root = tmp_path / "card"
card_root.mkdir()
command = [
find_cxx(), "-std=c++11", "-Wall", "-Wextra", "-Werror", "-pedantic",
"-DARDUINO=100",
f"-I{SHIM_DIR}", f"-I{ROOT / 'lib/tdeck_ui'}",
str(TEST_SOURCE), str(PACK_SOURCE), str(STORE_SOURCE),
str(SDACCESS_SOURCE), str(CODEC_SOURCE), str(MANIFEST_SOURCE),
"-o", str(binary),
]
if sanitize:
command[1:1] = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"]
compiled = subprocess.run(command, capture_output=True, text=True, timeout=120,
cwd=ROOT)
assert compiled.returncode == 0, compiled.stdout + compiled.stderr
env = os.environ.copy()
if sanitize:
env["ASAN_OPTIONS"] = "detect_leaks=1:halt_on_error=1"
env["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1"
ran = run_test(binary, card_root, env, timeout=120)
assert ran.returncode == 0, ran.stdout + ran.stderr
# The end-to-end section (4 tests) is conditional on a writable /sd mount;
# the core regression section (6 tests) always runs.
for expected in (
"map tile store path capacity: 6 tests passed\n",
"map tile store path capacity: 10 tests passed\n",
):
if ran.stdout == expected:
return
raise AssertionError(f"unexpected output: {ran.stdout!r}")