Close public cache archival and offline removal flows

This commit is contained in:
liu weikai committed 2026-09-30 23:28:08 +08:00
1 parent 0b90f47be3
commit 69167f211f
15 files changed
+722 -36

No files matched your search

BIN
View File
Binary file not shown.
+25
View File
@@ -172,3 +172,28 @@ Deletion, rebinding, and recovery retain full reference validation.
Read faults can occur during journal readback after redundant preflight reads
are removed. Fault coverage requires unchanged committed data and rejection of
further writes until recovery; an unconfirmed journal may remain on storage.
## Public withdrawal and local removal
On a verified cache detail, `Archive public cache` is available only when the
record author matches the current device identity. Confirming creates a signed
successor with the same cache ID, the selected revision hash as its parent, and
state `2`. An existing authored draft is retained with the archived state.
The UI remains `Awaiting directory confirmation...` until the matching accepted
directory result is committed. An offline delete never substitutes for this
public operation. The same action is available on an owned Downloaded detail.
Directories hide archived heads from ordinary queries, including continuation
pages created before archival. They reject ordinary reads of a former active
revision with `410 cache_archived`, including retries of previously successful
GET requests. Explicit signed tombstone reads and registered directory peers
retain access for replication. Publication receipts and synchronization history
remain durable; already downloaded copies cannot be remotely erased.
`Delete local copy` only clears the selected installed head and advances its
installation generation in a journaled transaction. This removes the offline
entry from Downloaded and Map and prevents older installation tasks from
restoring it. Immutable historical objects and GPX files remain on SD; this is
not a promise of immediate physical file reclamation. Local map projections
invalidate on committed saves, removals and installation changes even when the
viewport stays unchanged. Work and payload buffers are allocated in PSRAM.
@@ -225,6 +225,7 @@ struct DetailText
{
ui::geocaching::DetailStatus status = ui::geocaching::DetailStatus::Pending;
std::string description, hint, error;
bool can_archive = false;
};
DetailText detailText(const ui::geocaching::Item& item)
{
@@ -237,6 +238,7 @@ DetailText detailText(const ui::geocaching::Item& item)
out.status = value.status;
out.description = value.description;
out.hint = value.hint;
out.can_archive = value.can_archive;
out.error = value.error; },
&result);
require(!test::ui_io, "detail UI callback touched SD");
@@ -577,23 +579,51 @@ int main(int argc, char** argv)
// intentionally retains an active revision-2 draft for its map assertions.
const auto active_fixture = test::files;
const auto active_directories = test::directories;
draft.generation = item.edit_generation;
draft.state = 2;
require(test::source->saveDraft(draft), "archive draft was not queued");
until([&]
{ return test::source->draftSaveStatus(draft.id, draft.generation) == ui::geocaching::DraftSaveStatus::Saved; },
"archive draft was not saved");
until([&]
{
const auto view = snapshot(Section::Published);
if (!test::source->item(Section::Published, 0, view.generation, item)) return false;
draft_generation = item.edit_generation;
return item.state == 2 && !item.publication_confirmed && test::source->publicationAuthor(draft.id, draft_generation, author, &from, &to); },
"archive preview not ready or reused previous confirmation");
require(from == 2 && to == 3 && test::source->publishDraft(draft.id, draft_generation, author, to), "archive publication was rejected");
geocaching::protocol::CmpReader sent({router.sent_bytes.data(), router.sent_bytes.size()});
size_t sent_count = 0;
uint64_t sent_value = 0;
geocaching::ByteView sent_id;
require(sent.array(sent_count, 6) && sent.unsignedInteger(sent_value) && sent.unsignedInteger(sent_value) &&
sent.unsignedInteger(sent_value) && sent.binary(sent_id, 16),
"archive source request invalid");
geocaching::RequestId prior_request;
std::memcpy(prior_request.bytes.data(), sent_id.data, 16);
geocaching::protocol::PublishRequestView published;
geocaching::protocol::VerifiedRecordView public_record;
std::vector<uint8_t> detail_scratch(8192), own_detail_response(8192);
platform::esp::common::EspGeocachingCrypto public_crypto;
require(geocaching::protocol::decodePublishRequest({router.sent_bytes.data(), router.sent_bytes.size()}, prior_request, published) &&
geocaching::protocol::verifyGeocache(published.signed_cache, public_crypto, detail_scratch.data(), detail_scratch.size(), public_record) == geocaching::protocol::VerificationResult::Valid,
"archive base not verified");
ui::geocaching::Item public_item;
public_item.id = public_record.id.bytes;
public_item.revision_hash = public_record.hash.bytes;
geocaching::protocol::CmpReader signed_reader(published.signed_cache);
geocaching::ByteView archive_record_bytes, signature;
require(signed_reader.array(sent_count, 2) && signed_reader.binary(archive_record_bytes, 4096) && signed_reader.binary(signature, 64), "archive signed pair invalid");
geocaching::protocol::CmpWriter detail_writer(own_detail_response.data(), own_detail_response.size());
require(detail_writer.array(6) && detail_writer.unsignedInteger(1) && detail_writer.unsignedInteger(1) && detail_writer.unsignedInteger(3) &&
detail_writer.binary({prior_request.bytes.data(), 16}) && detail_writer.unsignedInteger(200) && detail_writer.array(3) &&
detail_writer.array(2) && detail_writer.binary(archive_record_bytes) && detail_writer.binary(signature) && detail_writer.unsignedInteger(1) && detail_writer.unsignedInteger(0),
"archive detail response failed");
own_detail_response.resize(detail_writer.size());
test::source->open(public_item, 0);
until([&]
{ return router.sends == 7; },
"archive detail not requested");
reply(router, own_detail_response, 3);
until([&]
{ return detailText(public_item).status == ui::geocaching::DetailStatus::Ready; },
"archive detail not verified");
require(detailText(public_item).can_archive, "owner cannot manage verified public detail");
auto stale_archive = public_item.revision_hash;
stale_archive[0] ^= 1;
require(!test::source->archiveCache(public_item.id, stale_archive), "archive accepted a stale detail");
require(test::source->archiveCache(public_item.id, public_item.revision_hash), "public detail archive not queued");
until([&]
{ return router.sends == 8; },
"archive request not dispatched");
require(test::source->archiveStatus(public_item.id) == ui::geocaching::DraftSaveStatus::Pending, "archive confirmed without directory reply");
reply(router, publicationReply(router, 3), 1);
until([&]
{
@@ -601,6 +631,7 @@ int main(int argc, char** argv)
return test::source->item(Section::Published, 0, view.generation, item) && item.state == 2 &&
item.publication_confirmed && item.publication_revision == 3; },
"archive directory confirmation not reflected in local record");
require(test::source->archiveStatus(public_item.id) == ui::geocaching::DraftSaveStatus::Saved, "archive detail did not show confirmation");
auto archive_overlay = std::make_unique<ui::geocaching::LocalMapOverlay>();
auto archive_map = std::make_unique<ui::map::MapOverlaySnapshot>();
until([&]
@@ -611,6 +642,22 @@ int main(int argc, char** argv)
return archive_overlay->finished(); },
"archive map did not finish local metadata");
require(archive_map->item_count == 1, "archived local cache remained on Map or hid the downloaded cache");
test::source->requestWindow(Section::Downloaded, 0, 4);
ui::geocaching::Item offline_copy;
until([&]
{ const auto view = snapshot(Section::Downloaded); return view.ready && test::source->item(Section::Downloaded, 0, view.generation, offline_copy); },
"offline removal row missing");
require(test::source->removeDownloaded(offline_copy), "offline removal not queued");
until([&]
{ return test::source->downloadedRemovalStatus(offline_copy.id, offline_copy.revision_hash) != ui::geocaching::DraftSaveStatus::Pending; },
"offline removal stuck");
require(test::source->downloadedRemovalStatus(offline_copy.id, offline_copy.revision_hash) == ui::geocaching::DraftSaveStatus::Saved, "offline removal failed");
until([&]
{ const auto view = snapshot(Section::Downloaded); return view.ready && !view.count; },
"deleted offline copy remained in Downloaded");
until([&]
{ archive_overlay->update(*test::source, 31, 121, 15); archive_map->item_count = 0; archive_overlay->append(*archive_map); return archive_overlay->finished() && !archive_map->item_count; },
"same-viewport map retained deleted offline marker");
test::source->activate(false);
until([&]
{ return !router.service; },
@@ -3,6 +3,7 @@
#include "platform/esp/arduino_common/geocaching/indexed_dispatch_store.h"
#include "platform/esp/arduino_common/geocaching/sd_index_initialize.h"
#include "platform/esp/arduino_common/geocaching/sd_indexed_commit.h"
#include "platform/esp/arduino_common/geocaching/sd_indexed_remove_saved.h"
#include "platform/esp/arduino_common/geocaching/sd_indexed_stop_task.h"
#include "runtime_environment.h"
#include <cstdio>
@@ -171,6 +172,44 @@ int main()
copy = 1 - copy;
}
readSend(0, true);
{
// Removal selects the exact current revision and advances the durable
// generation, without deleting the head's rollback protection.
std::array<uint8_t, 32> cache{}, hash{}, wrong{};
cache.fill(0x41);
hash.fill(0x42);
wrong.fill(0x43);
gc::CacheHeadView head;
head.current_hash = {hash.data(), hash.size()};
head.install_generation = 7;
head.highest_seen_revision = 9;
std::array<uint8_t, 64> value{};
size_t size = 0;
require(gc::encodeCacheHead({cache.data(), cache.size()}, head, value.data(), value.size(), size), "encode removal fixture head");
gc::MutationView row{2, {cache.data(), cache.size()}, {value.data(), size}, false};
sd::SdIndexedCommit commit(volume);
require(commit.begin(root, copy, &row, 1, frame.data(), frame.size(), roots[1 - copy]) &&
pump(commit, sd::IndexedCommitStep::Working) == sd::IndexedCommitStep::Verified && commit.committed(root),
"commit removal fixture head");
copy = 1 - copy;
const auto unchanged = test::files;
sd::SdIndexedRemoveSaved stale(volume);
require(stale.begin(root, copy, cache, wrong, frame.data(), frame.size(), roots[1 - copy]) &&
pump(stale, sd::IndexedCommitStep::Working) == sd::IndexedCommitStep::Invalid && test::files == unchanged,
"stale detail deleted another revision");
sd::SdIndexedRemoveSaved remove(volume);
require(remove.begin(root, copy, cache, hash, frame.data(), frame.size(), roots[1 - copy]) &&
pump(remove, sd::IndexedCommitStep::Working) == sd::IndexedCommitStep::Verified && remove.committed(root),
"remove offline head");
copy = 1 - copy;
sd::SdIndexGet read(volume);
require(read.begin(root, 2, {cache.data(), cache.size()}, frame.data(), frame.size()) &&
pump(read, sd::IndexGetStep::Working) == sd::IndexGetStep::Ready &&
gc::decodeCacheHead({cache.data(), cache.size()}, read.value(), head),
"read removed head");
require(!head.current_hash.size && head.install_generation == 8 && head.highest_seen_revision == 9,
"removal lost generation or rollback protection");
}
std::printf("Send preparation read bytes: reused=%llu fallback=%llu\n", static_cast<unsigned long long>(reused), static_cast<unsigned long long>(small));
std::printf("Reference validator size: %u bytes; eviction and overlay mismatch checks passed\n", static_cast<unsigned>(sizeof(sd::SdIndexReferences)));
return 0;
@@ -25,6 +25,14 @@ struct LocalSource : Source
size_t offset = 0;
bool ready = false, busy = false;
unsigned requests = 0;
uint64_t revision = 0;
bool revision_available = true;
bool localMapRevision(uint64_t& out) override
{
if (!revision_available) return false;
out = revision;
return true;
}
void snapshot(Section part, Snapshot& out) override
{
assert(part != Section::Discover);
@@ -93,6 +101,26 @@ int main()
auto result = std::make_unique<ui::map::MapOverlaySnapshot>();
immediate->append(*result);
assert(result->item_count == 2); // No extra timer tick for cached drafts.
const auto requests = available.requests;
immediate->update(available, 0, 102, 10);
assert(available.requests == requests); // An unchanged view stays cached.
available.drafts[0].state = 2;
++available.revision;
available.revision_available = false;
immediate->update(available, 0, 102, 10);
assert(immediate->markerCount() == 2); // Busy source is not deletion.
available.revision_available = true;
immediate->update(available, 0, 102, 10);
assert(immediate->markerCount() == 1); // Archive refreshes the same viewport.
available.drafts[0].state = 0;
available.drafts[0].latitude_e7 = 123000000;
++available.revision;
immediate->update(available, 0, 102, 10);
assert(immediate->markerCount() == 2);
available.drafts.clear();
++available.revision;
immediate->update(available, 0, 102, 10);
assert(immediate->markerCount() == 1); // Local deletion refreshes too.
}
LocalSource source;
source.drafts.push_back(marker(1, true, false));
@@ -76,6 +76,7 @@ struct DetailView
{
DetailStatus status = DetailStatus::Pending;
std::string_view description, hint, error;
bool can_archive = false;
};
class Source
{
@@ -90,6 +91,9 @@ class Source
// UI-thread notification only. A backend may asynchronously prepare this
// bounded window; item() returns false until its projection is ready.
virtual void requestWindow(Section, std::size_t, std::size_t) {}
// Nonblocking local marker revision; excludes network and loading status.
// False means unavailable this tick, so retain the last projection.
virtual bool localMapRevision(std::uint64_t&) { return false; }
// Map needs position metadata only, without publication history auditing.
virtual void requestMapWindow(Section section, std::size_t offset, std::size_t count)
{
@@ -106,6 +110,11 @@ class Source
virtual void closeDetail() {}
virtual bool loadMore() { return false; }
virtual bool download(const Item&, std::uint64_t) { return false; }
// Removes the selected offline copy, never its public publication.
virtual bool removeDownloaded(const Item&) { return false; }
virtual bool archiveCache(const std::array<uint8_t, 32>&, const std::array<uint8_t, 32>&) { return false; }
virtual DraftSaveStatus archiveStatus(const std::array<uint8_t, 32>&) { return DraftSaveStatus::Failed; }
virtual DraftSaveStatus downloadedRemovalStatus(const std::array<uint8_t, 32>&, const std::array<uint8_t, 32>&) { return DraftSaveStatus::Failed; }
// UI-thread call: enqueue/poll without I/O. Pending never calls or retains
// the sink/context. Ready calls it synchronously with borrowed text; the
// sink consumes it immediately and must not reenter Source.
@@ -19,15 +19,18 @@ class LocalMapOverlay
void update(Source& source, double latitude, double longitude, uint8_t zoom)
{
const double span = std::ldexp(360.0, -std::min<unsigned>(zoom, 22));
uint64_t revision = revision_;
const bool revision_ready = source.localMapRevision(revision);
// Finish the current bounded scan before reselecting for a new viewport.
// GPS movement and zoom updates must not cancel every pending page.
if (!started_ || (finished_ && (zoom != zoom_ || std::abs(latitude - latitude_) > span / 4 ||
if (!started_ || (finished_ && ((revision_ready && revision != revision_) || zoom != zoom_ || std::abs(latitude - latitude_) > span / 4 ||
std::abs(longitudeDelta(longitude, longitude_)) > span / 4)))
{
started_ = true;
latitude_ = latitude;
longitude_ = longitude;
zoom_ = zoom;
revision_ = revision;
section_ = Section::Downloaded;
offset_ = count_ = 0;
finished_ = truncated_ = false;
@@ -125,6 +128,7 @@ class LocalMapOverlay
}
std::array<Entry, map::MapOverlaySnapshot::kMaxItems> entries_{};
size_t offset_ = 0, count_ = 0;
uint64_t revision_ = 0;
double latitude_ = 0, longitude_ = 0;
uint8_t zoom_ = 0;
Section section_ = Section::Published;
@@ -60,6 +60,9 @@ struct PageState
uint8_t detail_status = 255;
std::array<uint8_t, 32> detail_id{}, detail_hash{};
bool valid = false, details = false;
bool detail_delete_confirmation = false, detail_deleting = false;
lv_obj_t* detail_archive = nullptr;
bool archive_confirmation = false, detail_archiving = false;
};
static_assert(sizeof(PageState) <= 640, "Page state must not contain an entire result list");
::ui::geocaching::Source* source = nullptr;
@@ -287,6 +290,7 @@ void refreshDetailText()
const auto status = static_cast<uint8_t>(view.status);
if (p.detail_status == status) return;
p.detail_status = status;
if (p.detail_archive && view.can_archive) lv_obj_remove_flag(p.detail_archive, LV_OBJ_FLAG_HIDDEN);
auto* description = lv_obj_get_child(lv_obj_get_child(p.list, 1), 1);
auto* hint_card = lv_obj_get_child(p.list, 2);
auto* hint = lv_obj_get_child(hint_card, 1);
@@ -379,6 +383,30 @@ void refreshView()
if (page->details)
{
if (!source) return;
if (page->detail_archiving)
{
const auto result = source->archiveStatus(page->detail_id);
if (result == ::ui::geocaching::DraftSaveStatus::Pending) return;
page->detail_archiving = false;
lv_label_set_text(lv_obj_get_child(page->detail_archive, 0), result == ::ui::geocaching::DraftSaveStatus::Saved ? "Archived; directory confirmed" : "Archive unconfirmed; retry");
setEnabled(page->detail_archive, result != ::ui::geocaching::DraftSaveStatus::Saved);
return;
}
if (page->detail_deleting)
{
const auto result = source->downloadedRemovalStatus(page->detail_id, page->detail_hash);
if (result == ::ui::geocaching::DraftSaveStatus::Pending) return;
page->detail_deleting = false;
if (result == ::ui::geocaching::DraftSaveStatus::Saved)
{
closeDetails();
return;
}
auto* action = lv_obj_get_child(page->list, 4);
setEnabled(action, true);
lv_label_set_text(lv_obj_get_child(action, 0), "Delete failed; retry");
return;
}
refreshDetailText();
::ui::geocaching::Snapshot current;
source->snapshot(page->section, current);
@@ -504,6 +532,9 @@ void closeDetails()
if (!page) return;
if (page->details && source) source->closeDetail();
page->details = false;
page->detail_delete_confirmation = page->detail_deleting = false;
page->detail_archive = nullptr;
page->archive_confirmation = page->detail_archiving = false;
page->valid = false;
lv_obj_remove_flag(lv_obj_get_parent(page->tabs[0]), LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(page->status, LV_OBJ_FLAG_HIDDEN);
@@ -567,6 +598,8 @@ void showDetails(const ::ui::geocaching::Item& item, size_t index)
p.detail_generation = p.snapshot.generation;
p.detail_id = item.id;
p.detail_hash = item.revision_hash;
p.detail_delete_confirmation = p.detail_deleting = false;
p.archive_confirmation = p.detail_archiving = false;
lv_obj_clean(p.list);
p.rows.fill(nullptr);
p.row_count = 0;
@@ -592,6 +625,70 @@ void showDetails(const ::ui::geocaching::Item& item, size_t index)
auto* metadata = detailBody(detailCard(p.list, "LOCATION & DETAILS"), item.detail.data());
lv_obj_set_style_text_font(metadata, ::ui::page_profile::resolve_caption_font(), 0);
lv_obj_set_style_text_color(metadata, lv_color_hex(styles::kTextMuted), 0);
if (item.downloaded)
{
auto* remove = lv_button_create(p.list);
lv_obj_set_width(remove, LV_PCT(100));
lv_obj_set_height(remove, LV_SIZE_CONTENT);
auto* label = lv_label_create(remove);
lv_label_set_text(label, "Delete local copy");
lv_obj_set_width(label, LV_PCT(100));
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
if (p.group) lv_group_add_obj(p.group, remove);
lv_obj_add_event_cb(
remove, [](lv_event_t* event)
{
if (!page || !page->details || !source || page->detail_deleting) return;
auto* button = static_cast<lv_obj_t*>(lv_event_get_target(event));
if (!page->detail_delete_confirmation)
{
page->detail_delete_confirmation = true;
lv_label_set_text(lv_obj_get_child(button, 0), "Confirm local delete");
return;
}
::ui::geocaching::Item selected;
selected.id = page->detail_id;
selected.revision_hash = page->detail_hash;
selected.downloaded = true;
if (!source->removeDownloaded(selected))
{
lv_label_set_text(lv_obj_get_child(button, 0), "Busy; retry local delete");
return;
}
page->detail_deleting = true;
lv_label_set_text(lv_obj_get_child(button, 0), "Deleting local copy...");
setEnabled(button, false);
setEnabled(page->next, false); },
LV_EVENT_CLICKED, nullptr);
}
p.detail_archive = lv_button_create(p.list);
lv_obj_set_width(p.detail_archive, LV_PCT(100));
lv_obj_set_height(p.detail_archive, LV_SIZE_CONTENT);
auto* archive_label = lv_label_create(p.detail_archive);
lv_label_set_text(archive_label, "Archive public cache");
lv_obj_set_width(archive_label, LV_PCT(100));
lv_obj_set_style_text_align(archive_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_add_flag(p.detail_archive, LV_OBJ_FLAG_HIDDEN);
if (p.group) lv_group_add_obj(p.group, p.detail_archive);
lv_obj_add_event_cb(
p.detail_archive, [](lv_event_t*)
{
if (!page || !source || page->detail_archiving || page->detail_deleting) return;
if (!page->archive_confirmation)
{
page->archive_confirmation = true;
lv_label_set_text(lv_obj_get_child(page->detail_archive, 0), "Confirm public archive");
return;
}
if (!source->archiveCache(page->detail_id, page->detail_hash))
{
lv_label_set_text(lv_obj_get_child(page->detail_archive, 0), "Archive unavailable; retry");
return;
}
page->detail_archiving = true;
setEnabled(page->detail_archive, false);
lv_label_set_text(lv_obj_get_child(page->detail_archive, 0), "Awaiting directory confirmation..."); },
LV_EVENT_CLICKED, nullptr);
refreshDetailText();
lv_obj_scroll_to_y(p.list, 0, LV_ANIM_OFF);
lv_label_set_text(p.range, "Scroll " LV_SYMBOL_DOWN);
@@ -61,6 +61,14 @@ struct TestSource : ui::geocaching::Source
}
unsigned pending_reads = 0;
bool detail_ready = false;
unsigned archives = 0;
ui::geocaching::DraftSaveStatus archive_result = ui::geocaching::DraftSaveStatus::Pending;
bool archiveCache(const std::array<uint8_t, 32>&, const std::array<uint8_t, 32>&) override
{
++archives;
return true;
}
ui::geocaching::DraftSaveStatus archiveStatus(const std::array<uint8_t, 32>&) override { return archive_result; }
unsigned detail_closes = 0;
bool readDetail(const std::array<uint8_t, 32>&, const std::array<uint8_t, 32>&,
void (*sink)(const ui::geocaching::DetailView&, void*), void* context) override
@@ -71,6 +79,7 @@ struct TestSource : ui::geocaching::Source
view.status = ui::geocaching::DetailStatus::Ready;
view.description = "A woodland cache beside the old trail.\nFollow the stream to the stone bridge.";
view.hint = "Look beneath the large flat stone.";
view.can_archive = true;
}
sink(view, context);
return true;
@@ -306,6 +315,19 @@ int main(int argc, char** argv)
!std::strstr(lv_label_get_text(lv_obj_get_child(lv_obj_get_child(list, 2), 1)), "large flat stone") ||
lv_obj_has_flag(lv_obj_get_child(list, 2), LV_OBJ_FLAG_HIDDEN)) return 65;
if (!save(std::string(argv[3]) + "-detail.ppm", screen)) return 11;
auto* archive_action = lv_obj_get_child(list, 4);
if (lv_obj_has_flag(archive_action, LV_OBJ_FLAG_HIDDEN)) return 80;
lv_obj_send_event(archive_action, LV_EVENT_CLICKED, nullptr);
if (source.archives || std::strcmp(lv_label_get_text(lv_obj_get_child(archive_action, 0)), "Confirm public archive")) return 81;
lv_obj_send_event(archive_action, LV_EVENT_CLICKED, nullptr);
if (source.archives != 1 || !lv_obj_has_state(archive_action, LV_STATE_DISABLED)) return 82;
lv_tick_inc(600);
lv_timer_handler();
if (std::strcmp(lv_label_get_text(lv_obj_get_child(archive_action, 0)), "Awaiting directory confirmation...")) return 83;
source.archive_result = ui::geocaching::DraftSaveStatus::Saved;
lv_tick_inc(600);
lv_timer_handler();
if (std::strcmp(lv_label_get_text(lv_obj_get_child(archive_action, 0)), "Archived; directory confirmed")) return 84;
lv_obj_scroll_to_view(lv_obj_get_child(list, 2), LV_ANIM_OFF);
if (!save(std::string(argv[3]) + "-detail-hint.ppm", screen)) return 66;
if (lv_obj_get_scroll_y(list) <= 0) return 67;
@@ -54,6 +54,14 @@ struct CacheDetail
}
bool verify(::geocaching::ByteView bytes, ::geocaching::protocol::RecordCrypto& crypto)
{
if (state == State::Saved && !response)
{
response = static_cast<uint8_t*>(::platform::esp::common::memory::allocatePreferred("geocaching.detail.saved", bytes.size, false));
if (!response) return false;
std::memcpy(response, bytes.data, bytes.size);
response_size = bytes.size;
bytes = {response, response_size};
}
constexpr size_t capacity = ::geocaching::kMaxRecordBytes + 64;
auto* scratch = static_cast<uint8_t*>(::platform::esp::common::memory::allocatePreferred("geocaching.detail.verify", capacity, false));
if (!scratch) return false;
@@ -0,0 +1,78 @@
#pragma once
#include "geocaching/storage/cache_head.h"
#include "platform/esp/arduino_common/geocaching/sd_indexed_commit.h"
namespace platform::esp::arduino_common::geocaching
{
// Removes only the selected offline revision. Retaining and advancing the
// installation generation prevents old download tasks from reinstalling it.
// The owner leases the pinned root/frame and allocates this operation in PSRAM.
class SdIndexedRemoveSaved
{
public:
explicit SdIndexedRemoveSaved(const ::geocaching::storage::VolumeInstance& volume) : volume_(volume) {}
bool begin(const ::geocaching::storage::IndexRootView& root, unsigned copy,
const std::array<uint8_t, 32>& cache, const std::array<uint8_t, 32>& hash,
uint8_t* frame, size_t capacity, ::geocaching::storage::IndexRootBytes& candidate)
{
if (result_ != IndexedCommitStep::Idle || copy > 1) return false;
root_ = root;
copy_ = copy;
cache_ = cache;
hash_ = hash;
frame_ = frame;
capacity_ = capacity;
candidate_ = &candidate;
if (!io_.emplace<SdIndexGet>(volume_).begin(root_, 2, {cache_.data(), cache_.size()}, frame, capacity)) return false;
result_ = IndexedCommitStep::Working;
return true;
}
IndexedCommitStep step()
{
using namespace ::geocaching::storage;
if (result_ != IndexedCommitStep::Working) return result_;
if (committing_) return result_ = std::get<SdIndexedCommit>(io_).step();
auto& read = std::get<SdIndexGet>(io_);
const auto status = read.step();
if (status == IndexGetStep::Working) return result_;
if (status != IndexGetStep::Ready)
return result_ = status == IndexGetStep::IoError ? IndexedCommitStep::IoError : status == IndexGetStep::VolumeChanged ? IndexedCommitStep::VolumeChanged
: IndexedCommitStep::Invalid;
CacheHeadView head;
const ::geocaching::ByteView key{cache_.data(), cache_.size()};
if (!decodeCacheHead(key, read.value(), head) || head.current_hash.size != hash_.size() ||
std::memcmp(head.current_hash.data, hash_.data(), hash_.size()) || head.install_generation == UINT64_MAX)
return result_ = IndexedCommitStep::Invalid;
head.current_hash = {};
head.conflict_state = 0;
++head.install_generation;
size_t size = 0;
if (!encodeCacheHead(key, head, value_.data(), value_.size(), size)) return result_ = IndexedCommitStep::Invalid;
mutation_ = {2, key, {value_.data(), size}, false};
if (!io_.emplace<SdIndexedCommit>(volume_).begin(root_, copy_, &mutation_, 1, frame_, capacity_, *candidate_))
return result_ = IndexedCommitStep::Invalid;
committing_ = true;
return result_;
}
bool committed(::geocaching::storage::IndexRootView& out) const
{
out = {};
return result_ == IndexedCommitStep::Verified && committing_ && std::get<SdIndexedCommit>(io_).committed(out);
}
private:
::geocaching::storage::VolumeInstance volume_;
::geocaching::storage::IndexRootView root_;
std::array<uint8_t, 32> cache_{}, hash_{};
std::array<uint8_t, 64> value_{};
::geocaching::storage::MutationView mutation_;
uint8_t* frame_ = nullptr;
size_t capacity_ = 0;
unsigned copy_ = 0;
::geocaching::storage::IndexRootBytes* candidate_ = nullptr;
std::variant<std::monostate, SdIndexGet, SdIndexedCommit> io_;
bool committing_ = false;
IndexedCommitStep result_ = IndexedCommitStep::Idle;
};
static_assert(sizeof(SdIndexedRemoveSaved) <= 1536, "Offline removal owns metadata only, in PSRAM");
} // namespace platform::esp::arduino_common::geocaching
@@ -18,6 +18,7 @@
#include "platform/esp/arduino_common/geocaching/sd_checkpoint_rotation.h"
#include "platform/esp/arduino_common/geocaching/sd_download_port.h"
#include "platform/esp/arduino_common/geocaching/sd_index_repair.h"
#include "platform/esp/arduino_common/geocaching/sd_indexed_remove_saved.h"
#include "platform/esp/arduino_common/geocaching/sd_indexed_stop_task.h"
#include "platform/esp/arduino_common/geocaching/sd_publish_port.h"
#include "platform/esp/arduino_common/geocaching/stored_reply_receipt.h"
@@ -83,6 +84,7 @@ struct PendingReply
std::atomic<PendingReply*> pending_reply{nullptr};
struct Session
{
uint64_t local_map_revision = 0;
struct Publication
{
enum class DraftStage : uint8_t
@@ -91,6 +93,8 @@ struct Session
Bind,
Binding,
Encode,
Archive,
ArchiveSaving,
Sign
};
DraftStage draft_stage = DraftStage::None;
@@ -136,6 +140,9 @@ struct Session
uint64_t expected = 0;
bool started = false, done = false, saved = false, editor_fields = true;
bool erase = false;
bool erase_download = false;
std::array<uint8_t, 32> cache{}, hash{};
::platform::memory::PsramPtr<SdIndexedRemoveSaved> removal;
~DraftSave() { heap_caps_free(bytes); }
};
::platform::memory::PsramPtr<DraftSave> draft_save;
@@ -775,7 +782,11 @@ bool processResponse(Session& s)
auto& attempt = *s.publication->attempt;
const auto before = attempt.phase();
if (attempt.accept(s.response_source, {s.response, s.response_size}) && before != gc::PublishAttemptPhase::Confirmed &&
attempt.phase() == gc::PublishAttemptPhase::Confirmed) reportPublication("confirmed", attempt);
attempt.phase() == gc::PublishAttemptPhase::Confirmed)
{
++s.local_map_revision;
reportPublication("confirmed", attempt);
}
++epoch;
}
else if (s.response_operation == 3 && s.download)
@@ -1053,6 +1064,7 @@ PublicationRestore restorePublication(Session& s, const gc::GeocacheId* cache =
return PublicationRestore::Pending;
}
std::memcpy(job->remote.bytes.data(), selected.key.data() + 16, 16);
job->cache = selected.cache;
job->port.reset(::platform::memory::createPsram<SdPublishPort>(*s.store, s.crypto, s.local, selected.cache, selected.hash, selected.task, selected.created));
if (job->port) job->attempt.reset(::platform::memory::createPsram<gc::PublishAttempt>(*job->port, s.crypto));
if (!job->attempt)
@@ -1133,6 +1145,93 @@ void advanceDraftPublication(Session& s)
++epoch;
return;
}
if (job.draft_stage == Stage::ArchiveSaving)
{
const auto result = s.store->stepCommit();
if (result == JournalWriteResult::Busy || result == JournalWriteResult::InProgress) return;
if (result != JournalWriteResult::Verified)
{
stop("Local archive could not be saved");
return;
}
heap_caps_free(job.draft_bytes);
job.draft_bytes = nullptr;
++s.local_map_revision;
job.draft_stage = Stage::Archive;
job.draft_generation = UINT64_MAX;
return;
}
if (job.draft_stage == Stage::Archive)
{
if (job.draft_generation != UINT64_MAX)
{
gc::RecordView record;
gc::ByteView value;
if (!gc::protocol::decodeGeocacheRecord({job.unsigned_bytes, job.unsigned_size}, record))
{
stop("Invalid archive record");
return;
}
const auto read = s.store->readDraft(record.creation_nonce, value);
if (read == DraftReadResult::Pending || read == DraftReadResult::Busy) return;
if (read == DraftReadResult::Ready)
{
gc::storage::DraftView draft;
if (!gc::storage::decodeDraft(record.creation_nonce, value, draft) || draft.generation == UINT64_MAX ||
draft.author.size != 64 || std::memcmp(draft.author.data, job.author.data(), 64))
{
stop("Local author mismatch");
return;
}
const auto expected = draft.generation;
draft.state = 2;
++draft.generation;
job.draft_size = value.size + 32;
job.draft_bytes = static_cast<uint8_t*>(mem::allocatePreferred("geocaching.archive-draft", job.draft_size, false));
if (!job.draft_bytes || !gc::storage::encodeDraft(record.creation_nonce, draft, job.draft_bytes, job.draft_size, job.draft_size))
{
stop("Cannot retain local archive");
return;
}
s.store->releaseDraftRead();
const auto saved = s.store->saveDraft(record.creation_nonce, {job.draft_bytes, job.draft_size}, expected);
if (saved == JournalWriteResult::Busy)
{
heap_caps_free(job.draft_bytes);
job.draft_bytes = nullptr;
return;
}
if (saved != JournalWriteResult::InProgress && saved != JournalWriteResult::Verified)
{
stop("Local archive save failed");
return;
}
job.draft_stage = Stage::ArchiveSaving;
if (saved == JournalWriteResult::Verified)
{
heap_caps_free(job.draft_bytes);
job.draft_bytes = nullptr;
job.draft_generation = UINT64_MAX;
job.draft_stage = Stage::Archive;
++s.local_map_revision;
}
return;
}
s.store->releaseDraftRead();
if (read != DraftReadResult::NotFound)
{
stop("Cannot read local archive ownership");
return;
}
job.draft_generation = UINT64_MAX;
}
job.author_port.reset(::platform::memory::createPsram<DeviceAuthorIssuePort>(*router, *s.store, s.crypto, job.issued));
if (job.author_port) job.issue.reset(::platform::memory::createPsram<gc::AuthorIssue>(*job.author_port));
if (!job.issue || !job.issue->begin({job.unsigned_bytes, job.unsigned_size}, job.bytes, job.size))
stop("Cannot prepare archive signing");
else job.draft_stage = Stage::Sign;
return;
}
gc::ByteView value;
gc::storage::DraftView draft;
const gc::ByteView key{job.draft_id.data(), job.draft_id.size()};
@@ -1353,6 +1452,13 @@ bool publicationDraftReady(const std::array<uint8_t, 16>& id, uint64_t generatio
class Facade final : public ::ui::geocaching::Source
{
public:
bool localMapRevision(uint64_t& out) override
{
Guard guard;
if (!guard.locked || !session) return false;
out = session->local_map_revision;
return true;
}
void activate(bool open) override
{
if (open && !wanted.load())
@@ -1636,6 +1742,10 @@ class Facade final : public ::ui::geocaching::Source
view.status = ::ui::geocaching::DetailStatus::Ready;
view.description = job->description.data();
view.hint = job->hint.data();
std::array<uint8_t, 64> author{};
view.can_archive = job->verified_record.record.state != gc::CacheState::Archived &&
router->getGeocachingAuthorKey(author.data()) &&
!std::memcmp(author.data(), job->verified_record.record.author_public_key.data, author.size());
}
else if (job->state == CacheDetail::State::Failed)
{
@@ -1650,6 +1760,53 @@ class Facade final : public ::ui::geocaching::Source
cancel_detail.store(true);
next_step.store(0);
}
bool archiveCache(const std::array<uint8_t, 32>& id, const std::array<uint8_t, 32>& hash) override
{
Guard guard;
if (!guard.locked || !session || !session->detail || session->detail->state != CacheDetail::State::Ready ||
!session->detail->matches(id, hash) || !session->port || session->needsRecovery() ||
publicationActive() || downloadActive() || draftSaveActive()) return false;
const auto& verified = session->detail->verified_record;
auto job = ::platform::memory::PsramPtr<Session::Publication>(::platform::memory::createPsram<Session::Publication>());
if (!job || verified.record.revision == UINT32_MAX || verified.record.state == gc::CacheState::Archived ||
!router->getGeocachingAuthorKey(job->author.data()) ||
std::memcmp(job->author.data(), verified.record.author_public_key.data, job->author.size()) ||
!session->port->pageSource(job->remote)) return false;
auto record = verified.record;
++record.revision;
record.previous_hash = {hash.data(), hash.size()};
record.state = gc::CacheState::Archived;
record.updated_at = std::max(record.updated_at, static_cast<uint64_t>(std::time(nullptr)));
job->issued = now(nullptr);
job->issued.has_utc = true;
job->issued.utc_seconds = record.updated_at;
job->cache = verified.id;
job->expected_revision = record.revision;
const size_t capacity = record.name.size() + record.description.size() + record.hint.size() + 240;
job->unsigned_bytes = static_cast<uint8_t*>(mem::allocatePreferred("geocaching.archive", capacity, false));
job->size = capacity + 70;
job->bytes = static_cast<uint8_t*>(mem::allocatePreferred("geocaching.sign", 2 * job->size + 26, false));
if (!job->unsigned_bytes || !job->bytes || !gc::protocol::encodeGeocacheRecord(record, job->unsigned_bytes, capacity, job->unsigned_size)) return false;
job->draft_stage = Session::Publication::DraftStage::Archive;
session->storage_requested = true;
session->publication = std::move(job);
next_step.store(0);
++epoch;
return true;
}
::ui::geocaching::DraftSaveStatus archiveStatus(const std::array<uint8_t, 32>& id) override
{
using Status = ::ui::geocaching::DraftSaveStatus;
Guard guard;
if (!guard.locked) return Status::Pending;
if (!session || !session->publication || session->publication->cache.bytes != id) return Status::Failed;
const auto& job = *session->publication;
if (job.error) return Status::Failed;
if (job.draft_stage != Session::Publication::DraftStage::None || job.bytes) return Status::Pending;
if (!job.attempt) return Status::Failed;
if (job.attempt->phase() == gc::PublishAttemptPhase::Confirmed) return Status::Saved;
return publicationActive() ? Status::Pending : Status::Failed;
}
bool publicationAuthor(const std::array<uint8_t, 16>& id, uint64_t generation, std::array<uint8_t, 64>& author, uint32_t* from, uint32_t* to) override
{
Guard guard;
@@ -1779,7 +1936,7 @@ class Facade final : public ::ui::geocaching::Source
using Status = ::ui::geocaching::DraftSaveStatus;
Guard guard;
if (!guard.locked) return Status::Pending;
if (!session || !session->draft_save || session->draft_save->id != id || session->draft_save->expected != generation) return Status::Failed;
if (!session || !session->draft_save || session->draft_save->erase_download || session->draft_save->id != id || session->draft_save->expected != generation) return Status::Failed;
const auto& job = *session->draft_save;
return !job.done ? Status::Pending : job.saved ? Status::Saved
: Status::Failed;
@@ -1799,6 +1956,33 @@ class Facade final : public ::ui::geocaching::Source
++epoch;
return true;
}
bool removeDownloaded(const ::ui::geocaching::Item& item) override
{
Guard guard;
if (!guard.locked || item.is_draft || !item.downloaded || !session || session->phase != Phase::Ready ||
session->needsRecovery() || downloadActive() || publicationActive() || draftSaveActive() ||
(session->store && session->store->commitPending())) return false;
auto job = ::platform::memory::PsramPtr<Session::DraftSave>(::platform::memory::createPsram<Session::DraftSave>());
if (!job) return false;
job->erase_download = true;
job->cache = item.id;
job->hash = item.revision_hash;
session->draft_save = std::move(job);
next_step.store(0);
++epoch;
return true;
}
::ui::geocaching::DraftSaveStatus downloadedRemovalStatus(const std::array<uint8_t, 32>& id, const std::array<uint8_t, 32>& hash) override
{
using Status = ::ui::geocaching::DraftSaveStatus;
Guard guard;
if (!guard.locked) return Status::Pending;
if (!session || !session->draft_save || !session->draft_save->erase_download ||
session->draft_save->cache != id || session->draft_save->hash != hash) return Status::Failed;
const auto& job = *session->draft_save;
return !job.done ? Status::Pending : job.saved ? Status::Saved
: Status::Failed;
}
bool download(const ::ui::geocaching::Item& item, uint64_t generation) override
{
Guard guard;
@@ -1846,6 +2030,47 @@ class Facade final : public ::ui::geocaching::Source
}
} facade;
bool advanceOfflineRemoval(Session& s)
{
if (!s.draft_save || !s.draft_save->erase_download || s.draft_save->done) return false;
auto& job = *s.draft_save;
if (!s.workspace_owner.acquire(&job)) return false;
auto finish = [&](bool saved)
{
job.done = true;
job.saved = saved;
job.removal.reset();
s.workspace_owner.release(&job);
if (saved)
{
if (s.saved) s.saved->reset();
++s.local_map_revision;
Serial.printf("[Geocaching][LocalDelete] confirmed cache_prefix=%02x%02x%02x%02x\n", job.cache[0], job.cache[1], job.cache[2], job.cache[3]);
}
++epoch;
return true;
};
if (!job.removal)
{
job.removal.reset(::platform::memory::createPsram<SdIndexedRemoveSaved>(s.volume));
if (!job.removal || !job.removal->begin(s.root, s.root_copy, job.cache, job.hash, s.frame, kFrameCapacity, s.roots[1 - s.root_copy]))
return finish(false);
}
const auto result = job.removal->step();
if (result == IndexedCommitStep::Working) return true;
if (result == IndexedCommitStep::Verified)
{
gc::storage::IndexRootView next;
if (!job.removal->committed(next)) return finish(false);
s.root = next;
s.root_copy = 1 - s.root_copy;
return finish(true);
}
if (result == IndexedCommitStep::IoError || result == IndexedCommitStep::VolumeChanged || result == IndexedCommitStep::RecoveryRequired)
s.checkpoint_recovery_required = true;
return finish(false);
}
DispatchResult dispatchForeground(Session& s)
{
std::array<uint8_t, 48> key{};
@@ -1883,6 +2108,11 @@ bool closeSession()
if (!session->needsRecovery() && session->workspace_owner.holder())
{
WorkspaceSlice workspace_slice{*session};
if (session->draft_save && session->draft_save->erase_download && session->workspace_owner.heldBy(session->draft_save.get()))
{
advanceOfflineRemoval(*session);
return false;
}
if (session->dispatch_store && session->workspace_owner.heldBy(session->dispatch_store.get()))
{
dispatchForeground(*session);
@@ -2217,24 +2447,32 @@ void step()
if (s.phase == Phase::Ready && draftSaveActive())
{
auto& job = *s.draft_save;
const auto result = job.started ? s.store->stepCommit() : job.erase ? s.store->deleteDraft({job.id.data(), job.id.size()}, job.expected)
: job.editor_fields ? s.store->editDraft({job.id.data(), job.id.size()}, job.bytes, job.size, job.capacity, job.expected)
: s.store->saveDraft({job.id.data(), job.id.size()}, {job.bytes, job.size}, job.expected);
if (result != JournalWriteResult::Busy)
if (job.erase_download)
{
job.started = true;
if (result != JournalWriteResult::InProgress || s.store->inputConsumed())
if (advanceOfflineRemoval(s)) return;
}
else
{
const auto result = job.started ? s.store->stepCommit() : job.erase ? s.store->deleteDraft({job.id.data(), job.id.size()}, job.expected)
: job.editor_fields ? s.store->editDraft({job.id.data(), job.id.size()}, job.bytes, job.size, job.capacity, job.expected)
: s.store->saveDraft({job.id.data(), job.id.size()}, {job.bytes, job.size}, job.expected);
if (result != JournalWriteResult::Busy)
{
heap_caps_free(job.bytes);
job.bytes = nullptr;
job.started = true;
if (result != JournalWriteResult::InProgress || s.store->inputConsumed())
{
heap_caps_free(job.bytes);
job.bytes = nullptr;
}
if (result != JournalWriteResult::InProgress)
{
job.done = true;
job.saved = result == JournalWriteResult::Verified;
if (job.saved) ++s.local_map_revision;
++epoch;
}
return;
}
if (result != JournalWriteResult::InProgress)
{
job.done = true;
job.saved = result == JournalWriteResult::Verified;
++epoch;
}
return;
}
// Advance the operation that currently owns storage before retrying.
}
@@ -2493,7 +2731,11 @@ void step()
if (phase != gc::PublishAttemptPhase::Waiting)
{
job.attempt->advance();
if (job.attempt->phase() == gc::PublishAttemptPhase::Confirmed) reportPublication("confirmed", *job.attempt);
if (job.attempt->phase() == gc::PublishAttemptPhase::Confirmed)
{
++s.local_map_revision;
reportPublication("confirmed", *job.attempt);
}
++epoch;
return;
}
@@ -2524,7 +2766,11 @@ void step()
{
// Queuing, waiting and cancelling requests do not change saved
// rows. Installation may have committed even if cleanup failed.
if (s.saved && before == gc::DownloadPhase::Installing) s.saved->reset();
if (before == gc::DownloadPhase::Installing)
{
++s.local_map_revision;
if (s.saved) s.saved->reset();
}
++epoch;
}
return;
+17
View File
@@ -142,3 +142,20 @@ Caddy 负责证书和 WebSocket 转发。桥接进程重启也会断开现有连
回退代码前检查数据库格式兼容性。
协议配置参考:<https://reticulum.network/manual/interfaces.html>
### Updating public archive handling
After pulling `codex/geocaching`, rebuild the NAS application image and recreate
the services from the repository root:
```sh
git pull --ff-only origin codex/geocaching
docker compose -f tools/geocaching/deploy/compose.yaml up -d --build
docker compose -f tools/geocaching/deploy/compose.yaml logs --tail=50 directory
```
Keep the existing volumes and configuration. This update does not require a new
directory identity, discovery address, WSS address or GitHub Pages deployment.
Updating firmware alone does not update the NAS directory. The updated directory
hides archived caches and rejects old active-version downloads with
`410 cache_archived`; it retains signed archival records for peer synchronization.
+35 -2
View File
@@ -204,6 +204,13 @@ class DirectoryStore:
prior = self.db.execute("SELECT fingerprint,response,created FROM requests WHERE source=? AND version=? AND id=?", (source, version, request_id)).fetchone()
if prior and prior["created"] + DEDUP_TTL > now:
if prior["fingerprint"] == fingerprint:
if operation == 3 and self._withdrawn(source, body):
return packed([1, 1, operation, request_id, 410, ["cache_archived", None, None]])
if operation == 2:
cached = unpacked(prior["response"])
if cached[4] == 200:
cached[5][1] = self._visible_items(cached[5][1])
return packed(cached)
return prior["response"]
return packed([1, 1, operation, request_id, 409, ["request_id_reused", None, None]])
if self.db.execute("SELECT COUNT(*) FROM requests").fetchone()[0] >= self.max_results:
@@ -252,7 +259,7 @@ class DirectoryStore:
south, west, north, east = bbox
self.db.execute("""INSERT INTO snapshot_items(snapshot,hash)
SELECT ?,o.hash FROM heads h JOIN objects o ON o.hash=h.hash
WHERE h.conflict=0 AND o.latitude BETWEEN ? AND ?
WHERE h.conflict=0 AND o.state!=2 AND o.latitude BETWEEN ? AND ?
AND (o.longitude BETWEEN ? AND ? OR (?=1800000000 AND o.longitude=-1800000000))
AND (? & (1 << o.state))!=0 AND (? IS NULL OR o.author=?)
ORDER BY o.cache LIMIT ?""", (snapshot, south, north, west, east, east, mask, author, author, self.max_snapshot_items + 1))
@@ -266,6 +273,8 @@ class DirectoryStore:
head = self._head(cache_id)
if not head:
raise ProtocolError(404, "not_found")
if self._withdrawn(source, body):
raise ProtocolError(410, "cache_archived")
if wanted is None and head["conflict"]:
raise ProtocolError(409, "version_conflict")
if wanted is None and known == head["hash"]:
@@ -289,6 +298,25 @@ class DirectoryStore:
return 200, self._page(source, 4, binding, token, limit, budget, request_id, now)
raise ProtocolError(501, "unsupported_operation")
def _withdrawn(self, source, body):
if type(body) is not list or len(body) != 3 or not binary(body[0], 32):
return False
head = self._head(body[0])
if not head or head["state"] != 2:
return False
# Explicit tombstone reads and known directory peers retain replication.
if body[1] == head["hash"]:
return False
return self.db.execute("SELECT 1 FROM peers WHERE destination=?", (source,)).fetchone() is None
def _visible_items(self, items):
visible = []
for item in items:
head = self._head(item[0])
if head is not None and head["state"] != 2:
visible.append(item)
return visible
@staticmethod
def _valid_cursor(token):
return token is None or (type(token) is bytes and 1 <= len(token) <= 64)
@@ -389,6 +417,8 @@ class DirectoryStore:
raise ProtocolError(410, expired)
if cursor["body"] is not None:
result = msgpack.unpackb(cursor["body"], raw=False)
if operation == 2:
result[1] = self._visible_items(result[1])
next_cursor = result[2] if operation == 2 else result[3]
expires = cursor["expires"]
if next_cursor is not None:
@@ -410,7 +440,10 @@ class DirectoryStore:
if not snapshot or snapshot["expires"] <= now:
raise ProtocolError(410, expired)
rows = self.db.execute("""SELECT i.position,o.* FROM snapshot_items i JOIN objects o ON o.hash=i.hash
WHERE i.snapshot=? AND i.position>? ORDER BY i.position LIMIT ?""", (snapshot_id, position, limit + 1)).fetchall()
JOIN heads h ON h.cache=o.cache
JOIN objects current ON current.hash=h.hash
WHERE i.snapshot=? AND i.position>? AND (?!=2 OR current.state!=2)
ORDER BY i.position LIMIT ?""", (snapshot_id, position, operation, limit + 1)).fetchall()
items = [msgpack.unpackb(row["summary"], raw=False) if operation == 2 else [row["cache"], row["revision"], row["hash"], row["state"]] for row in rows]
take = min(limit, len(items))
while True:
+33
View File
@@ -83,6 +83,39 @@ class DirectoryTests(unittest.TestCase):
self.assertEqual(self.store.sequence(), 0)
self.assertEqual(self.store.db.execute("SELECT COUNT(*) FROM objects").fetchone()[0], 0)
def test_archive_withdraws_search_and_old_downloads_after_restart(self):
records = [self.record() for _ in range(4)]
for signed in records:
self.assertEqual(self.publish(signed)[4], 200)
search_id = os.urandom(16)
first = self.query(limit=1, request_id=search_id)[5]
victim_id = first[1][0][0]
victim = next(signed for signed in records if verify_cache(signed).cache_id == victim_id)
item = verify_cache(victim)
get_id = os.urandom(16)
self.assertEqual(self.request(3, [item.cache_id, item.revision_hash, None], request_id=get_id)[4], 200)
archive = self.record(victim, **{"5": 2})
tombstone = verify_cache(archive)
self.assertEqual(self.publish(archive)[4], 200)
self.store.close()
self.store = self.open_store()
self.assertNotIn(item.cache_id, [row[0] for row in self.query()[5][1]])
self.assertEqual(self.query(limit=1, request_id=search_id)[5][1], [])
for body in ([item.cache_id, None, None], [item.cache_id, item.revision_hash, None],
[item.cache_id, None, item.revision_hash]):
self.assertEqual(self.request(3, body)[4:6], [410, ["cache_archived", None, None]])
self.assertEqual(self.request(3, [item.cache_id, item.revision_hash, None], request_id=get_id)[4], 410)
# A signed tombstone remains readable so independent directories can
# learn the withdrawal; it never returns the former active payload.
self.assertEqual(self.request(3, [item.cache_id, tombstone.revision_hash, None])[5][0], archive)
cursor = first[2]
remaining = []
while cursor:
page = self.query(cursor, limit=1)[5]
remaining.extend(page[1])
cursor = page[2]
self.assertNotIn(item.cache_id, [row[0] for row in remaining])
def test_query_snapshot_survives_update_restart_and_budget(self):
records = [self.record(**{"8": "X" * 96}) for _ in range(8)]
for signed in records: