mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-08-28 13:34:17 +00:00
feat: add bounded offline map tile store
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
// Copyright (c) 2026 Pyxis contributors
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "MapTileStore.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
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<std::uint32_t>(*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<unsigned>(key.zoom), static_cast<unsigned long>(key.x),
|
||||
static_cast<unsigned long>(key.y));
|
||||
if ((count < 0) || (static_cast<std::size_t>(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<std::uint64_t>(*p - '0');
|
||||
if (y > UINT32_MAX) return TileStoreResult::INDEX_MISMATCH;
|
||||
}
|
||||
key.zoom = (zoom <= UINT8_MAX) ? static_cast<std::uint8_t>(zoom) : UINT8_MAX;
|
||||
key.y = static_cast<std::uint32_t>(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<int>(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<int>(entry_count_++);
|
||||
entries_[static_cast<std::size_t>(index)] = Entry{key, 0U, 0U, 0U};
|
||||
}
|
||||
Entry& entry = entries_[static_cast<std::size_t>(index)];
|
||||
if ((entry.recovery_flags & flag) != 0U) { storage_.endList(); return TileStoreResult::INDEX_MISMATCH; }
|
||||
entry.recovery_flags = static_cast<std::uint8_t>(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<std::size_t>(index)].size) { storage_.endRead(); return TileStoreResult::INDEX_MISMATCH; }
|
||||
entries_[static_cast<std::size_t>(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<std::uint32_t>(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<std::uint32_t>(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<std::size_t>(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<std::size_t>(victim)].sequence)) victim = static_cast<int>(i);
|
||||
}
|
||||
if (victim < 0) return (prospective > config_.byte_quota) ? TileStoreResult::QUOTA_EXCEEDED : TileStoreResult::INDEX_FULL;
|
||||
char name[PATH_CAPACITY] = {};
|
||||
canonicalPath(entries_[static_cast<std::size_t>(victim)].key, name, sizeof(name));
|
||||
TileStoreResult result = storage_.remove(name);
|
||||
if ((result != TileStoreResult::OK) && (result != TileStoreResult::MISS)) return result;
|
||||
prospective -= entries_[static_cast<std::size_t>(victim)].size;
|
||||
removeEntry(static_cast<std::uint16_t>(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<std::size_t>(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
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,198 @@
|
||||
// Copyright (c) 2026 Pyxis contributors
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#include "MapTileStoreSD.h"
|
||||
|
||||
#ifdef ARDUINO
|
||||
#include <cstring>
|
||||
|
||||
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<std::uint32_t>(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<bool>(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<std::uint32_t>(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
|
||||
@@ -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 <Arduino.h>
|
||||
#include <FS.h>
|
||||
#include <SD.h>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,185 @@
|
||||
#include "Hardware/TDeck/MapTileStore.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::uint8_t> bytes; };
|
||||
|
||||
class FakeStorage : public MapTileStorage {
|
||||
public:
|
||||
bool available;
|
||||
bool short_write;
|
||||
bool fail_remove;
|
||||
int fail_rename_call;
|
||||
std::vector<File> 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<int>(i);
|
||||
return -1;
|
||||
}
|
||||
void add(const char* path, const std::vector<std::uint8_t>& bytes) {
|
||||
int i = find(path); if (i >= 0) files[static_cast<std::size_t>(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<std::uint32_t>(files[static_cast<std::size_t>(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<std::uint8_t>& b = files[static_cast<std::size_t>(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<std::uint8_t>()); 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<std::size_t>(i)].bytes.insert(files[static_cast<std::size_t>(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<std::size_t>(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<std::uint32_t>(files[static_cast<std::size_t>(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<std::uint8_t> png(std::size_t size = 40U, std::uint32_t w = 256U, std::uint32_t h = 256U) {
|
||||
std::vector<std::uint8_t> 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<std::uint8_t>(w>>24); b[17]=static_cast<std::uint8_t>(w>>16); b[18]=static_cast<std::uint8_t>(w>>8); b[19]=static_cast<std::uint8_t>(w);
|
||||
b[20]=static_cast<std::uint8_t>(h>>24); b[21]=static_cast<std::uint8_t>(h>>16); b[22]=static_cast<std::uint8_t>(h>>8); b[23]=static_cast<std::uint8_t>(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<std::uint8_t>& b, std::size_t split=17U) {
|
||||
TileStoreResult r=s.beginPut(k); if (r!=TileStoreResult::OK) return r;
|
||||
for (std::size_t p=0U;p<b.size();) { const std::size_t n=std::min(split,b.size()-p); r=s.writePutChunk(&b[p],n); if(r!=TileStoreResult::OK)return r; p+=n; }
|
||||
return s.finishPut();
|
||||
}
|
||||
void drain(MapTileStore& s, const TileKey& k, std::size_t expected) {
|
||||
std::uint32_t size=0U; CHECK(s.beginGet(k,size)==TileStoreResult::OK); CHECK(size==expected);
|
||||
std::uint8_t b[13]; std::size_t total=0U,n=0U; do { CHECK(s.readGetChunk(b,sizeof(b),n)==TileStoreResult::OK); total+=n; } while(n!=0U);
|
||||
CHECK(total==expected); s.endGet();
|
||||
}
|
||||
|
||||
void testKeyAndCanonicalPath() { beginTest(); FakeStorage fs; MapTileStore s(fs,config()); CHECK(s.initialize()==TileStoreResult::OK);
|
||||
char p[MapTileStore::PATH_CAPACITY]; CHECK(MapTileStore::canonicalPath(TileKey{22U,4194303U,4194303U},p,sizeof(p))==TileStoreResult::OK);
|
||||
CHECK(std::string(p)=="/pyxis-map/tiles/22/4194303/4194303.png");
|
||||
CHECK(MapTileStore::canonicalPath(TileKey{23U,0U,0U},p,sizeof(p))==TileStoreResult::INVALID_KEY);
|
||||
CHECK(MapTileStore::canonicalPath(TileKey{1U,2U,0U},p,sizeof(p))==TileStoreResult::INVALID_KEY);
|
||||
}
|
||||
void testMissHitAndRemoval() { beginTest(); FakeStorage fs; MapTileStore s(fs,config()); CHECK(s.initialize()==TileStoreResult::OK);
|
||||
std::uint32_t z=99U; CHECK(s.beginGet(TileKey{0U,0U,0U},z)==TileStoreResult::MISS); CHECK(put(s,TileKey{0U,0U,0U},png())==TileStoreResult::OK); drain(s,TileKey{0U,0U,0U},40U);
|
||||
fs.available=false; CHECK(s.beginGet(TileKey{0U,0U,0U},z)==TileStoreResult::STORAGE_UNAVAILABLE);
|
||||
}
|
||||
void testMalformedPngs() { beginTest(); FakeStorage fs; MapTileStore s(fs,config()); CHECK(s.initialize()==TileStoreResult::OK);
|
||||
std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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: "<<tests_run<<" tests passed\n"; }
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
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.cpp"
|
||||
PRODUCTION_SOURCE = ROOT / "lib/tdeck_ui/Hardware/TDeck/MapTileStore.cpp"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sanitize", [False, True], ids=["strict-cxx11", "asan-ubsan"])
|
||||
def test_bounded_map_tile_store(tmp_path: Path, sanitize: bool) -> 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"
|
||||
Reference in New Issue
Block a user