diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileStore.cpp b/lib/tdeck_ui/Hardware/TDeck/MapTileStore.cpp new file mode 100644 index 00000000..7ae52273 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileStore.cpp @@ -0,0 +1,388 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#include "MapTileStore.h" + +#include +#include + +namespace Hardware { +namespace TDeck { + +namespace { +const char TILE_PREFIX[] = "/pyxis-map/tiles/"; +const char LIVE_SUFFIX[] = ".png"; +const char TEMP_SUFFIX[] = ".png.tmp"; +const char BACKUP_SUFFIX[] = ".png.bak"; + +bool appendSuffix(const char* live, const char* suffix, char* output, std::size_t capacity) { + const std::size_t a = std::strlen(live); + const std::size_t b = std::strlen(suffix); + if ((a + b + 1U) > capacity) return false; + std::memcpy(output, live, a); + std::memcpy(output + a, suffix, b + 1U); + return true; +} + +bool parseNumber(const char*& cursor, char delimiter, std::uint32_t& value) { + if ((*cursor < '0') || (*cursor > '9')) return false; + if ((*cursor == '0') && (cursor[1] != delimiter)) return false; + std::uint32_t result = 0U; + while ((*cursor >= '0') && (*cursor <= '9')) { + const std::uint32_t digit = static_cast(*cursor - '0'); + if (result > ((UINT32_MAX - digit) / 10U)) return false; + result = (result * 10U) + digit; + ++cursor; + } + if (*cursor != delimiter) return false; + ++cursor; + value = result; + return true; +} +} + +MapTileStore::MapTileStore(MapTileStorage& storage, const TileStoreConfig& config) + : storage_(storage), config_(config), entry_count_(0U), total_bytes_(0U), next_sequence_(1U), + initialized_(false), read_open_(false), write_open_(false), put_key_{0U, 0U, 0U}, + put_live_{0}, put_temp_{0}, put_backup_{0}, png_header_{0}, png_header_count_(0U), put_size_(0U) {} + +bool MapTileStore::sameKey(const TileKey& a, const TileKey& b) { + return (a.zoom == b.zoom) && (a.x == b.x) && (a.y == b.y); +} + +bool MapTileStore::keyLess(const TileKey& a, const TileKey& b) { + if (a.zoom != b.zoom) return a.zoom < b.zoom; + if (a.x != b.x) return a.x < b.x; + return a.y < b.y; +} + +bool MapTileStore::isValidKey(const TileKey& key) { + if (key.zoom > MAX_ZOOM) return false; + const std::uint32_t count = UINT32_C(1) << key.zoom; + return (key.x < count) && (key.y < count); +} + +TileStoreResult MapTileStore::canonicalPath(const TileKey& key, char* output, std::size_t capacity) { + if (!isValidKey(key)) return TileStoreResult::INVALID_KEY; + if ((output == NULL) || (capacity == 0U)) return TileStoreResult::INVALID_ARGUMENT; + const int count = std::snprintf(output, capacity, "/pyxis-map/tiles/%u/%lu/%lu.png", + static_cast(key.zoom), static_cast(key.x), + static_cast(key.y)); + if ((count < 0) || (static_cast(count) >= capacity)) return TileStoreResult::INVALID_ARGUMENT; + return TileStoreResult::OK; +} + +TileStoreResult MapTileStore::parseOwnedPath(const char* name, TileKey& key, std::uint8_t& flag) { + if (name == NULL) return TileStoreResult::INDEX_MISMATCH; + const std::size_t prefix_size = sizeof(TILE_PREFIX) - 1U; + if (std::strncmp(name, TILE_PREFIX, prefix_size) != 0) return TileStoreResult::INDEX_MISMATCH; + const char* cursor = name + prefix_size; + std::uint32_t zoom = 0U; + if (!parseNumber(cursor, '/', zoom) || !parseNumber(cursor, '/', key.x)) return TileStoreResult::INDEX_MISMATCH; + const char* y_start = cursor; + if ((*cursor < '0') || (*cursor > '9')) return TileStoreResult::INDEX_MISMATCH; + while ((*cursor >= '0') && (*cursor <= '9')) ++cursor; + if ((*y_start == '0') && ((cursor - y_start) != 1)) return TileStoreResult::INDEX_MISMATCH; + std::uint64_t y = 0U; + for (const char* p = y_start; p != cursor; ++p) { + y = (y * 10U) + static_cast(*p - '0'); + if (y > UINT32_MAX) return TileStoreResult::INDEX_MISMATCH; + } + key.zoom = (zoom <= UINT8_MAX) ? static_cast(zoom) : UINT8_MAX; + key.y = static_cast(y); + if (!isValidKey(key)) return TileStoreResult::INDEX_MISMATCH; + if (std::strcmp(cursor, LIVE_SUFFIX) == 0) flag = HAS_LIVE; + else if (std::strcmp(cursor, TEMP_SUFFIX) == 0) flag = HAS_TEMP; + else if (std::strcmp(cursor, BACKUP_SUFFIX) == 0) flag = HAS_BACKUP; + else return TileStoreResult::INDEX_MISMATCH; + char canonical[PATH_CAPACITY] = {}; + if ((canonicalPath(key, canonical, sizeof(canonical)) != TileStoreResult::OK) || + (std::strncmp(name, canonical, std::strlen(canonical)) != 0)) return TileStoreResult::INDEX_MISMATCH; + return TileStoreResult::OK; +} + +int MapTileStore::findEntry(const TileKey& key) const { + for (std::uint16_t i = 0U; i < entry_count_; ++i) if (sameKey(entries_[i].key, key)) return static_cast(i); + return -1; +} + +void MapTileStore::removeEntry(std::uint16_t index) { + total_bytes_ -= entries_[index].size; + for (std::uint16_t i = index; (i + 1U) < entry_count_; ++i) entries_[i] = entries_[i + 1U]; + --entry_count_; +} + +TileStoreResult MapTileStore::validatePathHeader(const char* path, std::uint32_t& size) { + TileStoreResult result = storage_.beginRead(path, size); + if (result != TileStoreResult::OK) return result; + std::uint8_t header[24] = {}; + std::size_t total = 0U; + while (total < sizeof(header)) { + std::size_t got = 0U; + result = storage_.readChunk(header + total, sizeof(header) - total, got); + if (result != TileStoreResult::OK) { storage_.endRead(); return result; } + if (got == 0U) break; + total += got; + } + storage_.endRead(); + if (total != sizeof(header)) return TileStoreResult::INDEX_MISMATCH; + static const std::uint8_t signature[8] = {137U, 80U, 78U, 71U, 13U, 10U, 26U, 10U}; + if ((std::memcmp(header, signature, sizeof(signature)) != 0) || header[8] != 0U || header[9] != 0U || + header[10] != 0U || header[11] != 13U || std::memcmp(header + 12U, "IHDR", 4U) != 0 || + header[16] != 0U || header[17] != 0U || header[18] != 1U || header[19] != 0U || + header[20] != 0U || header[21] != 0U || header[22] != 1U || header[23] != 0U) { + return TileStoreResult::INDEX_MISMATCH; + } + return TileStoreResult::OK; +} + +TileStoreResult MapTileStore::validateLiveHeader(const Entry& entry) { + char name[PATH_CAPACITY] = {}; + if (canonicalPath(entry.key, name, sizeof(name)) != TileStoreResult::OK) { + return TileStoreResult::INDEX_MISMATCH; + } + std::uint32_t size = 0U; + const TileStoreResult result = validatePathHeader(name, size); + if (result != TileStoreResult::OK) return result; + return (size == entry.size) ? TileStoreResult::OK : TileStoreResult::INDEX_MISMATCH; +} + +TileStoreResult MapTileStore::recoverIndex() { + TileStoreResult result = storage_.beginList(); + if (result != TileStoreResult::OK) return result; + while (true) { + char name[PATH_CAPACITY] = {}; + bool done = false; + result = storage_.nextList(name, sizeof(name), done); + if (result != TileStoreResult::OK) { storage_.endList(); return result; } + if (done) break; + TileKey key = {0U, 0U, 0U}; + std::uint8_t flag = 0U; + result = parseOwnedPath(name, key, flag); + if (result != TileStoreResult::OK) { storage_.endList(); return result; } + int index = findEntry(key); + if (index < 0) { + if (entry_count_ >= config_.max_entries) { storage_.endList(); return TileStoreResult::INDEX_FULL; } + index = static_cast(entry_count_++); + entries_[static_cast(index)] = Entry{key, 0U, 0U, 0U}; + } + Entry& entry = entries_[static_cast(index)]; + if ((entry.recovery_flags & flag) != 0U) { storage_.endList(); return TileStoreResult::INDEX_MISMATCH; } + entry.recovery_flags = static_cast(entry.recovery_flags | flag); + } + storage_.endList(); + + std::uint16_t i = 0U; + while (i < entry_count_) { + Entry& entry = entries_[i]; + char live[PATH_CAPACITY] = {}, temp[PATH_CAPACITY] = {}, backup[PATH_CAPACITY] = {}; + canonicalPath(entry.key, live, sizeof(live)); + appendSuffix(live, ".tmp", temp, sizeof(temp)); + appendSuffix(live, ".bak", backup, sizeof(backup)); + + bool keep = false; + if ((entry.recovery_flags & HAS_LIVE) != 0U) { + result = validatePathHeader(live, entry.size); + if (result == TileStoreResult::OK) { + keep = true; + } else if ((result == TileStoreResult::IO_ERROR) || + (result == TileStoreResult::STORAGE_UNAVAILABLE)) { + return result; + } + } + + if (!keep && ((entry.recovery_flags & HAS_BACKUP) != 0U)) { + std::uint32_t backup_size = 0U; + result = validatePathHeader(backup, backup_size); + if (result == TileStoreResult::OK) { + const TileStoreResult removed = storage_.remove(live); + if ((removed != TileStoreResult::OK) && (removed != TileStoreResult::MISS)) return removed; + result = storage_.rename(backup, live); + if (result != TileStoreResult::OK) return result; + entry.size = backup_size; + keep = true; + } else if ((result == TileStoreResult::IO_ERROR) || + (result == TileStoreResult::STORAGE_UNAVAILABLE)) { + return result; + } + } + + if (keep) { + const TileStoreResult temp_removed = storage_.remove(temp); + if ((temp_removed != TileStoreResult::OK) && (temp_removed != TileStoreResult::MISS)) return temp_removed; + const TileStoreResult backup_removed = storage_.remove(backup); + if ((backup_removed != TileStoreResult::OK) && (backup_removed != TileStoreResult::MISS)) return backup_removed; + if ((entry.size > config_.max_tile_bytes) || + (UINT32_MAX - total_bytes_ < entry.size)) return TileStoreResult::QUOTA_EXCEEDED; + total_bytes_ += entry.size; + ++i; + continue; + } + + const TileStoreResult live_removed = storage_.remove(live); + if ((live_removed != TileStoreResult::OK) && (live_removed != TileStoreResult::MISS)) return live_removed; + const TileStoreResult temp_removed = storage_.remove(temp); + if ((temp_removed != TileStoreResult::OK) && (temp_removed != TileStoreResult::MISS)) return temp_removed; + const TileStoreResult backup_removed = storage_.remove(backup); + if ((backup_removed != TileStoreResult::OK) && (backup_removed != TileStoreResult::MISS)) return backup_removed; + for (std::uint16_t j = i; (j + 1U) < entry_count_; ++j) entries_[j] = entries_[j + 1U]; + --entry_count_; + } + if (total_bytes_ > config_.byte_quota) return TileStoreResult::QUOTA_EXCEEDED; + for (std::uint16_t a = 1U; a < entry_count_; ++a) { + Entry value = entries_[a]; std::uint16_t b = a; + while ((b > 0U) && keyLess(value.key, entries_[b - 1U].key)) { entries_[b] = entries_[b - 1U]; --b; } + entries_[b] = value; + } + for (std::uint16_t n = 0U; n < entry_count_; ++n) entries_[n].sequence = next_sequence_++; + return TileStoreResult::OK; +} + +TileStoreResult MapTileStore::initialize() { + if ((config_.max_entries == 0U) || (config_.max_entries > HARD_MAX_ENTRIES) || + (config_.byte_quota == 0U) || (config_.max_tile_bytes < 24U)) return TileStoreResult::INVALID_ARGUMENT; + if (!storage_.isAvailable()) return TileStoreResult::STORAGE_UNAVAILABLE; + entry_count_ = 0U; total_bytes_ = 0U; next_sequence_ = 1U; initialized_ = false; + const TileStoreResult result = recoverIndex(); + if (result == TileStoreResult::OK) initialized_ = true; + return result; +} + +TileStoreResult MapTileStore::beginGet(const TileKey& key, std::uint32_t& size) { + if (!initialized_) return TileStoreResult::NOT_INITIALIZED; + if (read_open_ || write_open_) return TileStoreResult::BUSY; + if (!isValidKey(key)) return TileStoreResult::INVALID_KEY; + if (!storage_.isAvailable()) return TileStoreResult::STORAGE_UNAVAILABLE; + const int index = findEntry(key); + if (index < 0) return TileStoreResult::MISS; + char name[PATH_CAPACITY] = {}; + canonicalPath(key, name, sizeof(name)); + TileStoreResult result = storage_.beginRead(name, size); + if (result != TileStoreResult::OK) return result; + if (size != entries_[static_cast(index)].size) { storage_.endRead(); return TileStoreResult::INDEX_MISMATCH; } + entries_[static_cast(index)].sequence = next_sequence_++; + read_open_ = true; + return TileStoreResult::OK; +} + +TileStoreResult MapTileStore::readGetChunk(std::uint8_t* output, std::size_t capacity, std::size_t& count) { + if (!read_open_) return TileStoreResult::BUSY; + if ((output == NULL) && (capacity != 0U)) return TileStoreResult::INVALID_ARGUMENT; + return storage_.readChunk(output, capacity, count); +} + +void MapTileStore::endGet() { if (read_open_) storage_.endRead(); read_open_ = false; } + +TileStoreResult MapTileStore::beginPut(const TileKey& key) { + if (!initialized_) return TileStoreResult::NOT_INITIALIZED; + if (read_open_ || write_open_) return TileStoreResult::BUSY; + if (!isValidKey(key)) return TileStoreResult::INVALID_KEY; + if (!storage_.isAvailable()) return TileStoreResult::STORAGE_UNAVAILABLE; + TileStoreResult result = canonicalPath(key, put_live_, sizeof(put_live_)); + if (result != TileStoreResult::OK) return result; + if (!appendSuffix(put_live_, ".tmp", put_temp_, sizeof(put_temp_)) || + !appendSuffix(put_live_, ".bak", put_backup_, sizeof(put_backup_))) return TileStoreResult::INVALID_ARGUMENT; + result = storage_.remove(put_temp_); + if ((result != TileStoreResult::OK) && (result != TileStoreResult::MISS)) return result; + result = storage_.beginWrite(put_temp_); + if (result != TileStoreResult::OK) return result; + put_key_ = key; put_size_ = 0U; png_header_count_ = 0U; std::memset(png_header_, 0, sizeof(png_header_)); write_open_ = true; + return TileStoreResult::OK; +} + +void MapTileStore::failPut() { + if (write_open_) storage_.abortWrite(); + storage_.remove(put_temp_); + write_open_ = false; +} + +TileStoreResult MapTileStore::writePutChunk(const std::uint8_t* data, std::size_t size) { + if (!write_open_) return TileStoreResult::BUSY; + if ((data == NULL) && (size != 0U)) { failPut(); return TileStoreResult::INVALID_ARGUMENT; } + if ((size > config_.max_tile_bytes) || (put_size_ > config_.max_tile_bytes - static_cast(size))) { + failPut(); return TileStoreResult::TOO_LARGE; + } + const std::size_t needed = sizeof(png_header_) - png_header_count_; + const std::size_t copy = (size < needed) ? size : needed; + if (copy != 0U) { std::memcpy(png_header_ + png_header_count_, data, copy); png_header_count_ += copy; } + std::size_t written = 0U; + const TileStoreResult result = storage_.writeChunk(data, size, written); + if ((result != TileStoreResult::OK) || (written != size)) { failPut(); return (result == TileStoreResult::OK) ? TileStoreResult::IO_ERROR : result; } + put_size_ += static_cast(size); + return TileStoreResult::OK; +} + +bool MapTileStore::validPngHeader() const { + static const std::uint8_t signature[8] = {137U, 80U, 78U, 71U, 13U, 10U, 26U, 10U}; + return (png_header_count_ == sizeof(png_header_)) && + (std::memcmp(png_header_, signature, sizeof(signature)) == 0) && + (png_header_[8] == 0U) && (png_header_[9] == 0U) && (png_header_[10] == 0U) && (png_header_[11] == 13U) && + (std::memcmp(png_header_ + 12U, "IHDR", 4U) == 0) && + (png_header_[16] == 0U) && (png_header_[17] == 0U) && (png_header_[18] == 1U) && (png_header_[19] == 0U) && + (png_header_[20] == 0U) && (png_header_[21] == 0U) && (png_header_[22] == 1U) && (png_header_[23] == 0U); +} + +TileStoreResult MapTileStore::evictFor(const TileKey& key, std::uint32_t new_size) { + int existing = findEntry(key); + std::uint32_t prospective = total_bytes_; + std::uint16_t count = entry_count_; + if (existing >= 0) { prospective -= entries_[static_cast(existing)].size; } + else { ++count; } + if (UINT32_MAX - prospective < new_size) return TileStoreResult::QUOTA_EXCEEDED; + prospective += new_size; + while ((prospective > config_.byte_quota) || (count > config_.max_entries)) { + int victim = -1; + for (std::uint16_t i = 0U; i < entry_count_; ++i) { + if (sameKey(entries_[i].key, key)) continue; + if ((victim < 0) || (entries_[i].sequence < entries_[static_cast(victim)].sequence)) victim = static_cast(i); + } + if (victim < 0) return (prospective > config_.byte_quota) ? TileStoreResult::QUOTA_EXCEEDED : TileStoreResult::INDEX_FULL; + char name[PATH_CAPACITY] = {}; + canonicalPath(entries_[static_cast(victim)].key, name, sizeof(name)); + TileStoreResult result = storage_.remove(name); + if ((result != TileStoreResult::OK) && (result != TileStoreResult::MISS)) return result; + prospective -= entries_[static_cast(victim)].size; + removeEntry(static_cast(victim)); + --count; + existing = findEntry(key); + } + return TileStoreResult::OK; +} + +TileStoreResult MapTileStore::finishPut() { + if (!write_open_) return TileStoreResult::BUSY; + if (!validPngHeader()) { failPut(); return TileStoreResult::INVALID_PNG; } + TileStoreResult result = storage_.commitWrite(); + if (result != TileStoreResult::OK) { failPut(); return result; } + write_open_ = false; + result = evictFor(put_key_, put_size_); + if (result != TileStoreResult::OK) { storage_.remove(put_temp_); return result; } + int index = findEntry(put_key_); + const bool duplicate = index >= 0; + if (duplicate) { + storage_.remove(put_backup_); + result = storage_.rename(put_live_, put_backup_); + if (result != TileStoreResult::OK) { storage_.remove(put_temp_); return result; } + } + result = storage_.rename(put_temp_, put_live_); + if (result != TileStoreResult::OK) { + if (duplicate) storage_.rename(put_backup_, put_live_); + storage_.remove(put_temp_); + return result; + } + if (duplicate) { + storage_.remove(put_backup_); + Entry& entry = entries_[static_cast(index)]; + total_bytes_ -= entry.size; total_bytes_ += put_size_; entry.size = put_size_; entry.sequence = next_sequence_++; + } else { + if (entry_count_ >= config_.max_entries) { storage_.remove(put_live_); return TileStoreResult::INDEX_FULL; } + entries_[entry_count_++] = Entry{put_key_, put_size_, next_sequence_++, HAS_LIVE}; + total_bytes_ += put_size_; + } + return TileStoreResult::OK; +} + +void MapTileStore::abortPut() { failPut(); } + +} // namespace TDeck +} // namespace Hardware diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileStore.h b/lib/tdeck_ui/Hardware/TDeck/MapTileStore.h new file mode 100644 index 00000000..8a5dce83 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileStore.h @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_MAP_TILE_STORE_H +#define HARDWARE_TDECK_MAP_TILE_STORE_H + +#include +#include + +namespace Hardware { +namespace TDeck { + +enum class TileStoreResult : std::uint8_t { + OK, + MISS, + INVALID_KEY, + INVALID_ARGUMENT, + STORAGE_UNAVAILABLE, + IO_ERROR, + INVALID_PNG, + TOO_LARGE, + QUOTA_EXCEEDED, + INDEX_FULL, + INDEX_MISMATCH, + NOT_INITIALIZED, + BUSY +}; + +struct TileKey { + std::uint8_t zoom; + std::uint32_t x; + std::uint32_t y; +}; + +struct TileStoreConfig { + std::uint16_t max_entries; + std::uint32_t byte_quota; + std::uint32_t max_tile_bytes; +}; + +/** Storage boundary used by the portable fixed-capacity cache core. */ +class MapTileStorage { +public: + virtual ~MapTileStorage() {} + virtual bool isAvailable() const = 0; + virtual TileStoreResult beginRead(const char* name, std::uint32_t& size) = 0; + virtual TileStoreResult readChunk(std::uint8_t* output, std::size_t capacity, std::size_t& count) = 0; + virtual void endRead() = 0; + virtual TileStoreResult beginWrite(const char* name) = 0; + virtual TileStoreResult writeChunk(const std::uint8_t* data, std::size_t size, std::size_t& written) = 0; + virtual TileStoreResult commitWrite() = 0; + virtual void abortWrite() = 0; + virtual TileStoreResult remove(const char* name) = 0; + virtual TileStoreResult rename(const char* from, const char* to) = 0; + virtual TileStoreResult stat(const char* name, std::uint32_t& size) = 0; + virtual TileStoreResult beginList() = 0; + virtual TileStoreResult nextList(char* name, std::size_t capacity, bool& done) = 0; + virtual void endList() = 0; +}; + +/** + * Bounded, allocation-free offline slippy-map tile store. + * + * initialize() reconstructs a fixed index from the owned SD namespace. It + * rejects malformed/unaccounted names, over-capacity media, and over-quota + * media rather than allowing pre-existing files to escape accounting. LRU + * order after reboot is the canonical TileKey order and is therefore + * deterministic; successful reads/writes advance the in-memory sequence. + */ +class MapTileStore { +public: + static const std::uint8_t MAX_ZOOM = 22U; + static const std::size_t PATH_CAPACITY = 64U; + static const std::uint16_t HARD_MAX_ENTRIES = 128U; + + MapTileStore(MapTileStorage& storage, const TileStoreConfig& config); + + TileStoreResult initialize(); + static bool isValidKey(const TileKey& key); + static TileStoreResult canonicalPath(const TileKey& key, char* output, std::size_t capacity); + + TileStoreResult beginGet(const TileKey& key, std::uint32_t& size); + TileStoreResult readGetChunk(std::uint8_t* output, std::size_t capacity, std::size_t& count); + void endGet(); + + TileStoreResult beginPut(const TileKey& key); + TileStoreResult writePutChunk(const std::uint8_t* data, std::size_t size); + TileStoreResult finishPut(); + void abortPut(); + + std::uint16_t entryCount() const { return entry_count_; } + std::uint32_t totalBytes() const { return total_bytes_; } + static std::size_t ramBytes() { return sizeof(MapTileStore); } + +private: + struct Entry { + TileKey key; + std::uint32_t size; + std::uint64_t sequence; + std::uint8_t recovery_flags; + }; + + enum RecoveryFlag { + HAS_LIVE = 1, + HAS_TEMP = 2, + HAS_BACKUP = 4 + }; + + MapTileStorage& storage_; + TileStoreConfig config_; + Entry entries_[HARD_MAX_ENTRIES]; + std::uint16_t entry_count_; + std::uint32_t total_bytes_; + std::uint64_t next_sequence_; + bool initialized_; + bool read_open_; + bool write_open_; + TileKey put_key_; + char put_live_[PATH_CAPACITY]; + char put_temp_[PATH_CAPACITY]; + char put_backup_[PATH_CAPACITY]; + std::uint8_t png_header_[24]; + std::size_t png_header_count_; + std::uint32_t put_size_; + + static bool sameKey(const TileKey& a, const TileKey& b); + static bool keyLess(const TileKey& a, const TileKey& b); + static TileStoreResult parseOwnedPath(const char* name, TileKey& key, std::uint8_t& flag); + int findEntry(const TileKey& key) const; + void removeEntry(std::uint16_t index); + TileStoreResult validatePathHeader(const char* path, std::uint32_t& size); + TileStoreResult validateLiveHeader(const Entry& entry); + TileStoreResult recoverIndex(); + TileStoreResult evictFor(const TileKey& key, std::uint32_t new_size); + bool validPngHeader() const; + void failPut(); +}; + +} // namespace TDeck +} // namespace Hardware + +#endif diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileStoreSD.cpp b/lib/tdeck_ui/Hardware/TDeck/MapTileStoreSD.cpp new file mode 100644 index 00000000..5ef9bf47 --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileStoreSD.cpp @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#include "MapTileStoreSD.h" + +#ifdef ARDUINO +#include + +namespace Hardware { +namespace TDeck { + +MapTileStoreSD::MapTileStoreSD() : stream_(), list_root_(), list_zoom_(), list_x_(), writing_(false) {} +MapTileStoreSD::~MapTileStoreSD() { abortWrite(); endRead(); endList(); } + +bool MapTileStoreSD::cardPresentLocked() { return SD.cardType() != CARD_NONE; } + +bool MapTileStoreSD::isAvailable() const { + if (!SDAccess::is_ready() || !SDAccess::acquire_bus(100U)) return false; + const bool present = cardPresentLocked(); + SDAccess::release_bus(); + return present; +} + +TileStoreResult MapTileStoreSD::copyName(const char* source, char* output, std::size_t capacity) { + if ((source == NULL) || (output == NULL)) return TileStoreResult::INVALID_ARGUMENT; + const std::size_t length = std::strlen(source); + if ((length + 1U) > capacity) return TileStoreResult::INDEX_MISMATCH; + std::memcpy(output, source, length + 1U); + return TileStoreResult::OK; +} + +bool MapTileStoreSD::makeParentDirectoriesLocked(const char* name) { + char part[MapTileStore::PATH_CAPACITY] = {}; + const std::size_t length = std::strlen(name); + if (length >= sizeof(part)) return false; + std::memcpy(part, name, length + 1U); + for (std::size_t i = 1U; i < length; ++i) { + if (part[i] == '/') { + part[i] = '\0'; + if (!SD.exists(part) && !SD.mkdir(part)) return false; + part[i] = '/'; + } + } + return true; +} + +TileStoreResult MapTileStoreSD::beginRead(const char* name, std::uint32_t& size) { + if (!SDAccess::is_ready() || !SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + stream_ = SD.open(name, FILE_READ); + if (!stream_) { SDAccess::release_bus(); return TileStoreResult::MISS; } + const std::size_t file_size = stream_.size(); + if (file_size > UINT32_MAX) { stream_.close(); SDAccess::release_bus(); return TileStoreResult::TOO_LARGE; } + size = static_cast(file_size); + writing_ = false; + SDAccess::release_bus(); + return TileStoreResult::OK; +} + +TileStoreResult MapTileStoreSD::readChunk(std::uint8_t* output, std::size_t capacity, std::size_t& count) { + if (!stream_) return TileStoreResult::IO_ERROR; + if (!SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + count = stream_.read(output, capacity); + const bool failed = (count == 0U) && stream_.available(); + SDAccess::release_bus(); + return failed ? TileStoreResult::IO_ERROR : TileStoreResult::OK; +} + +void MapTileStoreSD::endRead() { + if (!stream_ || writing_) return; + if (SDAccess::acquire_bus(500U)) { stream_.close(); SDAccess::release_bus(); } +} + +TileStoreResult MapTileStoreSD::beginWrite(const char* name) { + if (!SDAccess::is_ready() || !SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + if (!makeParentDirectoriesLocked(name)) { SDAccess::release_bus(); return TileStoreResult::IO_ERROR; } + stream_ = SD.open(name, FILE_WRITE); + writing_ = static_cast(stream_); + SDAccess::release_bus(); + return writing_ ? TileStoreResult::OK : TileStoreResult::IO_ERROR; +} + +TileStoreResult MapTileStoreSD::writeChunk(const std::uint8_t* data, std::size_t size, std::size_t& written) { + if (!stream_ || !writing_) return TileStoreResult::IO_ERROR; + if (!SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + written = stream_.write(data, size); + SDAccess::release_bus(); + return (written == size) ? TileStoreResult::OK : TileStoreResult::IO_ERROR; +} + +TileStoreResult MapTileStoreSD::commitWrite() { + if (!stream_ || !writing_) return TileStoreResult::IO_ERROR; + if (!SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + stream_.flush(); + stream_.close(); + writing_ = false; + SDAccess::release_bus(); + return TileStoreResult::OK; +} + +void MapTileStoreSD::abortWrite() { + if (!stream_ || !writing_) return; + if (SDAccess::acquire_bus(500U)) { stream_.close(); writing_ = false; SDAccess::release_bus(); } +} + +TileStoreResult MapTileStoreSD::remove(const char* name) { + if (!SDAccess::is_ready() || !SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + const bool existed = SD.exists(name); + const bool removed = !existed || SD.remove(name); + SDAccess::release_bus(); + return !existed ? TileStoreResult::MISS : (removed ? TileStoreResult::OK : TileStoreResult::IO_ERROR); +} + +TileStoreResult MapTileStoreSD::rename(const char* from, const char* to) { + if (!SDAccess::is_ready() || !SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + if (!SD.exists(from)) { SDAccess::release_bus(); return TileStoreResult::MISS; } + if (SD.exists(to) && !SD.remove(to)) { SDAccess::release_bus(); return TileStoreResult::IO_ERROR; } + const bool renamed = SD.rename(from, to); + SDAccess::release_bus(); + return renamed ? TileStoreResult::OK : TileStoreResult::IO_ERROR; +} + +TileStoreResult MapTileStoreSD::stat(const char* name, std::uint32_t& size) { + if (!SDAccess::is_ready() || !SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + fs::File file = SD.open(name, FILE_READ); + if (!file) { SDAccess::release_bus(); return TileStoreResult::MISS; } + const std::size_t file_size = file.size(); + file.close(); + SDAccess::release_bus(); + if (file_size > UINT32_MAX) return TileStoreResult::TOO_LARGE; + size = static_cast(file_size); + return TileStoreResult::OK; +} + +TileStoreResult MapTileStoreSD::beginList() { + endList(); + if (!SDAccess::is_ready() || !SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + list_root_ = SD.open("/pyxis-map/tiles", FILE_READ); + SDAccess::release_bus(); + return TileStoreResult::OK; // An absent cache directory is an empty cache. +} + +TileStoreResult MapTileStoreSD::nextList(char* name, std::size_t capacity, bool& done) { + done = false; + if (!list_root_) { done = true; return TileStoreResult::OK; } + if (!SDAccess::acquire_bus(500U)) return TileStoreResult::STORAGE_UNAVAILABLE; + if (!cardPresentLocked()) { SDAccess::release_bus(); return TileStoreResult::STORAGE_UNAVAILABLE; } + while (true) { + if (list_x_) { + fs::File item = list_x_.openNextFile(); + if (item) { + const TileStoreResult copied = copyName(item.path(), name, capacity); + item.close(); SDAccess::release_bus(); return copied; + } + list_x_.close(); + } + if (list_zoom_) { + fs::File item = list_zoom_.openNextFile(); + if (item) { + if (item.isDirectory()) { list_x_ = item; continue; } + const TileStoreResult copied = copyName(item.path(), name, capacity); + item.close(); SDAccess::release_bus(); return copied; + } + list_zoom_.close(); + } + fs::File item = list_root_.openNextFile(); + if (item) { + if (item.isDirectory()) { list_zoom_ = item; continue; } + const TileStoreResult copied = copyName(item.path(), name, capacity); + item.close(); SDAccess::release_bus(); return copied; + } + done = true; + SDAccess::release_bus(); + return TileStoreResult::OK; + } +} + +void MapTileStoreSD::endList() { + if (!list_root_ && !list_zoom_ && !list_x_) return; + if (SDAccess::acquire_bus(500U)) { + if (list_x_) list_x_.close(); + if (list_zoom_) list_zoom_.close(); + if (list_root_) list_root_.close(); + SDAccess::release_bus(); + } +} + +} // namespace TDeck +} // namespace Hardware +#endif // ARDUINO diff --git a/lib/tdeck_ui/Hardware/TDeck/MapTileStoreSD.h b/lib/tdeck_ui/Hardware/TDeck/MapTileStoreSD.h new file mode 100644 index 00000000..5a7c750c --- /dev/null +++ b/lib/tdeck_ui/Hardware/TDeck/MapTileStoreSD.h @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef HARDWARE_TDECK_MAP_TILE_STORE_SD_H +#define HARDWARE_TDECK_MAP_TILE_STORE_SD_H + +#include "MapTileStore.h" + +#ifdef ARDUINO +#include "SDAccess.h" +#include +#include +#include + +namespace Hardware { +namespace TDeck { + +/** + * MapTileStorage adapter for the already-mounted SD card owned by SDAccess. + * It never mounts, remounts, or formats media. Each filesystem/chunk action + * takes and releases the shared SPI mutex so display and radio traffic can + * run between chunks. + */ +class MapTileStoreSD : public MapTileStorage { +public: + MapTileStoreSD(); + virtual ~MapTileStoreSD(); + + virtual bool isAvailable() const; + virtual TileStoreResult beginRead(const char* name, std::uint32_t& size); + virtual TileStoreResult readChunk(std::uint8_t* output, std::size_t capacity, std::size_t& count); + virtual void endRead(); + virtual TileStoreResult beginWrite(const char* name); + virtual TileStoreResult writeChunk(const std::uint8_t* data, std::size_t size, std::size_t& written); + virtual TileStoreResult commitWrite(); + virtual void abortWrite(); + virtual TileStoreResult remove(const char* name); + virtual TileStoreResult rename(const char* from, const char* to); + virtual TileStoreResult stat(const char* name, std::uint32_t& size); + virtual TileStoreResult beginList(); + virtual TileStoreResult nextList(char* name, std::size_t capacity, bool& done); + virtual void endList(); + +private: + fs::File stream_; + fs::File list_root_; + fs::File list_zoom_; + fs::File list_x_; + bool writing_; + + static bool cardPresentLocked(); + static TileStoreResult copyName(const char* source, char* output, std::size_t capacity); + static bool makeParentDirectoriesLocked(const char* name); +}; + +} // namespace TDeck +} // namespace Hardware +#endif // ARDUINO +#endif diff --git a/tests/build_scripts/test_map_tile_store_contract.py b/tests/build_scripts/test_map_tile_store_contract.py new file mode 100644 index 00000000..1d09c5bb --- /dev/null +++ b/tests/build_scripts/test_map_tile_store_contract.py @@ -0,0 +1,36 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CORE_H = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileStore.h" +CORE_CPP = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileStore.cpp" +SD_H = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileStoreSD.h" +SD_CPP = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileStoreSD.cpp" + + +def test_sd_adapter_uses_existing_mount_without_begin_or_format(): + source = SD_H.read_text() + SD_CPP.read_text() + assert "SDAccess::is_ready" in source + assert "SDAccess::acquire_bus" in source + assert "SDAccess::release_bus" in source + assert "SD.begin" not in source + assert ".begin(" not in source + assert "format(" not in source + assert "LittleFS" not in source + assert "already-mounted SD" in source + + +def test_portable_core_has_no_dynamic_standard_containers_or_paths_from_callers(): + source = CORE_H.read_text() + CORE_CPP.read_text() + header = CORE_H.read_text() + for forbidden in ("std::vector", "std::map", "std::string", "LittleFS"): + assert forbidden not in source + assert '"/pyxis-map/tiles/' in source + assert "TileKey" in source + assert "beginGet(const char*" not in header + assert "beginPut(const char*" not in header + + +def test_adapter_releases_shared_bus_for_each_chunk(): + source = SD_CPP.read_text() + assert "readChunk" in source and "writeChunk" in source + assert source.count("SDAccess::release_bus()") >= 8 diff --git a/tests/native/test_map_tile_store.cpp b/tests/native/test_map_tile_store.cpp new file mode 100644 index 00000000..6f336de3 --- /dev/null +++ b/tests/native/test_map_tile_store.cpp @@ -0,0 +1,185 @@ +#include "Hardware/TDeck/MapTileStore.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using Hardware::TDeck::MapTileStore; +using Hardware::TDeck::MapTileStorage; +using Hardware::TDeck::TileKey; +using Hardware::TDeck::TileStoreConfig; +using Hardware::TDeck::TileStoreResult; + +namespace { +std::size_t tests_run = 0U; +void fail(const char* e, int line) { std::cerr << "line " << line << ": " << e << '\n'; std::exit(1); } +#define CHECK(e) do { if (!(e)) fail(#e, __LINE__); } while (false) +void beginTest() { ++tests_run; } + +struct File { std::string path; std::vector bytes; }; + +class FakeStorage : public MapTileStorage { +public: + bool available; + bool short_write; + bool fail_remove; + int fail_rename_call; + std::vector files; + std::string open_path; + std::size_t position; + std::size_t list_position; + int rename_calls; + + FakeStorage() : available(true), short_write(false), fail_remove(false), fail_rename_call(0), position(0U), + list_position(0U), rename_calls(0) {} + + int find(const char* path) const { + for (std::size_t i = 0U; i < files.size(); ++i) if (files[i].path == path) return static_cast(i); + return -1; + } + void add(const char* path, const std::vector& bytes) { + int i = find(path); if (i >= 0) files[static_cast(i)].bytes = bytes; + else files.push_back(File{path, bytes}); + } + virtual bool isAvailable() const { return available; } + virtual TileStoreResult beginRead(const char* path, std::uint32_t& size) { + if (!available) return TileStoreResult::STORAGE_UNAVAILABLE; + const int i = find(path); if (i < 0) return TileStoreResult::MISS; + open_path = path; position = 0U; size = static_cast(files[static_cast(i)].bytes.size()); + return TileStoreResult::OK; + } + virtual TileStoreResult readChunk(std::uint8_t* out, std::size_t capacity, std::size_t& count) { + if (!available) return TileStoreResult::STORAGE_UNAVAILABLE; + const int i = find(open_path.c_str()); if (i < 0) return TileStoreResult::IO_ERROR; + const std::vector& b = files[static_cast(i)].bytes; + count = std::min(capacity, b.size() - position); + if (count != 0U) std::memcpy(out, &b[position], count); + position += count; return TileStoreResult::OK; + } + virtual void endRead() { open_path.clear(); } + virtual TileStoreResult beginWrite(const char* path) { + if (!available) return TileStoreResult::STORAGE_UNAVAILABLE; + add(path, std::vector()); open_path = path; return TileStoreResult::OK; + } + virtual TileStoreResult writeChunk(const std::uint8_t* data, std::size_t size, std::size_t& written) { + if (!available) return TileStoreResult::STORAGE_UNAVAILABLE; + const int i = find(open_path.c_str()); if (i < 0) return TileStoreResult::IO_ERROR; + written = (short_write && size != 0U) ? size - 1U : size; + files[static_cast(i)].bytes.insert(files[static_cast(i)].bytes.end(), data, data + written); + return TileStoreResult::OK; + } + virtual TileStoreResult commitWrite() { open_path.clear(); return available ? TileStoreResult::OK : TileStoreResult::STORAGE_UNAVAILABLE; } + virtual void abortWrite() { if (!open_path.empty()) remove(open_path.c_str()); open_path.clear(); } + virtual TileStoreResult remove(const char* path) { + if (fail_remove) return TileStoreResult::IO_ERROR; + const int i = find(path); if (i < 0) return TileStoreResult::MISS; + files.erase(files.begin() + i); return TileStoreResult::OK; + } + virtual TileStoreResult rename(const char* from, const char* to) { + ++rename_calls; if (fail_rename_call == rename_calls) return TileStoreResult::IO_ERROR; + const int i = find(from); if (i < 0) return TileStoreResult::MISS; + remove(to); files[static_cast(i >= find(from) ? find(from) : 0)].path = to; + return TileStoreResult::OK; + } + virtual TileStoreResult stat(const char* path, std::uint32_t& size) { + if (!available) return TileStoreResult::STORAGE_UNAVAILABLE; + const int i = find(path); if (i < 0) return TileStoreResult::MISS; + size = static_cast(files[static_cast(i)].bytes.size()); return TileStoreResult::OK; + } + virtual TileStoreResult beginList() { list_position = 0U; return available ? TileStoreResult::OK : TileStoreResult::STORAGE_UNAVAILABLE; } + virtual TileStoreResult nextList(char* path, std::size_t capacity, bool& done) { + if (!available) return TileStoreResult::STORAGE_UNAVAILABLE; + if (list_position >= files.size()) { done = true; return TileStoreResult::OK; } + done = false; const std::string& p = files[list_position++].path; + if (p.size() + 1U > capacity) return TileStoreResult::INDEX_MISMATCH; + std::memcpy(path, p.c_str(), p.size() + 1U); return TileStoreResult::OK; + } + virtual void endList() {} +}; + +std::vector png(std::size_t size = 40U, std::uint32_t w = 256U, std::uint32_t h = 256U) { + std::vector b(size, 0U); + const std::uint8_t sig[] = {137U,80U,78U,71U,13U,10U,26U,10U}; + if (size >= 24U) { + std::memcpy(&b[0], sig, 8U); b[11] = 13U; b[12]='I'; b[13]='H'; b[14]='D'; b[15]='R'; + b[16]=static_cast(w>>24); b[17]=static_cast(w>>16); b[18]=static_cast(w>>8); b[19]=static_cast(w); + b[20]=static_cast(h>>24); b[21]=static_cast(h>>16); b[22]=static_cast(h>>8); b[23]=static_cast(h); + } + return b; +} +TileStoreConfig config(std::uint16_t entries=3U, std::uint32_t quota=120U, std::uint32_t maximum=80U) { + TileStoreConfig c = {entries, quota, maximum}; return c; +} +TileStoreResult put(MapTileStore& s, const TileKey& k, const std::vector& b, std::size_t split=17U) { + TileStoreResult r=s.beginPut(k); if (r!=TileStoreResult::OK) return r; + for (std::size_t p=0U;p bad=png(); bad[0]=0U; CHECK(put(s,TileKey{0U,0U,0U},bad)==TileStoreResult::INVALID_PNG); + CHECK(put(s,TileKey{0U,0U,0U},png(20U))==TileStoreResult::INVALID_PNG); + CHECK(put(s,TileKey{0U,0U,0U},png(40U,255U,256U))==TileStoreResult::INVALID_PNG); + CHECK(put(s,TileKey{0U,0U,0U},png(81U))==TileStoreResult::TOO_LARGE); +} +void testShortWriteAbortsTemp() { beginTest(); FakeStorage fs; MapTileStore s(fs,config()); CHECK(s.initialize()==TileStoreResult::OK); fs.short_write=true; + CHECK(put(s,TileKey{0U,0U,0U},png())==TileStoreResult::IO_ERROR); CHECK(fs.files.empty()); +} +void testExactQuotaAndLruEviction() { beginTest(); FakeStorage fs; MapTileStore s(fs,config(3U,80U,80U)); CHECK(s.initialize()==TileStoreResult::OK); + CHECK(put(s,TileKey{1U,0U,0U},png())==TileStoreResult::OK); CHECK(put(s,TileKey{1U,1U,0U},png())==TileStoreResult::OK); + std::uint32_t n=0U; CHECK(s.beginGet(TileKey{1U,0U,0U},n)==TileStoreResult::OK); s.endGet(); + CHECK(put(s,TileKey{1U,0U,1U},png())==TileStoreResult::OK); CHECK(s.beginGet(TileKey{1U,1U,0U},n)==TileStoreResult::MISS); CHECK(s.totalBytes()==80U); +} +void testDuplicateAtomicReplacement() { beginTest(); FakeStorage fs; MapTileStore s(fs,config(2U,100U,80U)); CHECK(s.initialize()==TileStoreResult::OK); TileKey k={0U,0U,0U}; + CHECK(put(s,k,png(40U))==TileStoreResult::OK); CHECK(put(s,k,png(60U))==TileStoreResult::OK); CHECK(s.entryCount()==1U); CHECK(s.totalBytes()==60U); drain(s,k,60U); +} +void testInterruptedFilesRecover() { beginTest(); FakeStorage fs; fs.add("/pyxis-map/tiles/1/0/0.png.bak",png(40U)); fs.add("/pyxis-map/tiles/1/1/0.png.tmp",png(40U)); + MapTileStore s(fs,config()); CHECK(s.initialize()==TileStoreResult::OK); CHECK(fs.find("/pyxis-map/tiles/1/0/0.png")>=0); CHECK(fs.find("/pyxis-map/tiles/1/1/0.png.tmp")<0); CHECK(s.entryCount()==1U); +} +void testLiveWinsRecovery() { beginTest(); FakeStorage fs; fs.add("/pyxis-map/tiles/0/0/0.png",png(40U)); fs.add("/pyxis-map/tiles/0/0/0.png.bak",png(60U)); fs.add("/pyxis-map/tiles/0/0/0.png.tmp",png(50U)); + MapTileStore s(fs,config()); CHECK(s.initialize()==TileStoreResult::OK); CHECK(fs.files.size()==1U); CHECK(s.totalBytes()==40U); +} +void testCorruptLiveRecoversValidBackup() { beginTest(); FakeStorage fs; std::vector bad=png(40U); bad[0]=0U; + fs.add("/pyxis-map/tiles/0/0/0.png",bad); fs.add("/pyxis-map/tiles/0/0/0.png.bak",png(60U)); MapTileStore s(fs,config()); + CHECK(s.initialize()==TileStoreResult::OK); CHECK(s.entryCount()==1U); CHECK(s.totalBytes()==60U); drain(s,TileKey{0U,0U,0U},60U); +} +void testCorruptLiveWithoutBackupIsRemoved() { beginTest(); FakeStorage fs; std::vector bad=png(40U); bad[0]=0U; + fs.add("/pyxis-map/tiles/0/0/0.png",bad); MapTileStore s(fs,config()); CHECK(s.initialize()==TileStoreResult::OK); + CHECK(s.entryCount()==0U); CHECK(fs.files.empty()); +} +void testStaleTempRemovalFailureAbortsPut() { beginTest(); FakeStorage fs; MapTileStore s(fs,config()); CHECK(s.initialize()==TileStoreResult::OK); + fs.add("/pyxis-map/tiles/0/0/0.png.tmp",png()); fs.fail_remove=true; + CHECK(s.beginPut(TileKey{0U,0U,0U})==TileStoreResult::IO_ERROR); CHECK(fs.find("/pyxis-map/tiles/0/0/0.png.tmp")>=0); +} +void testRecoveryRejectsMalformedAndExhaustion() { beginTest(); FakeStorage fs; fs.add("/pyxis-map/tiles/0/0/../evil.png",png()); MapTileStore a(fs,config()); CHECK(a.initialize()==TileStoreResult::INDEX_MISMATCH); + FakeStorage fs2; fs2.add("/pyxis-map/tiles/1/0/0.png",png()); fs2.add("/pyxis-map/tiles/1/1/0.png",png()); MapTileStore b(fs2,config(1U,100U,80U)); CHECK(b.initialize()==TileStoreResult::INDEX_FULL); +} +void testRecoveryQuotaFailsClosed() { beginTest(); FakeStorage fs; fs.add("/pyxis-map/tiles/1/0/0.png",png(60U)); fs.add("/pyxis-map/tiles/1/1/0.png",png(60U)); MapTileStore s(fs,config(3U,100U,80U)); CHECK(s.initialize()==TileStoreResult::QUOTA_EXCEEDED); } +void testRenameFailureRestoresDuplicate() { beginTest(); FakeStorage fs; MapTileStore s(fs,config()); CHECK(s.initialize()==TileStoreResult::OK); TileKey k={0U,0U,0U}; CHECK(put(s,k,png())==TileStoreResult::OK); + fs.fail_rename_call=fs.rename_calls+2; CHECK(put(s,k,png(50U))==TileStoreResult::IO_ERROR); drain(s,k,40U); +} +void testDeterministicStress() { beginTest(); FakeStorage fs; MapTileStore s(fs,config(3U,120U,80U)); CHECK(s.initialize()==TileStoreResult::OK); CHECK(put(s,TileKey{2U,0U,0U},png())==TileStoreResult::OK); + std::uint32_t size=0U; for(std::uint32_t i=0U;i<100000U;++i) { const TileKey k={2U,i&3U,(i>>2)&3U}; TileStoreResult r=s.beginGet(k,size); CHECK(r==TileStoreResult::OK||r==TileStoreResult::MISS); if(r==TileStoreResult::OK)s.endGet(); } +} +} +int main() { testKeyAndCanonicalPath(); testMissHitAndRemoval(); testMalformedPngs(); testShortWriteAbortsTemp(); testExactQuotaAndLruEviction(); testDuplicateAtomicReplacement(); testInterruptedFilesRecover(); testLiveWinsRecovery(); testCorruptLiveRecoversValidBackup(); testCorruptLiveWithoutBackupIsRemoved(); testStaleTempRemovalFailureAbortsPut(); testRecoveryRejectsMalformedAndExhaustion(); testRecoveryQuotaFailsClosed(); testRenameFailureRestoresDuplicate(); testDeterministicStress(); std::cout<<"map tile store: "< None: + binary = tmp_path / "test_map_tile_store" + command = [find_cxx(), "-std=c++11", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-Wconversion", "-Wsign-conversion", f"-I{ROOT / 'lib/tdeck_ui'}", + str(TEST_SOURCE), str(PRODUCTION_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=60) + 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 = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60, env=env) + assert ran.returncode == 0, ran.stdout + ran.stderr + assert ran.stdout == "map tile store: 15 tests passed\n"